Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
from synodic_client.protocol import extract_uri_from_args
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
from synodic_client.updater import initialize_velopack

# Parse flags early so logging uses the right filename and level.
Expand Down
65 changes: 65 additions & 0 deletions synodic_client/application/config_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Centralized configuration store.

Provides a single source of truth for :class:`ResolvedConfig` so that
every consumer (ToolsView, SettingsWindow, UpdateController,
ToolUpdateOrchestrator) always reads the same snapshot and receives
change notifications through a Qt signal.
"""

from __future__ import annotations

from PySide6.QtCore import QObject, Signal

from synodic_client.resolution import ResolvedConfig, resolve_config, update_user_config


class ConfigStore(QObject):
"""Observable wrapper around :class:`ResolvedConfig`.

All config mutations go through :meth:`update` (which persists to
disk) or :meth:`set` (which replaces without persisting). Both
emit :attr:`changed` so every connected consumer stays in sync.

Typical usage::

store = ConfigStore(initial_config)
store.changed.connect(some_consumer.on_config_changed)
store.update(auto_apply=False) # persists + emits
"""

changed = Signal(object)
"""Emitted with the new ``ResolvedConfig`` after every mutation."""

def __init__(self, config: ResolvedConfig | None = None, parent: QObject | None = None) -> None:
"""Create a new store, optionally seeded with *config*."""
super().__init__(parent)
self._config = config if config is not None else resolve_config()

@property
def config(self) -> ResolvedConfig:
"""The current configuration snapshot."""
return self._config

def update(self, **changes: object) -> ResolvedConfig:
"""Persist *changes* to disk and broadcast the new config.

Wraps :func:`~synodic_client.resolution.update_user_config`.

Args:
**changes: Field-name / value pairs forwarded to
:func:`update_user_config`.

Returns:
The fresh :class:`ResolvedConfig`.
"""
self._config = update_user_config(**changes)
self.changed.emit(self._config)
return self._config

def set(self, config: ResolvedConfig) -> None:
"""Replace the config without persisting and notify listeners.

Use for externally resolved configs (e.g. passed at startup).
"""
self._config = config
self.changed.emit(self._config)
8 changes: 5 additions & 3 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtWidgets import QApplication, QWidget

from synodic_client.application.config_store import ConfigStore
from synodic_client.application.icon import app_icon
from synodic_client.application.init import run_startup_preamble
from synodic_client.application.instance import SingleInstance
Expand Down Expand Up @@ -216,8 +217,9 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
sys.exit(0)
instance.start_server()

_screen = Screen(porringer, config)
_tray = TrayScreen(app, client, _screen.window, config=config)
_store = ConfigStore(config)
_screen = Screen(porringer, _store)
_tray = TrayScreen(app, client, _screen.window, store=_store)

# Keep install preview windows alive until the app exits
_install_windows: list[InstallPreviewWindow] = []
Expand All @@ -227,7 +229,7 @@ def _handle_install_uri(manifest_url: str) -> None:
window = InstallPreviewWindow(
porringer,
manifest_url,
config=config,
config=_store.config,
)
_install_windows.append(window)
window.show()
Expand Down
21 changes: 21 additions & 0 deletions synodic_client/application/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,24 @@ class ToolUpdateResult:
failed: int = 0
updated_packages: set[str] = field(default_factory=set)
"""Package names that were successfully upgraded."""


@dataclass(frozen=True, slots=True)
class UpdateTarget:
"""Identifies the scope of a manual tool update.

Passed to the shared completion handler so it can clear the correct
updating state and derive timestamp keys. ``None`` (the default in
the handler) means the update was periodic / automatic.

