synodic-client 0.0.1.dev65__tar.gz → 0.0.1.dev67__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 (80) hide show
  1. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/pyproject.toml +2 -2
  3. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/__init__.py +14 -6
  4. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/action_card.py +19 -42
  5. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/install.py +5 -11
  6. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/install_workers.py +8 -8
  7. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/log_panel.py +3 -4
  8. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/schema.py +7 -71
  9. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/settings.py +28 -34
  10. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/tray.py +15 -2
  11. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/update_banner.py +64 -2
  12. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/theme.py +2 -2
  13. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/update_controller.py +105 -75
  14. synodic_client-0.0.1.dev67/synodic_client/application/update_model.py +121 -0
  15. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_action_card.py +52 -108
  16. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_install_preview.py +29 -13
  17. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_log_panel.py +0 -1
  18. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_preview_model.py +2 -4
  19. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_settings.py +27 -16
  20. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_update_banner.py +1 -2
  21. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_update_controller.py +101 -62
  22. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/LICENSE.md +0 -0
  23. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/README.md +0 -0
  24. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/__init__.py +0 -0
  25. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/__main__.py +0 -0
  26. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/__init__.py +0 -0
  27. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/bootstrap.py +0 -0
  28. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/config_store.py +0 -0
  29. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/data.py +0 -0
  30. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/icon.py +0 -0
  31. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/init.py +0 -0
  32. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/instance.py +0 -0
  33. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/package_state.py +0 -0
  34. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/qt.py +0 -0
  35. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/schema.py +0 -0
  36. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/card.py +0 -0
  37. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/plugin_row.py +0 -0
  38. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/projects.py +0 -0
  39. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/screen.py +0 -0
  40. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/sidebar.py +0 -0
  41. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/spinner.py +0 -0
  42. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/screen/tool_update_controller.py +0 -0
  43. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/uri.py +0 -0
  44. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/application/workers.py +0 -0
  45. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/cli.py +0 -0
  46. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/client.py +0 -0
  47. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/config.py +0 -0
  48. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/logging.py +0 -0
  49. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/protocol.py +0 -0
  50. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/py.typed +0 -0
  51. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/resolution.py +0 -0
  52. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/schema.py +0 -0
  53. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/startup.py +0 -0
  54. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/subprocess_patch.py +0 -0
  55. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/synodic_client/updater.py +0 -0
  56. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/__init__.py +0 -0
  57. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/conftest.py +0 -0
  58. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/__init__.py +0 -0
  59. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/__init__.py +0 -0
  60. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/conftest.py +0 -0
  61. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_gather_packages.py +0 -0
  62. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_logging.py +0 -0
  63. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_sidebar.py +0 -0
  64. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_tray_window_show.py +0 -0
  65. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/qt/test_update_feedback.py +0 -0
  66. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_cli.py +0 -0
  67. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_client_updater.py +0 -0
  68. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_client_version.py +0 -0
  69. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_config.py +0 -0
  70. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_examples.py +0 -0
  71. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_init.py +0 -0
  72. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_install.py +0 -0
  73. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_resolution.py +0 -0
  74. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_updater.py +0 -0
  75. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_uri.py +0 -0
  76. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/test_workers.py +0 -0
  77. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/windows/__init__.py +0 -0
  78. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/windows/conftest.py +0 -0
  79. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/tests/unit/windows/test_protocol.py +0 -0
  80. {synodic_client-0.0.1.dev65 → synodic_client-0.0.1.dev67}/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.dev65
3
+ Version: 0.0.1.dev67
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,7 +8,7 @@ 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.dev78
11
+ Requires-Dist: porringer>=0.2.1.dev79
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1444.dev49733
14
14
  Requires-Dist: typer>=0.24.1
@@ -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.dev78",
13
+ "porringer>=0.2.1.dev79",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev65"
18
+ version = "0.0.1.dev67"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  from datetime import UTC, datetime
10
10
 
