synodic-client 0.0.1.dev64__tar.gz → 0.0.1.dev65__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 (79) hide show
  1. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/pyproject.toml +2 -2
  3. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/config_store.py +1 -1
  4. synodic_client-0.0.1.dev65/synodic_client/application/package_state.py +125 -0
  5. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/install.py +14 -7
  6. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/install_workers.py +0 -1
  7. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/projects.py +5 -1
  8. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/schema.py +0 -1
  9. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/screen.py +60 -31
  10. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/settings.py +0 -10
  11. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/tool_update_controller.py +10 -4
  12. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/resolution.py +0 -1
  13. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/schema.py +0 -5
  14. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_gather_packages.py +1 -2
  15. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_install_preview.py +6 -9
  16. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_settings.py +0 -27
  17. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_tray_window_show.py +0 -1
  18. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_update_controller.py +0 -1
  19. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_config.py +0 -2
  20. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_resolution.py +0 -1
  21. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/LICENSE.md +0 -0
  22. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/README.md +0 -0
  23. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/__init__.py +0 -0
  24. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/__main__.py +0 -0
  25. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/__init__.py +0 -0
  26. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/bootstrap.py +0 -0
  27. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/data.py +0 -0
  28. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/icon.py +0 -0
  29. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/init.py +0 -0
  30. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/instance.py +0 -0
  31. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/qt.py +0 -0
  32. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/schema.py +0 -0
  33. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/__init__.py +0 -0
  34. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/action_card.py +0 -0
  35. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/card.py +0 -0
  36. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/log_panel.py +0 -0
  37. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/plugin_row.py +0 -0
  38. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/sidebar.py +0 -0
  39. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/spinner.py +0 -0
  40. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/tray.py +0 -0
  41. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/screen/update_banner.py +0 -0
  42. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/theme.py +0 -0
  43. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/update_controller.py +0 -0
  44. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/uri.py +0 -0
  45. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/application/workers.py +0 -0
  46. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/cli.py +0 -0
  47. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/client.py +0 -0
  48. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/config.py +0 -0
  49. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/logging.py +0 -0
  50. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/protocol.py +0 -0
  51. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/py.typed +0 -0
  52. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/startup.py +0 -0
  53. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/subprocess_patch.py +0 -0
  54. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/synodic_client/updater.py +0 -0
  55. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/__init__.py +0 -0
  56. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/conftest.py +0 -0
  57. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/__init__.py +0 -0
  58. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/__init__.py +0 -0
  59. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/conftest.py +0 -0
  60. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_action_card.py +0 -0
  61. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_log_panel.py +0 -0
  62. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_logging.py +0 -0
  63. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_preview_model.py +0 -0
  64. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_sidebar.py +0 -0
  65. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_update_banner.py +0 -0
  66. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/qt/test_update_feedback.py +0 -0
  67. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_cli.py +0 -0
  68. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_client_updater.py +0 -0
  69. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_client_version.py +0 -0
  70. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_examples.py +0 -0
  71. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_init.py +0 -0
  72. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_install.py +0 -0
  73. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_updater.py +0 -0
  74. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_uri.py +0 -0
  75. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/test_workers.py +0 -0
  76. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/windows/__init__.py +0 -0
  77. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/windows/conftest.py +0 -0
  78. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/tests/unit/windows/test_protocol.py +0 -0
  79. {synodic_client-0.0.1.dev64 → synodic_client-0.0.1.dev65}/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.dev64
3
+ Version: 0.0.1.dev65
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.dev77
11
+ Requires-Dist: porringer>=0.2.1.dev78
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.dev77",
13
+ "porringer>=0.2.1.dev78",
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.dev64"
18
+ version = "0.0.1.dev65"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -24,7 +24,7 @@ class ConfigStore(QObject):
24
24
 
25
25
  store = ConfigStore(initial_config)
26
26
  store.changed.connect(some_consumer.on_config_changed)