When *package* is empty the update targeted an entire plugin;
otherwise it targeted one specific package within the plugin.
*plugin* always carries the signal key (possibly composite
``"plugin:tag"``).
"""

plugin: str
"""Signal key for the plugin (may be composite ``"name:tag"``)."""

package: str = ''
"""Package name, or empty when the whole plugin was updated."""
15 changes: 9 additions & 6 deletions synodic_client/application/screen/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import logging
from pathlib import Path
from typing import TYPE_CHECKING

from porringer.api import API
from porringer.backend.command.core.discovery import DiscoveredPlugins
Expand All @@ -24,7 +25,9 @@
from synodic_client.application.screen.sidebar import ManifestSidebar
from synodic_client.application.screen.spinner import LoadingIndicator
from synodic_client.application.theme import COMPACT_MARGINS
from synodic_client.resolution import ResolvedConfig

if TYPE_CHECKING:
from synodic_client.application.config_store import ConfigStore

logger = logging.getLogger(__name__)

Expand All @@ -41,7 +44,7 @@ class ProjectsView(QWidget):
def __init__(
self,
porringer: API,
config: ResolvedConfig,
store: ConfigStore,
parent: QWidget | None = None,
*,
coordinator: DataCoordinator | None = None,
Expand All @@ -50,14 +53,14 @@ def __init__(

Args:
porringer: The porringer API instance.
config: Resolved configuration.
store: The centralised :class:`ConfigStore`.
parent: Optional parent widget.
coordinator: Shared data coordinator for validated directory
data.
"""
super().__init__(parent)
self._porringer = porringer
self._config = config
self._store = store
self._coordinator = coordinator
self._refresh_in_progress = False
self._pending_select: Path | None = None
Expand Down Expand Up @@ -163,7 +166,7 @@ async def _async_refresh(self) -> None:
widget.load(
str(path),
project_directory=path if path.is_dir() else path.parent,
detect_updates=self._config.detect_updates,
detect_updates=self._store.config.detect_updates,
)

except Exception:
Expand Down Expand Up @@ -196,7 +199,7 @@ def _create_directory_widgets(
self._porringer,
self,
show_close=False,
config=self._config,
config=self._store.config,
)
widget._discovered_plugins = discovered
widget.install_finished.connect(self._on_install_finished)
Expand Down
42 changes: 21 additions & 21 deletions synodic_client/application/screen/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
QWidget,
)