11
- from porringer.schema import SetupAction, SkipReason
11
+ from porringer.schema import SetupAction, SetupActionResult, SkipReason
12
12
  from porringer.schema.plugin import PluginKind
13
13
 
14
14
  _SECONDS_PER_MINUTE = 60
@@ -63,18 +63,26 @@ def skip_reason_label(reason: SkipReason | None) -> str:
63
63
  return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())
64
64
 
65
65
 
66
- def format_cli_command(action: SetupAction, *, suppress_description: bool = False) -> str:
66
+ def format_cli_command(
67
+ action: SetupAction,
68
+ *,
69
+ result: SetupActionResult | None = None,
70
+ suppress_description: bool = False,
71
+ ) -> str:
67
72
  """Return a human-readable CLI command string for *action*.
68
73
 
69
- Prefers ``cli_command``, falls back to ``command``, then synthesises
70
- an ``installer install <package>`` string for package actions, and
74
+ Prefers ``result.cli_command`` (populated after dry-run), falls
75
+ back to ``action.command``, then synthesises an
76
+ ``installer install <package>`` string for package actions, and
71
77
  finally returns the action description as a last resort.
72
78
 
73
79
  When *suppress_description* is ``True`` the final description
74
80
  fallback returns an empty string instead.
75
81
  """
76
- if parts := (action.cli_command or action.command):
77
- return ' '.join(parts)
82
+ if result is not None and result.cli_command:
83
+ return ' '.join(result.cli_command)
84
+ if action.command:
85
+ return ' '.join(action.command)
78
86
  if action.kind == PluginKind.PACKAGE and action.package:
79
87
  return f'{action.installer or "pip"} install {action.package}'
80
88
  return '' if suppress_description else action.description
@@ -73,19 +73,6 @@ _SPINNER_INTERVAL = 50
73
73
  _KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}
74
74
 
75
75
 
76
- def action_key(action: SetupAction) -> tuple[object, ...]:
77
- """Return a stable identity key for *action*.
78
-
79
- Uses content-based fields (kind, installer, package name, command)
80
- so the same logical action from different ``execute_stream`` runs
81
- resolves to the same key.
82
- """
83
- pkg_name = str(action.package.name) if action.package else None
84
- pt_name = str(action.plugin_target.name) if action.plugin_target else None
85
- cmd = tuple(action.command) if action.command else None
86
- return (action.kind, action.installer, pkg_name, pt_name, cmd)
87
-
88
-
89
76
  def action_sort_key(action: SetupAction) -> int:
90
77
  """Return a sort key that groups cards by execution phase.
91
78
 
@@ -396,24 +383,6 @@ class ActionCard(QFrame):
396
383
  else:
397
384
  self._prerelease_cb.hide()
398
385
 
399
- def update_command(self, action: SetupAction) -> None:
400
- """Update the CLI command label after the resolved preview arrives.
401
-
402
- Called from the two-phase display flow once ``MANIFEST_LOADED``
403
- provides actions with their ``cli_command`` populated.
404
-
405
- Args:
406
- action: The setup action with resolved CLI command.
407
- """
408
- if self._is_skeleton:
409
- return
410
- cmd_text = format_cli_command(action, suppress_description=True)
411
- if cmd_text:
412
- self._command_label.setText(cmd_text)
413
- self._command_row.show()
414
- else:
415
- self._command_row.hide()
416
-
417
386
  def _populate_status(
418
387
  self,
419
388
  action: SetupAction,
@@ -494,7 +463,7 @@ class ActionCard(QFrame):
494
463
  self._status_label.setText(label)
495
464
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_UPDATE)
496
465
  elif result.skipped:
497
- label = skip_reason_label(result.skip_reason)
466
+ label = '\u2713 ' + skip_reason_label(result.skip_reason)
498
467
  self._status_label.setText(label)
499
468
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
500
469
  elif not result.success:
@@ -517,6 +486,13 @@ class ActionCard(QFrame):
517
486
  else:
518
487
  self._status_label.setToolTip('')
519
488
 
489
+ # CLI command — update with resolved cli_command from result
490
+ assert self._action is not None
491
+ cmd_text = format_cli_command(self._action, result=result, suppress_description=True)
492
+ if cmd_text:
493
+ self._command_label.setText(cmd_text)
494
+ self._command_row.show()
495
+
520
496
  # Version column
521
497
  self._check_available_version = result.available_version
522
498
  if result.installed_version and result.available_version:
@@ -524,6 +500,9 @@ class ActionCard(QFrame):
524
500
  self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: #d7ba7d;')
525
501
  elif result.installed_version:
526
502
  self._version_label.setText(result.installed_version)
503
+ elif result.available_version:
504
+ self._version_label.setText(f'\u2192 {result.available_version}')
505
+ self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: grey;')
527
506
 
528
507
  def finalize_checking(self) -> None:
529
508
  """Resolve a still-pending 'Checking\u2026' status to 'Needed'.