27
- store.update(auto_apply=False) # persists + emits
27
+ store.update(auto_apply=False) # persists + emits
28
28
  """
29
29
 
30
30
  changed = Signal(object)
@@ -0,0 +1,125 @@
1
+ """Shared package update state registry.
2
+
3
+ Provides :class:`PackageStateStore`, a centralised record of
4
+ per-package update status used by both ToolsView and ProjectsView
5
+ so that version/update information discovered in one view is
6
+ immediately available to the other.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from PySide6.QtCore import QObject, Signal
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class PackageState:
18
+ """Canonical update state for a single package.
19
+
20
+ Keyed by ``(signal_key, name)`` inside :class:`PackageStateStore`.
21
+ """
22
+
23
+ name: str
24
+ """Package name (e.g. ``"ruff"``)."""
25
+
26
+ installed_version: str = ''
27
+ """Currently installed version, or empty if unknown."""
28
+
29
+ available_version: str = ''
30
+ """Latest available version, or empty if unknown."""
31
+
32
+ has_update: bool = False
33
+ """Whether the package has a newer version available."""
34
+
35
+
36
+ class PackageStateStore(QObject):
37
+ """Shared registry of package update states across views.
38
+
39
+ Both ToolsView and ProjectsView write discovered update information
40
+ into the store; each view can then read the canonical state
41
+ regardless of which view made the discovery.
42
+
43
+ :attr:`state_changed` is emitted whenever new data arrives so
44
+ listeners can refresh their badges/labels without polling.
45
+ """
46
+
47
+ state_changed = Signal()
48
+ """Emitted whenever any package state is created or modified."""
49
+
50
+ def __init__(self, parent: QObject | None = None) -> None:
51
+ """Initialise with an empty registry."""
52
+ super().__init__(parent)
53
+ self._data: dict[str, dict[str, PackageState]] = {}
54
+
55
+ # -- Bulk write (ToolsView check_updates) ----------------------------
56
+
57
+ def set_check_results(self, available: dict[str, dict[str, str]]) -> None:
58
+ """Populate from ``check_updates`` results.
59
+
60
+ *available* maps ``{signal_key: {package_name: latest_version}}``
61
+ — the same shape previously stored in
62
+ ``ToolsView._updates_available``.
63
+ """
64
+ self._data.clear()
65
+ for key, packages in available.items():
66
+ for pkg_name, latest in packages.items():
67
+ self._data.setdefault(key, {})[pkg_name] = PackageState(
68
+ name=pkg_name,
69
+ available_version=latest,
70
+ has_update=True,
71
+ )
72
+ self.state_changed.emit()
73
+
74
+ # -- Single-action write (ProjectsView dry-run) ----------------------
75
+
76
+ def record_action_result(
77
+ self,
78
+ signal_key: str,
79
+ pkg_name: str,
80
+ *,
81
+ installed_version: str = '',
82
+ available_version: str = '',
83
+ has_update: bool = False,
84
+ ) -> None:
85
+ """Record a single dry-run result.
86
+
87
+ Merges with any existing state so that data discovered by
88
+ different views accumulates rather than overwrites.
89
+ """
90
+ existing = self._data.get(signal_key, {}).get(pkg_name)
91
+ state = PackageState(
92
+ name=pkg_name,
93
+ installed_version=installed_version or (existing.installed_version if existing else ''),
94
+ available_version=available_version or (existing.available_version if existing else ''),
95
+ has_update=has_update or (existing.has_update if existing else False),
96
+ )
97
+ self._data.setdefault(signal_key, {})[pkg_name] = state
98
+ self.state_changed.emit()
99
+
100
+ # -- Read API --------------------------------------------------------
101
+
102
+ def get_updates(self, signal_key: str) -> dict[str, str]:
103
+ """Return ``{package_name: latest_version}`` for packages with updates.
104
+
105
+ Drop-in replacement for ``_updates_available.get(key, {})``.
106
+ """
107
+ bucket = self._data.get(signal_key, {})
108
+ return {name: s.available_version for name, s in bucket.items() if s.has_update}
109
+
110
+ def has_updates_for(self, signal_key: str) -> bool:
111
+ """Return whether any package under *signal_key* has an update."""
112
+ return any(s.has_update for s in self._data.get(signal_key, {}).values())
113
+
114
+ def get(self, signal_key: str, pkg_name: str) -> PackageState | None:
115
+ """Return the state for a specific package, or ``None``."""
116
+ return self._data.get(signal_key, {}).get(pkg_name)
117
+
118
+ @property
119
+ def has_data(self) -> bool:
120
+ """Return whether any update data has been recorded."""
121
+ return bool(self._data)
122
+
123
+ def clear(self) -> None:
124
+ """Remove all recorded state."""
125
+ self._data.clear()
@@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
43
43
  QWidget,
44
44
  )
45
45
 
46
+ from synodic_client.application.package_state import PackageStateStore
46
47
  from synodic_client.application.screen import skip_reason_label
47
48
  from synodic_client.application.screen.action_card import ActionCardList, action_key
48
49
  from synodic_client.application.screen.card import CardFrame
@@ -113,6 +114,7 @@ class SetupPreviewWidget(QWidget):
113
114
  *,
114
115
  show_close: bool = True,
115
116
  config: ResolvedConfig | None = None,
117
+ package_store: PackageStateStore | None = None,
116
118
  ) -> None:
117
119
  """Initialize the preview widget.
118
120
 
@@ -122,11 +124,13 @@ class SetupPreviewWidget(QWidget):
122
124
  show_close: Whether to show the Close button. Set ``False``
123
125
  when embedding inside a persistent view (e.g. a tab).
124
126
  config: Global configuration for per-manifest pre-release state.
127
+ package_store: Shared package update state registry.
125
128
  """
126
129
  super().__init__(parent)
127
130
  self._porringer = porringer
128
131
  self._show_close = show_close
129
132
  self._config = config
133
+ self._package_store = package_store
130
134
  self._discovered_plugins: DiscoveredPlugins | None = None
131
135
 
132
136
  self._model = PreviewModel()