from synodic_client.application.config_store import ConfigStore
from synodic_client.application.data import DataCoordinator
from synodic_client.application.icon import app_icon
from synodic_client.application.screen.plugin_row import (
Expand Down Expand Up @@ -65,7 +66,6 @@
SEARCH_INPUT_STYLE,
SETTINGS_GEAR_STYLE,
)
from synodic_client.resolution import ResolvedConfig, update_user_config

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -111,7 +111,7 @@ class ToolsView(QWidget):
def __init__(
self,
porringer: API,
config: ResolvedConfig,
store: ConfigStore,
parent: QWidget | None = None,
*,
coordinator: DataCoordinator | None = None,
Expand All @@ -120,15 +120,15 @@ def __init__(

Args:
porringer: The porringer API instance.
config: Resolved configuration (for auto-update toggles).
store: The centralised :class:`ConfigStore`.
parent: Optional parent widget.
coordinator: Shared data coordinator. When provided, the
view delegates plugin/directory fetching to the
coordinator instead of calling porringer directly.
"""
super().__init__(parent)
self._porringer = porringer
self._config = config
self._store = store
self._coordinator = coordinator
self._section_widgets: list[QWidget] = []
self._filter_chips: dict[str, FilterChip] = {}
Expand Down Expand Up @@ -379,7 +379,7 @@ def _build_widget_tree(self, data: RefreshData) -> None:
"""Clear existing widgets and rebuild the tool/package tree."""
self._clear_section_widgets()

auto_update_map = self._config.plugin_auto_update or {}
auto_update_map = self._store.config.plugin_auto_update or {}
kind_buckets = self._bucket_by_kind(
data.plugins,
data.packages_map,
Expand Down Expand Up @@ -446,7 +446,7 @@ def _build_runtime_sections(

auto_val = auto_update_map.get(plugin.name, True)
plugin_updates = self._updates_available.get(plugin.name, {})
tool_timestamps = self._config.last_tool_updates or {}
tool_timestamps = self._store.config.last_tool_updates or {}
default_exe = data.default_runtime_executable

# Sort: default runtime first, then descending by tag
Expand Down Expand Up @@ -534,7 +534,7 @@ def _build_plugin_section(
plugin_manifest = data.manifest_packages.get(plugin.name, set())
raw_packages = data.packages_map.get(plugin.name, [])
display_packages = self._build_display_packages(raw_packages, plugin_manifest)
tool_timestamps = self._config.last_tool_updates or {}
tool_timestamps = self._store.config.last_tool_updates or {}

if display_packages:
for pkg in display_packages:
Expand Down Expand Up @@ -1069,15 +1069,15 @@ async def _gather_project_requirements(

def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None:
"""Persist the plugin-level auto-update toggle change to config."""
mapping = dict(self._config.plugin_auto_update or {})
mapping = dict(self._store.config.plugin_auto_update or {})

if enabled:
mapping.pop(plugin_name, None)
else:
mapping[plugin_name] = False

new_value = mapping if mapping else None
self._config = update_user_config(plugin_auto_update=new_value)
self._store.update(plugin_auto_update=new_value)
logger.info('Auto-update for %s set to %s', plugin_name, enabled)

def _on_package_auto_update_toggled(
Expand All @@ -1087,7 +1087,7 @@ def _on_package_auto_update_toggled(
enabled: bool,
) -> None:
"""Persist a per-package auto-update override to the nested config dict."""
mapping = dict(self._config.plugin_auto_update or {})
mapping = dict(self._store.config.plugin_auto_update or {})
current = mapping.get(plugin_name)

if isinstance(current, dict):
Expand All @@ -1103,7 +1103,7 @@ def _on_package_auto_update_toggled(
mapping.pop(plugin_name, None)

new_value = mapping if mapping else None
self._config = update_user_config(plugin_auto_update=new_value)
self._store.update(plugin_auto_update=new_value)
logger.info(
'Auto-update for %s/%s set to %s',
plugin_name,
Expand Down Expand Up @@ -1389,17 +1389,17 @@ class MainWindow(QMainWindow):
def __init__(
self,
porringer: API | None = None,
config: ResolvedConfig | None = None,
store: ConfigStore | None = None,
) -> None:
"""Initialize the main window.

Args:
porringer: Optional porringer API instance for manifest display.
config: Resolved configuration for plugin auto-update state.
store: The centralised :class:`ConfigStore`.
"""
super().__init__()
self._porringer = porringer
self._config = config
self._store = store
self._coordinator: DataCoordinator | None = DataCoordinator(porringer) if porringer is not None else None
self.setWindowTitle('Synodic Client')
self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
Expand Down Expand Up @@ -1445,20 +1445,20 @@ def update_banner(self) -> UpdateBanner:

def show(self) -> None:
"""Show the window, initializing UI lazily on first show."""
if self._tabs is None and self._porringer is not None and self._config is not None:
if self._tabs is None and self._porringer is not None and self._store is not None:
self._tabs = QTabWidget(self)

self._projects_view = ProjectsView(
self._porringer,
self._config,
self._store,
self,
coordinator=self._coordinator,
)
self._tabs.addTab(self._projects_view, 'Projects')

self._tools_view = ToolsView(
self._porringer,
self._config,
self._store,
self,
coordinator=self._coordinator,
)
Expand Down Expand Up @@ -1507,16 +1507,16 @@ class Screen:
def __init__(
self,
porringer: API | None = None,
config: ResolvedConfig | None = None,
store: ConfigStore | None = None,
) -> None:
"""Initialize the screen.

Args:
porringer: Optional porringer API instance.
config: Resolved configuration.
store: The centralised :class:`ConfigStore`.
"""
self._porringer = porringer
self._config = config
self._store = store

@property
def window(self) -> MainWindow:
Expand All @@ -1526,5 +1526,5 @@ def window(self) -> MainWindow:
The MainWindow instance.
"""
if self._window is None:
self._window = MainWindow(self._porringer, self._config)
self._window = MainWindow(self._porringer, self._store)
return self._window
Loading
Loading