@@ -611,9 +590,8 @@ class ActionCard(QFrame):
611
590
  class ActionCardList(QWidget):
612
591
  """Container of :class:`ActionCard` widgets.
613
592
 
614
- Cards are keyed by :func:`action_key` (content-based) so that
615
- look-ups work across different ``execute_stream`` runs where the
616
- ``SetupAction`` objects are different Python instances.
593
+ Cards are keyed by ``SetupAction`` directly (frozen dataclass) so
594
+ that look-ups work across different ``execute_stream`` runs.
617
595
  """
618
596
 
619
597
  prerelease_toggled = Signal(str, bool)
@@ -629,7 +607,7 @@ class ActionCardList(QWidget):
629
607
  self._layout.addStretch()
630
608
 
631
609
  self._cards: list[ActionCard] = []
632
- self._action_map: dict[tuple[object, ...], ActionCard] = {}
610
+ self._action_map: dict[SetupAction, ActionCard] = {}
633
611
 
634
612
  # ------------------------------------------------------------------
635
613
  # Skeleton loading
@@ -679,7 +657,7 @@ class ActionCardList(QWidget):
679
657
  card.prerelease_toggled.connect(self.prerelease_toggled.emit)
680
658
  self._layout.insertWidget(self._layout.count() - 1, card)
681
659
  self._cards.append(card)
682
- self._action_map[action_key(act)] = card
660
+ self._action_map[act] = card
683
661
 
684
662
  # ------------------------------------------------------------------
685
663
  # Card lookup
@@ -696,11 +674,10 @@ class ActionCardList(QWidget):
696
674
  return len(self._cards)
697
675
 
698
676
  def get_card(self, action: SetupAction) -> ActionCard | None:
699
- """Look up the card for a given action by stable content key.
677
+ """Look up the card for a given action.
700
678
 
701
- Works across different ``execute_stream`` runs the preview
702
- and install phases produce different ``SetupAction`` instances
703
- but the same logical action maps to the same card.
679
+ ``SetupAction`` is a frozen dataclass, so the same logical
680
+ action from different ``execute_stream`` runs hashes equally.
704
681
 
705
682
  Args:
706
683
  action: The setup action to find.
@@ -708,7 +685,7 @@ class ActionCardList(QWidget):
708
685
  Returns:
709
686
  The card widget, or ``None`` if not found.
710
687
  """
711
- return self._action_map.get(action_key(action))
688
+ return self._action_map.get(action)
712
689
 
713
690
  # ------------------------------------------------------------------
714
691
  # Bulk operations
@@ -45,7 +45,7 @@ from PySide6.QtWidgets import (
45
45
 
46
46
  from synodic_client.application.package_state import PackageStateStore
47
47
  from synodic_client.application.screen import skip_reason_label
48
- from synodic_client.application.screen.action_card import ActionCardList, action_key
48
+ from synodic_client.application.screen.action_card import ActionCardList
49
49
  from synodic_client.application.screen.card import CardFrame
50
50
  from synodic_client.application.screen.install_workers import run_install, run_preview
51
51
  from synodic_client.application.screen.log_panel import ExecutionLogPanel
@@ -595,11 +595,11 @@ class SetupPreviewWidget(QWidget):
595
595
  self._install_btn.setEnabled(True)
596
596
 
597
597
  def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None:
598
- """Handle the fully-resolved preview (CLI commands populated).
598
+ """Handle the fully-resolved preview.
599
599
 
600
600
  Called after ``MANIFEST_LOADED`` — cards are already visible
601
- from the earlier ``_on_manifest_parsed`` handler. This only
602
- updates CLI command text and the temp-dir reference.
601
+ from the earlier ``_on_manifest_parsed`` handler. This updates
602
+ the temp-dir reference and emits metadata.
603
603
  """