@@ -293,7 +297,6 @@ class SetupPreviewWidget(QWidget):
293
297
  path_or_url: str,
294
298
  *,
295
299
  project_directory: Path | None = None,
296
- detect_updates: bool = True,
297
300
  ) -> None:
298
301
  """Load a manifest preview, or skip if the same manifest is already showing results.
299
302
 
@@ -305,7 +308,6 @@ class SetupPreviewWidget(QWidget):
305
308
  Args:
306
309
  path_or_url: Manifest path or URL.
307
310
  project_directory: Working directory for project sync actions.
308
- detect_updates: Query package indices for newer versions.
309
311
  """
310
312
  key = normalize_manifest_key(path_or_url)
311
313
 
@@ -345,7 +347,6 @@ class SetupPreviewWidget(QWidget):
345
347
  self._run_preview_task(
346
348
  path_or_url,
347
349
  project_directory=self._model.project_directory,
348
- detect_updates=detect_updates,
349
350
  prerelease_packages=overrides,
350
351
  ),
351
352
  )
@@ -474,7 +475,6 @@ class SetupPreviewWidget(QWidget):
474
475
  path_or_url: str,
475
476
  *,
476
477
  project_directory: Path | None = None,
477
- detect_updates: bool = True,
478
478
  prerelease_packages: set[str] | None = None,
479
479
  ) -> None:
480
480
  """Run the preview coroutine and route completion/errors."""
@@ -484,7 +484,6 @@ class SetupPreviewWidget(QWidget):
484
484
  path_or_url,
485
485
  config=PreviewConfig(
486
486
  project_directory=project_directory,
487
- detect_updates=detect_updates,
488
487
  prerelease_packages=prerelease_packages,
489
488
  ),
490
489
  callbacks=PreviewCallbacks(
@@ -640,6 +639,16 @@ class SetupPreviewWidget(QWidget):
640
639
  if card is not None:
641
640
  card.set_check_result(result)
642
641
 
642
+ # Record in shared store so ToolsView can reflect the update
643
+ if self._package_store is not None and action.installer and action.package:
644
+ self._package_store.record_action_result(
645
+ action.installer,
646
+ str(action.package.name),
647
+ installed_version=result.installed_version or '',
648
+ available_version=result.available_version or '',
649
+ has_update=result.skip_reason == SkipReason.UPDATE_AVAILABLE,
650
+ )
651
+
643
652
  # Update phase text
644
653
  m.checked_count += 1
645
654
  total = len(m.action_states)
@@ -987,11 +996,9 @@ class InstallPreviewWindow(QMainWindow):
987
996
  logger.info('Starting install preview for: %s', self._manifest_url)
988
997
  self._url_label.setText(f'<b>Manifest:</b> {self._manifest_url}')
989
998
 
990
- detect = self._config.detect_updates if self._config else True
991
999
  self._preview_widget.load(
992
1000
  self._manifest_url,
993
1001
  project_directory=self._project_directory,
994
- detect_updates=detect,
995
1002
  )
996
1003
 
997
1004
  # --- Callbacks ---
@@ -232,7 +232,6 @@ async def run_preview(
232
232
  paths=[manifest_path],
233
233
  dry_run=True,
234
234
  project_directory=cfg.project_directory,
235
- detect_updates=cfg.detect_updates,
236
235
  prerelease_packages=cfg.prerelease_packages,
237
236
  )
238
237
  state = _DispatchState()
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
20
20
  )
21
21
 
22
22
  from synodic_client.application.data import DataCoordinator
23
+ from synodic_client.application.package_state import PackageStateStore
23
24
  from synodic_client.application.screen.install import SetupPreviewWidget
24
25
  from synodic_client.application.screen.schema import PreviewPhase
25
26
  from synodic_client.application.screen.sidebar import ManifestSidebar
@@ -48,6 +49,7 @@ class ProjectsView(QWidget):
48
49
  parent: QWidget | None = None,
49
50
  *,
50
51
  coordinator: DataCoordinator | None = None,
52
+ package_store: PackageStateStore | None = None,
51
53
  ) -> None:
52
54
  """Initialize the projects view.
53
55
 
@@ -57,11 +59,13 @@ class ProjectsView(QWidget):
57
59
  parent: Optional parent widget.
58
60
  coordinator: Shared data coordinator for validated directory
59
61
  data.
62
+ package_store: Shared package update state registry.
60
63
  """
61
64
  super().__init__(parent)
62
65
  self._porringer = porringer
63
66
  self._store = store
64
67
  self._coordinator = coordinator
68
+ self._package_store = package_store
65
69
  self._refresh_in_progress = False
66
70
  self._pending_select: Path | None = None
67
71
  self._widgets: dict[Path, SetupPreviewWidget] = {}
@@ -166,7 +170,6 @@ class ProjectsView(QWidget):
166
170
  widget.load(
167
171
  str(path),
168
172
  project_directory=path if path.is_dir() else path.parent,
169
- detect_updates=self._store.config.detect_updates,
170
173
  )
171
174
 
172
175
  except Exception:
@@ -200,6 +203,7 @@ class ProjectsView(QWidget):
200
203
  self,
201
204
  show_close=False,
202
205
  config=self._store.config,
206
+ package_store=self._package_store,
203
207
  )
204
208
  widget._discovered_plugins = discovered
205
209
  widget.install_finished.connect(self._on_install_finished)
@@ -334,7 +334,6 @@ class PreviewConfig:
334
334
  """Optional execution parameters for :func:`run_preview`."""
335
335
 
336
336
  project_directory: Path | None = None
337
- detect_updates: bool = True
338
337
  prerelease_packages: set[str] | None = None
339
338
 
340
339
 
@@ -37,6 +37,7 @@ from PySide6.QtWidgets import (
37
37
  from synodic_client.application.config_store import ConfigStore
38
38
  from synodic_client.application.data import DataCoordinator
39
39
  from synodic_client.application.icon import app_icon
40
+ from synodic_client.application.package_state import PackageStateStore
40
41
  from synodic_client.application.screen.plugin_row import (
41
42
  FilterChip,
42
43
  PluginKindHeader,
@@ -115,6 +116,7 @@ class ToolsView(QWidget):
115
116
  parent: QWidget | None = None,
116
117
  *,
117
118
  coordinator: DataCoordinator | None = None,
119
+ package_store: PackageStateStore | None = None,
118
120
  ) -> None:
119
121
  """Initialize the tools view.
120
122
 
@@ -125,22 +127,25 @@ class ToolsView(QWidget):
125
127
  coordinator: Shared data coordinator. When provided, the
126
128
  view delegates plugin/directory fetching to the
127
129
  coordinator instead of calling porringer directly.
130
+ package_store: Shared package update state registry.
128
131
  """
129
132
  super().__init__(parent)
130
133
  self._porringer = porringer
131
134
  self._store = store
132
135
  self._coordinator = coordinator
136
+ self._package_store = package_store
133
137
  self._section_widgets: list[QWidget] = []
134
138
  self._filter_chips: dict[str, FilterChip] = {}
135
139
  self._deselected_plugins: set[str] = set()
136
140
  self._refresh_in_progress = False
137
141
  self._check_in_progress = False
138
- self._updates_checked = False
139
- self._updates_available: dict[str, dict[str, str]] = {}
140
142
  self._directories: list[ManifestDirectory] = []
141
143
  self._timestamp_timer: QTimer | None = None
142
144
  self._init_ui()
143
145
 
146
+ if self._package_store is not None:
147
+ self._package_store.state_changed.connect(self._on_package_state_changed)
148
+
144
149
  def _init_ui(self) -> None:
145
150
  """Initialize the UI components."""
146
151
  outer = QVBoxLayout(self)
@@ -234,6 +239,11 @@ class ToolsView(QWidget):
234
239
 
235
240
  # --- Public API ---
236
241
 
242
+ def invalidate_update_data(self) -> None:
243
+ """Clear cached update state so the next refresh re-checks."""
244
+ if self._package_store is not None:
245
+ self._package_store.clear()
246
+
237
247
  def refresh(self) -> None:
238
248
  """Schedule an asynchronous rebuild of the tool list."""
239
249
  if self._refresh_in_progress:
@@ -257,7 +267,7 @@ class ToolsView(QWidget):
257
267
 
258
268
  try:
259
269
  data = await self._gather_refresh_data()
260
- need_deferred_check = not self._updates_checked
270
+ need_deferred_check = not self._has_update_data
261
271
  self._build_widget_tree(data)
262
272
  except Exception:
263
273
  logger.exception('Failed to refresh tools')
@@ -272,7 +282,7 @@ class ToolsView(QWidget):
272
282
  # Fire-and-forget: detect updates in the background, then patch
273
283
  # the just-rendered widget tree with update badges.
274
284
  if need_deferred_check:
275
- asyncio.create_task(self._deferred_update_check(self._directories))
285
+ asyncio.create_task(self._deferred_update_check())
276
286
 
277
287
  # ------------------------------------------------------------------
278
288
  # _async_refresh helper methods
@@ -445,7 +455,6 @@ class ToolsView(QWidget):
445
455
  return
446
456
 
447
457
  auto_val = auto_update_map.get(plugin.name, True)
448
- plugin_updates = self._updates_available.get(plugin.name, {})
449
458
  tool_timestamps = self._store.config.last_tool_updates or {}
450
459
  default_exe = data.default_runtime_executable
451
460
 
@@ -462,11 +471,14 @@ class ToolsView(QWidget):
462
471
  if is_default:
463
472
  tag_text += ' (default)'
464
473
 
474
+ # Runtime updates use composite keys "plugin:tag"
475
+ rt_updates = self._get_plugin_updates(f'{plugin.name}:{rt.tag}')
476
+
465
477
  provider = PluginProviderHeader(
466
478
  plugin,
467
479
  auto_val is not False,
468
480
  show_controls=True,
469
- has_updates=bool(plugin_updates),
481
+ has_updates=bool(rt_updates),
470
482
  parent=self._container,
471
483
  )
472
484
  provider.set_runtime(rt.tag, label=tag_text)
@@ -496,7 +508,7 @@ class ToolsView(QWidget):
496
508
  plugin_name=plugin.name,
497
509
  auto_update=pkg_auto,
498
510
  show_toggle=True,
499
- has_update=pkg.name in plugin_updates,
511
+ has_update=pkg.name in rt_updates,
500
512
  is_global=True,
501
513
  host_tool=pkg.host_tool,
502
514
  runtime_tag=rt.tag,
@@ -518,7 +530,7 @@ class ToolsView(QWidget):
518
530
  ``ProjectChildRow`` widgets are no longer used.
519
531
  """
520
532
  auto_val = auto_update_map.get(plugin.name, True)
521
- plugin_updates = self._updates_available.get(plugin.name, {})
533
+ plugin_updates = self._get_plugin_updates(plugin.name)
522
534
 
523
535
  provider = PluginProviderHeader(
524
536
  plugin,
@@ -1121,11 +1133,15 @@ class ToolsView(QWidget):
1121
1133
  asyncio.create_task(self._run_inline_update_check())
1122
1134
 
1123
1135
  async def _run_inline_update_check(self) -> None:
1124
- """Check for updates with inline spinners (no overlay / rebuild)."""
1136
+ """Check for updates with inline spinners (no overlay / rebuild).
1137
+
1138
+ Used by both the manual *Check for Updates* button and the
1139
+ automatic deferred check after initial refresh.
1140
+ """
1125
1141
  self._check_in_progress = True
1126
1142
  try:
1127
- self._updates_available = await self._check_for_updates(self._directories)
1128
- self._updates_checked = True
1143
+ available = await self._check_for_updates(self._directories)
1144
+ self._store_check_results(available)
1129
1145
  self._apply_update_badges()
1130
1146
  except Exception:
1131
1147
  logger.debug('Inline update check failed', exc_info=True)
@@ -1225,7 +1241,6 @@ class ToolsView(QWidget):
1225
1241
  params = SetupParameters(
1226
1242
  paths=[str(manifest_path)],
1227
1243
  dry_run=True,
1228
- detect_updates=True,
1229
1244
  project_directory=path,
1230
1245
  )
1231
1246
  async for event in self._porringer.sync.execute_stream(params):
@@ -1247,31 +1262,42 @@ class ToolsView(QWidget):
1247
1262
  )
1248
1263
  return available
1249
1264
 
1250
- async def _deferred_update_check(
1251
- self,
1252
- directories: list[ManifestDirectory],
1253
- ) -> None:
1265
+ async def _deferred_update_check(self) -> None:
1254
1266
  """Run update detection in the background, then patch the widget tree.
1255
1267
 
1256
1268
  Called after the initial render so the user sees the tool list
1257
1269
  immediately while update badges are populated asynchronously.
1258
- Inline per-row spinners provide visual feedback.
1270
+ Delegates to :meth:`_run_inline_update_check`.
1259
1271
  """
1260
- self._check_in_progress = True
1261
1272
  self._check_btn.setEnabled(False)
1262
1273
  self._check_btn.setText('Checking\u2026')
1263
1274
  self._set_all_checking(True)
1264
- try:
1265
- self._updates_available = await self._check_for_updates(directories)
1266
- self._updates_checked = True
1275
+ await self._run_inline_update_check()
1276
+
1277
+ def _get_plugin_updates(self, signal_key: str) -> dict[str, str]:
1278
+ """Return ``{package_name: latest_version}`` for *signal_key*.
1279
+
1280
+ Reads from the shared :class:`PackageStateStore` when available,
1281
+ otherwise returns an empty dict.
1282
+ """
1283
+ if self._package_store is not None:
1284
+ return self._package_store.get_updates(signal_key)
1285
+ return {}
1286
+
1287
+ def _store_check_results(self, available: dict[str, dict[str, str]]) -> None:
1288
+ """Push check results into the store."""
1289
+ if self._package_store is not None:
1290
+ self._package_store.set_check_results(available)
1291
+
1292
+ @property
1293
+ def _has_update_data(self) -> bool:
1294
+ """Return whether update data has been fetched at least once."""
1295
+ return self._package_store is not None and self._package_store.has_data
1296
+
1297
+ def _on_package_state_changed(self) -> None:
1298
+ """Re-apply badges when another view writes to the shared store."""
1299
+ if not self._check_in_progress and not self._refresh_in_progress:
1267
1300
  self._apply_update_badges()
1268
- except Exception:
1269
- logger.debug('Deferred update check failed', exc_info=True)
1270
- finally:
1271
- self._set_all_checking(False)
1272
- self._check_btn.setEnabled(True)
1273
- self._check_btn.setText('Check for Updates')
1274
- self._check_in_progress = False
1275
1301
 
1276
1302
  def _apply_update_badges(self) -> None:
1277
1303
  """Walk existing widgets and show/hide Update buttons + set inline status."""
@@ -1279,12 +1305,12 @@ class ToolsView(QWidget):
1279
1305
  for widget in self._section_widgets:
1280
1306
  if isinstance(widget, PluginProviderHeader):
1281
1307
  current_plugin = widget._signal_key
1282
- plugin_updates = self._updates_available.get(current_plugin, {})
1308
+ plugin_updates = self._get_plugin_updates(current_plugin)
1283
1309
  has = bool(plugin_updates)
1284
1310
  if widget._update_btn is not None:
1285
1311
  widget._update_btn.setVisible(has)
1286
1312
  elif isinstance(widget, PluginRow) and widget._plugin_name:
1287
- plugin_updates = self._updates_available.get(widget._signal_key, {})
1313
+ plugin_updates = self._get_plugin_updates(widget._signal_key)
1288
1314
  latest_version = plugin_updates.get(widget._package_name)
1289
1315
  has_update = latest_version is not None
1290
1316
 
@@ -1295,7 +1321,7 @@ class ToolsView(QWidget):
1295
1321
  if has_update:
1296
1322
  version_text = f'v{latest_version} available' if latest_version else 'Update available'
1297
1323
  widget.set_update_status(version_text, PLUGIN_ROW_STATUS_AVAILABLE_STYLE)
1298
- elif self._updates_checked:
1324
+ elif self._has_update_data:
1299
1325
  widget.set_update_status('Up to date', PLUGIN_ROW_STATUS_UP_TO_DATE_STYLE)
1300
1326
 
1301
1327
  def _set_all_checking(self, checking: bool) -> None:
@@ -1401,6 +1427,7 @@ class MainWindow(QMainWindow):
1401
1427
  self._porringer = porringer
1402
1428
  self._store = store
1403
1429
  self._coordinator: DataCoordinator | None = DataCoordinator(porringer) if porringer is not None else None
1430
+ self._package_store: PackageStateStore | None = PackageStateStore(self) if porringer is not None else None
1404
1431
  self.setWindowTitle('Synodic Client')
1405
1432
  self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
1406
1433
  self.setWindowIcon(app_icon())
@@ -1453,6 +1480,7 @@ class MainWindow(QMainWindow):
1453
1480
  self._store,
1454
1481
  self,
1455
1482
  coordinator=self._coordinator,
1483
+ package_store=self._package_store,
1456
1484
  )
1457
1485
  self._tabs.addTab(self._projects_view, 'Projects')
1458
1486
 
@@ -1461,6 +1489,7 @@ class MainWindow(QMainWindow):
1461
1489
  self._store,
1462
1490
  self,
1463
1491
  coordinator=self._coordinator,
1492
+ package_store=self._package_store,
1464
1493
  )
1465
1494
  self._tabs.addTab(self._tools_view, 'Tools')
1466
1495
  self.tools_view_created.emit(self._tools_view)
@@ -182,11 +182,6 @@ class SettingsWindow(QMainWindow):
182
182
  row.addStretch()
183
183
  content.addLayout(row)
184
184
 
185
- # Detect updates during previews
186
- self._detect_updates_check = QCheckBox('Detect updates during previews')
187
- self._detect_updates_check.toggled.connect(self._on_detect_updates_changed)
188
- content.addWidget(self._detect_updates_check)
189
-
190
185
  # Automatically apply updates
191
186
  self._auto_apply_check = QCheckBox('Automatically apply updates')
192
187
  self._auto_apply_check.toggled.connect(self._on_auto_apply_changed)
@@ -266,7 +261,6 @@ class SettingsWindow(QMainWindow):
266
261
  self._tool_update_spin.setValue(config.tool_update_interval_minutes)
267
262
 
268
263
  # Checkboxes
269
- self._detect_updates_check.setChecked(config.detect_updates)
270
264
  self._auto_apply_check.setChecked(config.auto_apply)
271
265
  self._auto_start_check.setChecked(is_startup_registered())
272
266
 
@@ -351,7 +345,6 @@ class SettingsWindow(QMainWindow):
351
345
  self._source_edit,
352
346
  self._auto_update_spin,
353
347
  self._tool_update_spin,
354
- self._detect_updates_check,
355
348
  self._auto_apply_check,
356
349
  self._auto_start_check,
357
350
  self._debug_logging_check,
@@ -390,9 +383,6 @@ class SettingsWindow(QMainWindow):
390
383
  def _on_tool_update_interval_changed(self, value: int) -> None:
391
384
  self._persist(tool_update_interval_minutes=value)
392
385
 
393
- def _on_detect_updates_changed(self, checked: bool) -> None:
394
- self._persist(detect_updates=checked)
395
-
396
386
  def _on_auto_apply_changed(self, checked: bool) -> None:
397
387
  self._persist(auto_apply=checked)
398
388
 
@@ -438,7 +438,7 @@ class ToolUpdateOrchestrator:
438
438
  # not None) call show() below which triggers the refresh.
439
439
  tools_view = self._window.tools_view
440
440
  if tools_view is not None:
441
- tools_view._updates_checked = False
441
+ tools_view.invalidate_update_data()
442
442
  if self._window.isVisible() and target is None:
443
443
  tools_view.refresh()
444
444
 
@@ -509,7 +509,10 @@ class ToolUpdateOrchestrator:
509
509
  except Exception as exc:
510
510
  logger.exception('Package removal failed')
511
511
  self._fail_package_update(
512
- plugin_name, package_name, f'Failed to remove {package_name}: {exc}', removing=True,
512
+ plugin_name,
513
+ package_name,
514
+ f'Failed to remove {package_name}: {exc}',
515
+ removing=True,
513
516
  )
514
517
 
515
518
  def _on_package_remove_finished(
@@ -523,7 +526,10 @@ class ToolUpdateOrchestrator:
523
526
  detail = result.message or 'Unknown error'
524
527
  logger.warning('Package removal failed for %s/%s: %s', plugin_name, package_name, detail)
525
528
  self._fail_package_update(
526
- plugin_name, package_name, f'Could not remove {package_name}: {detail}', removing=True,
529
+ plugin_name,
530
+ package_name,
531
+ f'Could not remove {package_name}: {detail}',
532
+ removing=True,
527
533
  )
528
534
  return
529
535
 
@@ -532,5 +538,5 @@ class ToolUpdateOrchestrator:
532
538
  tools_view = self._window.tools_view
533
539
  if tools_view is not None:
534
540
  tools_view.set_package_removing(plugin_name, package_name, False)
535
- tools_view._updates_checked = False
541
+ tools_view.invalidate_update_data()
536
542
  tools_view.refresh()
@@ -122,7 +122,6 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig:
122
122
  auto_update_interval_minutes=auto_interval,
123
123
  tool_update_interval_minutes=tool_interval,
124
124
  plugin_auto_update=user.plugin_auto_update,
125
- detect_updates=user.detect_updates,
126
125
  prerelease_packages=user.prerelease_packages,
127
126
  auto_apply=auto_apply,
128
127
  auto_start=auto_start,
@@ -78,10 +78,6 @@ class UserConfig(BaseModel):
78
78
  # ``None`` or absent means all plugins auto-update with manifest-aware defaults.
79
79
  plugin_auto_update: dict[str, bool | dict[str, bool]] | None = None
80
80
 
81
- # Check for updates during dry-run previews. When True the preview
82
- # will query package indices for newer versions.
83
- detect_updates: bool = True
84
-
85
81
  # Per-manifest pre-release overrides. Outer key is a normalised
86
82
  # manifest path (or URL for remote manifests) produced by
87
83
  # ``normalize_manifest_key()``. Inner value is a sorted list of
@@ -231,7 +227,6 @@ class ResolvedConfig:
231
227
  auto_update_interval_minutes: int
232
228
  tool_update_interval_minutes: int
233
229
  plugin_auto_update: dict[str, bool | dict[str, bool]] | None
234
- detect_updates: bool
235
230
  prerelease_packages: dict[str, list[str]] | None
236
231
  auto_apply: bool
237
232
  auto_start: bool
@@ -1,4 +1,4 @@
1
- """Tests for ToolsView._gather_packages global + per-directory queries."""
1
+ """Tests for ToolsView._gather_packages global + per-directory queries."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -45,7 +45,6 @@ def _make_config() -> ResolvedConfig:
45
45
  auto_update_interval_minutes=60,
46
46
  tool_update_interval_minutes=60,
47
47
  plugin_auto_update=None,
48
- detect_updates=False,
49
48
  prerelease_packages=None,
50
49
  auto_apply=True,
51
50
  auto_start=False,
@@ -662,12 +662,12 @@ class TestPreviewWorkerSignals:
662
662
  assert order == ['parsed', 'plugins', 'ready']
663
663
 
664
664
 
665
- class TestPreviewWorkerUpdateDetection:
666
- """Tests for run_preview passing update-detection flags to porringer."""
665
+ class TestPreviewWorkerPrerelease:
666
+ """Tests for run_preview passing prerelease config to porringer."""
667
667
 
668
668
  @staticmethod
669
- def test_passes_detect_updates_and_prerelease_packages(tmp_path: Path) -> None:
670
- """Verify detect_updates and prerelease_packages are forwarded to SetupParameters."""
669
+ def test_passes_prerelease_packages(tmp_path: Path) -> None:
670
+ """Verify prerelease_packages are forwarded to SetupParameters."""
671
671
  manifest = tmp_path / 'porringer.json'
672
672
  manifest.write_text('{}')
673
673
 
@@ -688,19 +688,17 @@ class TestPreviewWorkerUpdateDetection:
688
688
  porringer,
689
689
  str(manifest),
690
690
  config=PreviewConfig(
691
- detect_updates=True,
692
691
  prerelease_packages={'some-pkg'},
693
692
  ),
694
693
  ),