604
604
  if self._model.preview is None:
605
605
  return
@@ -609,19 +609,13 @@ class SetupPreviewWidget(QWidget):
609
609
  if preview.metadata:
610
610
  self.metadata_ready.emit(preview)
611
611
 
612
- for action in preview.actions:
613
- if action.cli_command:
614
- card = self._card_list.get_card(action)
615
- if card is not None:
616
- card.update_command(action)
617
-
618
612
  def _on_action_checked(self, row: int, result: SetupActionResult) -> None:
619
613
  """Update the model and action card with a dry-run result."""
620
614
  m = self._model
621
615
  if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE:
622
616
  label = skip_reason_label(result.skip_reason)
623
617
  if 0 <= row < len(m.action_states):
624
- m.upgradable_keys.add(action_key(m.action_states[row].action))
618
+ m.upgradable_keys.add(m.action_states[row].action)
625
619
  elif result.skipped:
626
620
  label = skip_reason_label(result.skip_reason)
627
621
  elif not result.success:
@@ -159,10 +159,9 @@ def _dispatch_preview_event(
159
159
  ) -> None:
160
160
  """Route a single preview stream event to the appropriate callback.
161
161
 
162
- Mutates *state* in-place with updated ``action_index`` / ``got_parsed``.
162
+ Mutates *state* in-place (``got_parsed`` flag).
163
163
  """
164
164
  if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest:
165
- state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)}
166
165
  if cb.on_manifest_parsed is not None:
167
166
  cb.on_manifest_parsed(event.manifest, manifest_path, temp_dir_str)
168
167
  state.got_parsed = True
@@ -176,16 +175,17 @@ def _dispatch_preview_event(
176
175
  return
177
176
 
178
177
  if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest:
179
- if not state.got_parsed:
180
- state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)}
181
178
  if cb.on_preview_ready is not None:
182
179
  cb.on_preview_ready(event.manifest, manifest_path, temp_dir_str)
183
180
  return
184
181
 
185
- if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action:
186
- row = state.action_index.get(id(event.action))
187
- if row is not None and cb.on_action_checked is not None:
188
- cb.on_action_checked(row, event.result)
182
+ if (
183
+ event.kind == ProgressEventKind.ACTION_COMPLETED
184
+ and event.result
185
+ and event.action_index is not None
186
+ and cb.on_action_checked is not None
187
+ ):
188
+ cb.on_action_checked(event.action_index, event.result)
189
189
 
190
190
 
191
191
  # ---------------------------------------------------------------------------
@@ -26,7 +26,6 @@ from PySide6.QtWidgets import (
26
26
  )
27
27
 
28
28
  from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label
29
- from synodic_client.application.screen.action_card import action_key
30
29
  from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader
31
30
  from synodic_client.application.theme import (
32
31
  LOG_CHEVRON_STYLE,
@@ -186,7 +185,7 @@ class ExecutionLogPanel(QWidget):
186
185
  self._layout.addStretch()
187
186
 
188
187
  # Map action content-key → section widget for quick lookup
189
- self._sections: dict[tuple[object, ...], ActionLogSection] = {}
188
+ self._sections: dict[SetupAction, ActionLogSection] = {}
190
189
  self._section_count = 0
191
190
 
192
191
  # --- Public API ---
@@ -204,7 +203,7 @@ class ExecutionLogPanel(QWidget):
204
203
  section = ActionLogSection(action, self._section_count, self)
205
204
  # Insert before the stretch
206
205
  self._layout.insertWidget(self._layout.count() - 1, section)
207
- self._sections[action_key(action)] = section
206
+ self._sections[action] = section
208
207
 
209
208
  return section
210
209
 
@@ -217,7 +216,7 @@ class ExecutionLogPanel(QWidget):
217
216
  Returns:
218
217
  The section widget, or ``None`` if not found.
219
218
  """
220
- return self._sections.get(action_key(action))
219
+ return self._sections.get(action)
221
220
 
222
221
  def on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None:
223
222
  """Handle a sub-action progress event.
@@ -11,9 +11,7 @@ from __future__ import annotations
11
11
  import enum
12
12
  from collections.abc import Callable
13
13
  from dataclasses import dataclass, field
14
- from enum import Enum, auto
15
14
  from pathlib import Path
16
- from typing import Protocol, runtime_checkable
17
15
 
18
16
  from porringer.schema import (
19
17
  PluginInfo,
@@ -25,7 +23,6 @@ from porringer.schema import (
25
23
  )
26
24
  from porringer.schema.plugin import RuntimePackageResult
27
25
 
28
- from synodic_client.application.screen.action_card import action_key
29
26
  from synodic_client.application.uri import normalize_manifest_key
30
27
 
31
28
  # ---------------------------------------------------------------------------
@@ -239,7 +236,6 @@ class PreviewModel:
239
236
 
240
237
  def __init__(self) -> None:
241
238
  """Initialise a blank preview model."""
242
- self._action_key = action_key
243
239
  self._normalize = normalize_manifest_key
244
240
 
245
241
  self.phase: PreviewPhase = PreviewPhase.IDLE
@@ -250,19 +246,19 @@ class PreviewModel:
250
246
  self.plugin_installed: dict[str, bool] = {}
251
247
  self.prerelease_overrides: set[str] = set()
252
248
  self.action_states: list[ActionState] = []
253
- self._action_state_map: dict[tuple[object, ...], ActionState] = {}
249
+ self._action_state_map: dict[SetupAction, ActionState] = {}
254
250
  self._action_state_map_len: int = 0
255
- self.upgradable_keys: set[tuple[object, ...]] = set()
251
+ self.upgradable_keys: set[SetupAction] = set()
256
252
  self.checked_count: int = 0
257
253
  self.completed_count: int = 0
258
254
  self.temp_dir: str | None = None
259
255
 
260
256
  # -- Computed helpers --------------------------------------------------
261
257
 
262
- def _ensure_action_state_map(self) -> dict[tuple[object, ...], ActionState]:
263
- """Return the action-key → state lookup, rebuilding if stale."""
258
+ def _ensure_action_state_map(self) -> dict[SetupAction, ActionState]:
259
+ """Return the action → state lookup, rebuilding if stale."""
264
260
  if len(self.action_states) != self._action_state_map_len:
265
- self._action_state_map = {self._action_key(s.action): s for s in self.action_states}
261
+ self._action_state_map = {s.action: s for s in self.action_states}
266
262
  self._action_state_map_len = len(self.action_states)
267
263
  return self._action_state_map
268
264
 
@@ -281,8 +277,8 @@ class PreviewModel:
281
277
  return self.actionable_count > 0 or any(s.action.kind is None for s in self.action_states)
282
278
 
283
279
  def action_state_for(self, act: SetupAction) -> ActionState | None:
284
- """Look up :class:`ActionState` by content key (O(1) amortized)."""
285
- return self._ensure_action_state_map().get(self._action_key(act))
280
+ """Look up :class:`ActionState` for *act* (O(1) amortized)."""
281
+ return self._ensure_action_state_map().get(act)
286
282
 
287
283
  def has_same_manifest(self, key: str) -> bool:
288
284
  """Return ``True`` if *key* matches the current manifest key."""
@@ -341,64 +337,4 @@ class PreviewConfig:
341
337
  class _DispatchState:
342
338
  """Mutable accumulator for :func:`_dispatch_preview_event`."""
343
339
 
344
- action_index: dict[int, int] = field(default_factory=dict)
345
340
  got_parsed: bool = False
346
-
347
-
348
- # ---------------------------------------------------------------------------
349
- # Update view protocol & banner data models
350
- # ---------------------------------------------------------------------------
351
-
352
-
353
- @runtime_checkable
354
- class UpdateView(Protocol):
355
- """Minimal display contract for the self-update lifecycle.
356
-
357
- :class:`UpdateBanner` satisfies this protocol implicitly via
358
- structural typing. The controller broadcasts state transitions
359
- through a ``list[UpdateView]`` so that every window showing update
360
- status stays in sync.
361
- """
362
-
363
- def show_downloading(self, version: str) -> None:
364
- """Indicate that *version* is being downloaded."""
365
- ...
366
-
367
- def show_downloading_progress(self, percentage: int) -> None:
368
- """Update the download progress indicator."""
369
- ...
370
-
371
- def show_ready(self, version: str) -> None:
372
- """Indicate that *version* is downloaded and ready to install."""
373
- ...
374
-
375
- def show_error(self, message: str) -> None:
376
- """Display an error *message* in the update area."""
377
- ...
378
-
379
- def hide_banner(self) -> None:
380
- """Hide the update banner."""
381
- ...
382
-
383
-
384
- class UpdateBannerState(Enum):
385
- """Visual states for the update banner."""
386
-
387
- HIDDEN = auto()
388
- DOWNLOADING = auto()
389
- READY = auto()
390
- ERROR = auto()
391
-
392
-
393
- @dataclass(frozen=True, slots=True)
394
- class _BannerConfig:
395
- """Bundled visual configuration for a banner state transition."""
396
-
397
- state: UpdateBannerState
398
- style: str
399
- icon: str
400
- text: str
401
- text_style: str
402
- version: str = ''
403
- action_label: str = ''
404
- show_progress: bool = False
@@ -32,7 +32,8 @@ from synodic_client.application.config_store import ConfigStore
32
32
  from synodic_client.application.icon import app_icon
33
33
  from synodic_client.application.screen import _format_relative_time
34
34
  from synodic_client.application.screen.card import CardFrame
35
- from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
35
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
36
+ from synodic_client.application.update_model import UpdateModel
36
37
  from synodic_client.logging import log_path, set_debug_level
37
38
  from synodic_client.schema import GITHUB_REPO_URL
38
39
  from synodic_client.startup import is_startup_registered, register_startup, remove_startup
@@ -237,6 +238,32 @@ class SettingsWindow(QMainWindow):
237
238
  card.content_layout.addLayout(row)
238
239
  return card
239
240
 
241
+ # ------------------------------------------------------------------
242
+ # Model binding
243
+ # ------------------------------------------------------------------
244
+
245
+ def connect_model(self, model: UpdateModel) -> None:
246
+ """Connect to an :class:`UpdateModel` for state observation.
247
+
248
+ The model's settings-facing signals drive the update status
249
+ label, check button, restart button, and timestamp label.
250
+ """
251
+ model.status_text_changed.connect(self._on_status_changed)
252
+ model.check_button_enabled_changed.connect(self._check_updates_btn.setEnabled)
253
+ model.restart_visible_changed.connect(self._restart_btn.setVisible)
254
+ model.last_checked_changed.connect(self._on_last_checked_changed)
255
+
256
+ def _on_status_changed(self, text: str, style: str) -> None:
257
+ """Apply a status text and style from the model."""
258
+ self._update_status_label.setText(text)
259
+ self._update_status_label.setStyleSheet(style)
260
+
261
+ def _on_last_checked_changed(self, timestamp: str) -> None:
262
+ """Apply a *last updated* timestamp from the model."""
263
+ relative = _format_relative_time(timestamp)
264
+ self._last_client_update_label.setText(f'Last updated: {relative}')
265
+ self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')
266
+
240
267
  # ------------------------------------------------------------------
241
268
  # Public API
242
269
  # ------------------------------------------------------------------
@@ -275,37 +302,6 @@ class SettingsWindow(QMainWindow):
275
302
  else:
276
303
  self._last_client_update_label.setText('')
277
304
 
278
- def set_update_status(self, text: str, style: str = '') -> None:
279
- """Set the inline status text next to the *Check for Updates* button.
280
-
281
- Args:
282
- text: The status message.
283
- style: Optional stylesheet for the label (e.g. color).
284
- """
285
- self._update_status_label.setText(text)
286
- self._update_status_label.setStyleSheet(style)
287
-
288
- def set_checking(self) -> None:
289
- """Enter the *checking* state — disable button and show status."""
290
- self._check_updates_btn.setEnabled(False)
291
- self._restart_btn.hide()
292
- self._update_status_label.setText('Checking\u2026')
293
- self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
294
-
295
- def reset_check_updates_button(self) -> None:
296
- """Re-enable the *Check for Updates* button after a check completes."""
297
- self._check_updates_btn.setEnabled(True)
298
-
299
- def set_last_checked(self, timestamp: str) -> None:
300
- """Update the *last updated* label from an ISO 8601 timestamp."""
301
- relative = _format_relative_time(timestamp)
302
- self._last_client_update_label.setText(f'Last updated: {relative}')
303
- self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')
304
-
305
- def show_restart_button(self) -> None:
306
- """Show the *Restart & Update* button."""
307
- self._restart_btn.show()
308
-
309
305
  def show(self) -> None:
310
306
  """Sync controls from config, size to content, then show the window."""
311
307
  self.sync_from_config()
@@ -360,8 +356,6 @@ class SettingsWindow(QMainWindow):
360
356
 
361
357
  def _on_check_updates_clicked(self) -> None:
362
358
  """Handle the *Check for Updates* button click."""
363
- self._check_updates_btn.setEnabled(False)
364
- self._update_status_label.setText('Checking\u2026')
365
359
  self.check_updates_requested.emit()
366
360
 
367
361
  def _on_channel_changed(self, index: int) -> None:
@@ -16,6 +16,7 @@ from synodic_client.application.screen.screen import MainWindow
16
16
  from synodic_client.application.screen.settings import SettingsWindow
17
17
  from synodic_client.application.screen.tool_update_controller import ToolUpdateOrchestrator
18
18
  from synodic_client.application.update_controller import UpdateController
19
+ from synodic_client.application.update_model import UpdateModel
19
20
  from synodic_client.client import Client
20
21
 
21
22
  if TYPE_CHECKING:
@@ -66,17 +67,29 @@ class TrayScreen:
66
67
  # MainWindow gear button -> open settings
67
68
  window.settings_requested.connect(self._show_settings)
68
69
 
70
+ # Update model — centralised observable state for the update lifecycle
71
+ self._update_model = UpdateModel()
72
+
69
73
  # Update controller - owns the self-update lifecycle & timer
70
74
  self._banner = window.update_banner
71
75
  self._update_controller = UpdateController(
72
76
  app,
73
77
  client,
74
- [self._banner],
75
- settings_window=self._settings_window,
78
+ self._update_model,
76
79
  store=self._store,
77
80
  )
78
81
  self._update_controller.set_user_active_predicate(self._is_user_active)
79
82
 
83
+ # Connect views to the model
84
+ self._banner.connect_model(self._update_model)
85
+ self._settings_window.connect_model(self._update_model)
86
+
87
+ # Wire user-action signals back to the controller
88
+ self._banner.restart_requested.connect(self._update_controller.request_apply)
89
+ self._banner.retry_requested.connect(self._update_controller.request_retry)
90
+ self._settings_window.check_updates_requested.connect(self._update_controller.request_check)
91
+ self._settings_window.restart_requested.connect(self._update_controller.request_apply)
92
+
80
93
  # Tool update orchestrator - owns tool/package update lifecycle
81
94
  self._tool_orchestrator = ToolUpdateOrchestrator(
82
95
  window,