695
694
  )
696
695
 
697
696
  assert len(captured_params) == 1
698
- assert captured_params[0].detect_updates is True
699
697
  assert captured_params[0].prerelease_packages == {'some-pkg'}
700
698
 
701
699
  @staticmethod
702
- def test_defaults_detect_updates_true(tmp_path: Path) -> None:
703
- """Verify detect_updates defaults to True."""
700
+ def test_defaults_prerelease_none(tmp_path: Path) -> None:
701
+ """Verify prerelease_packages defaults to None."""
704
702
  manifest = tmp_path / 'porringer.json'
705
703
  manifest.write_text('{}')
706
704
 
@@ -719,7 +717,6 @@ class TestPreviewWorkerUpdateDetection:
719
717
  asyncio.run(run_preview(porringer, str(manifest)))
720
718
 
721
719
  assert len(captured_params) == 1
722
- assert captured_params[0].detect_updates is True
723
720
  assert captured_params[0].prerelease_packages is None
724
721
 
725
722
 
@@ -24,7 +24,6 @@ def _make_config(**overrides: Any) -> ResolvedConfig:
24
24
  'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
25
25
  'tool_update_interval_minutes': DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
26
26
  'plugin_auto_update': None,
27
- 'detect_updates': True,
28
27
  'prerelease_packages': None,
29
28
  'auto_apply': True,
30
29
  'auto_start': True,
@@ -144,20 +143,6 @@ class TestSyncFromConfig:
144
143
  window.sync_from_config()
145
144
  assert window._tool_update_spin.value() == custom_interval
146
145
 
147
- @staticmethod
148
- def test_detect_updates_true_default() -> None:
149
- """Default detect_updates is checked."""
150
- window = _make_window(_make_config())
151
- window.sync_from_config()
152
- assert window._detect_updates_check.isChecked() is True
153
-
154
- @staticmethod
155
- def test_detect_updates_false() -> None:
156
- """Disabled detect_updates is unchecked."""
157
- window = _make_window(_make_config(detect_updates=False))
158
- window.sync_from_config()
159
- assert window._detect_updates_check.isChecked() is False
160
-
161
146
  @staticmethod
162
147
  def test_auto_start_reflects_registry() -> None:
163
148
  """Auto-start checkbox mirrors the OS registration state."""
@@ -250,18 +235,6 @@ class TestSettingsCallbacks:
250
235
 
251
236
  mock_update.assert_called_with(tool_update_interval_minutes=new_interval)
252
237
 
253
- @staticmethod
254
- def test_detect_updates_change() -> None:
255
- """Toggling detect_updates saves via the store."""
256
- config = _make_config()
257
- window = _make_window(config)
258
- window.sync_from_config()
259
-
260
- with patch.object(window._store, 'update') as mock_update:
261
- window._detect_updates_check.setChecked(False)
262
-
263
- mock_update.assert_called_with(detect_updates=False)
264
-
265
238
  @staticmethod
266
239
  def test_auto_start_registers_startup_when_frozen() -> None:
267
240
  """Enabling auto-start calls register_startup in frozen builds."""
@@ -20,7 +20,6 @@ def _make_config() -> ResolvedConfig:
20
20
  auto_update_interval_minutes=DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
21
21
  tool_update_interval_minutes=DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
22
22
  plugin_auto_update=None,
23
- detect_updates=True,
24
23
  prerelease_packages=None,
25
24
  auto_apply=True,
26
25
  auto_start=True,
@@ -35,7 +35,6 @@ def _make_config(**overrides: Any) -> ResolvedConfig:
35
35
  'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
36
36
  'tool_update_interval_minutes': DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
37
37
  'plugin_auto_update': None,
38
- 'detect_updates': True,
39
38
  'prerelease_packages': None,
40
39
  'auto_apply': True,
41
40
  'auto_start': True,
@@ -45,7 +45,6 @@ class TestUserConfig:
45
45
  assert config.auto_update_interval_minutes is None
46
46
  assert config.tool_update_interval_minutes is None
47
47
  assert config.plugin_auto_update is None
48
- assert config.detect_updates is True
49
48
  assert config.prerelease_packages is None
50
49
  assert config.auto_apply is None
51
50
  assert config.auto_start is None
@@ -170,7 +169,6 @@ class TestSaveUserConfig:
170
169
  assert data['update_channel'] == 'dev'
171
170
  assert 'update_source' in data
172
171
  assert 'auto_update_interval_minutes' in data
173
- assert 'detect_updates' in data
174
172
 
175
173
  @staticmethod
176
174
  def test_creates_directory(tmp_path: Path) -> None:
@@ -34,7 +34,6 @@ def _make_resolved(**overrides: Any) -> ResolvedConfig:
34
34
  'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
35
35
  'tool_update_interval_minutes': DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
36
36
  'plugin_auto_update': None,
37
- 'detect_updates': True,
38
37
  'prerelease_packages': None,
39
38
  'auto_apply': True,
40
39
  'auto_start': True,