diff --git a/pdm.lock b/pdm.lock index 33c68b6..8f7cd12 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:e9a4056ff08a0aec46d8c923b2bf3fcd62be72cea23542cc6d93de81ecec0ed4" +content_hash = "sha256:cd2a7f2a6059a13c3a552d045427b10e910ff85fe5700695de8b84909e64c96a" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev73" +version = "0.2.1.dev74" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev73-py3-none-any.whl", hash = "sha256:edc8dff942269e1c1f849c35afbe0c1e162193dc447b79c3362f1edc2083638f"}, - {file = "porringer-0.2.1.dev73.tar.gz", hash = "sha256:10351cf835251c0a5d6e4359a52e2af937bdd9366c21d8c64b88c979cb828e58"}, + {file = "porringer-0.2.1.dev74-py3-none-any.whl", hash = "sha256:c082f235d918f16f3c4bb824848f7ffe0117d2db1e408daa47b037eabf420205"}, + {file = "porringer-0.2.1.dev74.tar.gz", hash = "sha256:9c2d8b8c392021aa6b9cc4c7dc47c3f24b03e372f7551ba6b08f6bdcf49b54b3"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 3d8953a..8991276 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev73", + "porringer>=0.2.1.dev74", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", @@ -25,9 +25,18 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = ["pyinstaller>=6.19.0"] -lint = ["ruff>=0.15.4", "pyrefly>=0.55.0"] -test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] +build = [ + "pyinstaller>=6.19.0", +] +lint = [ + "ruff>=0.15.4", + "pyrefly>=0.55.0", +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/__init__.py b/synodic_client/__init__.py index 1a0a256..cc8e969 100644 --- a/synodic_client/__init__.py +++ b/synodic_client/__init__.py @@ -3,13 +3,13 @@ import importlib.metadata from synodic_client.client import Client -from synodic_client.updater import ( +from synodic_client.schema import ( UpdateChannel, UpdateConfig, UpdateInfo, - Updater, UpdateState, ) +from synodic_client.updater import Updater try: __version__ = importlib.metadata.version('synodic_client') diff --git a/synodic_client/application/bootstrap.py b/synodic_client/application/bootstrap.py index c7c44f9..ce2d66a 100644 --- a/synodic_client/application/bootstrap.py +++ b/synodic_client/application/bootstrap.py @@ -17,10 +17,9 @@ from synodic_client.config import set_dev_mode from synodic_client.logging import configure_logging +from synodic_client.protocol import extract_uri_from_args from synodic_client.updater import initialize_velopack -_PROTOCOL_SCHEME = 'synodic' - # Parse --dev flag early so logging uses the right filename. _dev_mode = '--dev' in sys.argv[1:] set_dev_mode(_dev_mode) @@ -36,5 +35,4 @@ # Heavy imports happen here — PySide6, porringer, etc. from synodic_client.application.qt import application -_uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None) -application(uri=_uri, dev_mode=_dev_mode) +application(uri=extract_uri_from_args(), dev_mode=_dev_mode) diff --git a/synodic_client/application/data.py b/synodic_client/application/data.py index f43d587..97fd7e2 100644 --- a/synodic_client/application/data.py +++ b/synodic_client/application/data.py @@ -14,7 +14,6 @@ import asyncio import logging -from dataclasses import dataclass, field from porringer.api import API from porringer.backend.command.core.discovery import DiscoveredPlugins @@ -22,36 +21,11 @@ from porringer.schema import ( CheckParameters, CheckResult, - DirectoryValidationResult, - ManifestDirectory, - PluginInfo, ) -logger = logging.getLogger(__name__) - - -@dataclass(slots=True) -class Snapshot: - """Immutable bundle of data produced by a single refresh cycle. - - All fields are populated by :meth:`DataCoordinator.refresh` and - remain stable until the next refresh. - """ +from synodic_client.application.schema import Snapshot - plugins: list[PluginInfo] = field(default_factory=list) - """All discovered plugins with install status and version info.""" - - directories: list[ManifestDirectory] = field(default_factory=list) - """Cached project directories (un-validated).""" - - validated_directories: list[DirectoryValidationResult] = field(default_factory=list) - """Cached directories with ``exists`` / ``has_manifest`` validation.""" - - discovered: DiscoveredPlugins | None = None - """Full plugin discovery result including runtime context.""" - - plugin_managers: dict[str, PluginManager] = field(default_factory=dict) - """Project-environment plugins implementing the ``PluginManager`` protocol.""" +logger = logging.getLogger(__name__) class DataCoordinator: diff --git a/synodic_client/application/init.py b/synodic_client/application/init.py index 709dd65..cc8442d 100644 --- a/synodic_client/application/init.py +++ b/synodic_client/application/init.py @@ -23,7 +23,11 @@ logger = logging.getLogger(__name__) -_preamble_done = False + +class _PreambleState: + """Module-level mutable state (avoids ``global`` statements).""" + + done: bool = False def run_startup_preamble(exe_path: str | None = None) -> None: @@ -39,10 +43,9 @@ def run_startup_preamble(exe_path: str | None = None) -> None: exe_path: Absolute path to the application executable. Defaults to ``sys.executable`` when not supplied. """ - global _preamble_done # noqa: PLW0603 - if _preamble_done: + if _PreambleState.done: return - _preamble_done = True + _PreambleState.done = True if exe_path is None: exe_path = sys.executable diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index 1e9f209..a737efb 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -24,6 +24,7 @@ from synodic_client.client import Client from synodic_client.config import set_dev_mode from synodic_client.logging import configure_logging +from synodic_client.protocol import extract_uri_from_args from synodic_client.resolution import ( ResolvedConfig, resolve_config, @@ -187,8 +188,5 @@ def _handle_install_uri(manifest_url: str) -> None: loop.run_forever() -_PROTOCOL_SCHEME = 'synodic' - if __name__ == '__main__': - _uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None) - application(uri=_uri) + application(uri=extract_uri_from_args()) diff --git a/synodic_client/application/schema.py b/synodic_client/application/schema.py new file mode 100644 index 0000000..e42ba1a --- /dev/null +++ b/synodic_client/application/schema.py @@ -0,0 +1,53 @@ +"""Application-layer data models. + +Contains data structures shared across application modules — the +``DataCoordinator`` snapshot and the tool-update result summary. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from porringer.backend.command.core.discovery import DiscoveredPlugins +from porringer.core.plugin_schema.plugin_manager import PluginManager +from porringer.schema import ( + DirectoryValidationResult, + ManifestDirectory, + PluginInfo, +) + + +@dataclass(slots=True) +class Snapshot: + """Immutable bundle of data produced by a single refresh cycle. + + All fields are populated by :meth:`DataCoordinator.refresh` and + remain stable until the next refresh. + """ + + plugins: list[PluginInfo] = field(default_factory=list) + """All discovered plugins with install status and version info.""" + + directories: list[ManifestDirectory] = field(default_factory=list) + """Cached project directories (un-validated).""" + + validated_directories: list[DirectoryValidationResult] = field(default_factory=list) + """Cached directories with ``exists`` / ``has_manifest`` validation.""" + + discovered: DiscoveredPlugins | None = None + """Full plugin discovery result including runtime context.""" + + plugin_managers: dict[str, PluginManager] = field(default_factory=dict) + """Project-environment plugins implementing the ``PluginManager`` protocol.""" + + +@dataclass(slots=True) +class ToolUpdateResult: + """Summary of a tool-update run across cached manifests.""" + + manifests_processed: int = 0 + updated: int = 0 + already_latest: int = 0 + failed: int = 0 + updated_packages: set[str] = field(default_factory=set) + """Package names that were successfully upgraded.""" diff --git a/synodic_client/application/screen/__init__.py b/synodic_client/application/screen/__init__.py index 38036f8..109ae5e 100644 --- a/synodic_client/application/screen/__init__.py +++ b/synodic_client/application/screen/__init__.py @@ -6,9 +6,15 @@ from __future__ import annotations +from datetime import UTC, datetime + from porringer.schema import SetupAction, SkipReason from porringer.schema.plugin import PluginKind +_SECONDS_PER_MINUTE = 60 +_MINUTES_PER_HOUR = 60 +_HOURS_PER_DAY = 24 + ACTION_KIND_LABELS: dict[PluginKind | None, str] = { PluginKind.PACKAGE: 'Package', PluginKind.TOOL: 'Tool', @@ -57,15 +63,45 @@ def skip_reason_label(reason: SkipReason | None) -> str: return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize()) -def format_cli_command(action: SetupAction) -> str: +def format_cli_command(action: SetupAction, *, suppress_description: bool = False) -> str: """Return a human-readable CLI command string for *action*. Prefers ``cli_command``, falls back to ``command``, then synthesises an ``installer install `` string for package actions, and finally returns the action description as a last resort. + + When *suppress_description* is ``True`` the final description + fallback returns an empty string instead. """ if parts := (action.cli_command or action.command): return ' '.join(parts) if action.kind == PluginKind.PACKAGE and action.package: return f'{action.installer or "pip"} install {action.package}' - return action.description + return '' if suppress_description else action.description + + +def _format_relative_time(iso_timestamp: str) -> str: + """Format an ISO 8601 timestamp as a human-readable relative time. + + Returns strings like ``'just now'``, ``'5m ago'``, ``'2h ago'``, + ``'3d ago'``. Returns an empty string if the timestamp cannot be + parsed. + """ + try: + dt = datetime.fromisoformat(iso_timestamp) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + delta = datetime.now(UTC) - dt + seconds = max(int(delta.total_seconds()), 0) + if seconds < _SECONDS_PER_MINUTE: + return 'just now' + minutes = seconds // _SECONDS_PER_MINUTE + if minutes < _MINUTES_PER_HOUR: + return f'{minutes}m ago' + hours = minutes // _MINUTES_PER_HOUR + if hours < _HOURS_PER_DAY: + return f'{hours}h ago' + days = hours // _HOURS_PER_DAY + return f'{days}d ago' + except ValueError, TypeError: + return '' diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 46289a1..ca0c7bf 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -16,8 +16,8 @@ from porringer.backend.command.core.action_builder import PHASE_ORDER from porringer.schema import SetupAction, SetupActionResult, SkipReason from porringer.schema.plugin import PluginKind -from PySide6.QtCore import QRect, Qt, QTimer, Signal -from PySide6.QtGui import QColor, QPainter, QPen +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QApplication, QCheckBox, @@ -30,6 +30,7 @@ ) from synodic_client.application.screen import ACTION_KIND_LABELS, format_cli_command, skip_reason_label +from synodic_client.application.screen.spinner import SpinnerCanvas from synodic_client.application.theme import ( ACTION_CARD_COMMAND_STYLE, ACTION_CARD_DESC_STYLE, @@ -66,9 +67,6 @@ #: Timer interval for per-card inline spinner (ms). _SPINNER_INTERVAL = 50 -#: Arc span for per-card spinner (degrees × 16 for Qt drawArc). -_SPINNER_ARC = 90 - #: Sort priority derived from porringer's execution phase order so the #: display order always matches the order actions actually execute. @@ -101,62 +99,6 @@ def action_sort_key(action: SetupAction) -> int: return _KIND_ORDER.get(action.kind, len(PHASE_ORDER)) -def _format_command(action: SetupAction) -> str: - """Return a short CLI command string for display. - - Wraps :func:`~synodic_client.application.screen.format_cli_command` - but returns an empty string instead of the description fallback so - cards only show an explicit command line. - """ - text = format_cli_command(action) - return '' if text == action.description else text - - -# --------------------------------------------------------------------------- -# _CardSpinner — tiny per-card inline spinner -# --------------------------------------------------------------------------- - - -class _CardSpinner(QWidget): - """Tiny spinning arc used inside an :class:`ActionCard` while checking. - - The spinner replaces the status text label during the dry-run check - phase and is hidden once the result arrives. - """ - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._angle = 0 - self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE) - - def paintEvent(self, _event: object) -> None: - """Draw the muted track and animated highlight arc.""" - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - m = ACTION_CARD_SPINNER_PEN // 2 + 1 - rect = QRect(m, m, self.width() - 2 * m, self.height() - 2 * m) - - # Track circle - track_pen = QPen(self.palette().mid(), ACTION_CARD_SPINNER_PEN) - track_pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(track_pen) - painter.drawEllipse(rect) - - # Highlight arc - hl_pen = QPen(self.palette().highlight(), ACTION_CARD_SPINNER_PEN) - hl_pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(hl_pen) - painter.drawArc(rect, self._angle * 16, _SPINNER_ARC * 16) - - painter.end() - - def tick(self) -> None: - """Advance the arc and repaint.""" - self._angle = (self._angle - 10) % 360 - self.update() - - # --------------------------------------------------------------------------- # ActionCard — a single action row # --------------------------------------------------------------------------- @@ -297,7 +239,11 @@ def _build_top_row(self) -> QHBoxLayout: top.addWidget(self._version_label) # Inline spinner (replaces status text while checking) - self._spinner_canvas = _CardSpinner(self) + self._spinner_canvas = SpinnerCanvas( + size=ACTION_CARD_SPINNER_SIZE, + pen_width=ACTION_CARD_SPINNER_PEN, + parent=self, + ) self._spinner_canvas.hide() self._spinner_timer = QTimer(self) self._spinner_timer.setInterval(_SPINNER_INTERVAL) @@ -418,7 +364,7 @@ def populate( self._desc_label.hide() # CLI command (always visible when present) - cmd_text = _format_command(action) + cmd_text = format_cli_command(action, suppress_description=True) if cmd_text: self._command_label.setText(cmd_text) self._command_row.show() @@ -461,7 +407,7 @@ def update_command(self, action: SetupAction) -> None: """ if self._is_skeleton: return - cmd_text = _format_command(action) + cmd_text = format_cli_command(action, suppress_description=True) if cmd_text: self._command_label.setText(cmd_text) self._command_row.show() diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index aa33889..f07d4d6 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -12,26 +12,15 @@ from __future__ import annotations import asyncio -import enum import logging -import shutil -import tempfile -from collections.abc import Callable -from dataclasses import dataclass, field from pathlib import Path from typing import Any -from urllib.parse import urlparse -from urllib.request import url2pathname from porringer.api import API from porringer.backend.command.core.discovery import DiscoveredPlugins from porringer.schema import ( - DownloadParameters, - ProgressEvent, - ProgressEventKind, SetupAction, SetupActionResult, - SetupParameters, SetupResults, SkipReason, SubActionProgress, @@ -55,7 +44,17 @@ from synodic_client.application.screen import skip_reason_label from synodic_client.application.screen.action_card import ActionCardList, action_key from synodic_client.application.screen.card import CardFrame +from synodic_client.application.screen.install_workers import run_install, run_preview from synodic_client.application.screen.log_panel import ExecutionLogPanel +from synodic_client.application.screen.schema import ( + ActionState, + InstallCallbacks, + InstallConfig, + PreviewCallbacks, + PreviewConfig, + PreviewModel, + PreviewPhase, +) from synodic_client.application.theme import ( ACTION_CARD_SKELETON_BAR_STYLE, CARD_SPACING, @@ -68,231 +67,12 @@ MUTED_STYLE, NO_MARGINS, ) +from synodic_client.application.uri import normalize_manifest_key, resolve_local_path, safe_rmtree from synodic_client.resolution import ResolvedConfig, update_user_config logger = logging.getLogger(__name__) -def normalize_manifest_key(path_or_url: str) -> str: - """Return a canonical key for a manifest path or URL. - - Local paths are resolved to absolute form so that the same manifest on - disk always maps to the same config entry regardless of how it was - referenced (relative, symlinked, etc.). Remote URLs are returned - unchanged. - """ - parsed = urlparse(path_or_url) - if parsed.scheme in {'http', 'https'}: - return path_or_url - try: - return str(Path(path_or_url).resolve()) - except Exception: - return path_or_url - - -# --------------------------------------------------------------------------- -# PreviewPhase / ActionState / PreviewModel — data layer -# --------------------------------------------------------------------------- - - -class PreviewPhase(enum.Enum): - """Lifecycle phase of a :class:`SetupPreviewWidget`. - - The widget transitions through these phases and uses them to decide - whether certain operations (like reloading the preview or toggling - buttons) are allowed. Having an explicit enum replaces the previous - ``_installing`` boolean flag and status-label-text-based implicit state. - """ - - IDLE = 'idle' - """No preview loaded.""" - - LOADING = 'loading' - """Skeleton placeholders displayed; preview worker running.""" - - PREVIEWING = 'previewing' - """Cards populated; dry-run status checks in progress.""" - - READY = 'ready' - """Dry-run complete; install button may be enabled.""" - - INSTALLING = 'installing' - """Install worker running.""" - - DONE = 'done' - """Install finished; execution logs visible.""" - - ERROR = 'error' - """Preview or install failed.""" - - -@dataclass -class ActionState: - """Per-action data that survives widget rebuilds. - - Each entry stores the authoritative execution log so that - :class:`ActionCard` widgets can be destroyed and recreated - without losing output. - """ - - action: SetupAction - """The porringer setup action.""" - - status: str = 'Checking\u2026' - """Human-readable dry-run status label.""" - - log_lines: list[tuple[str, str | None]] = field(default_factory=list) - """Accumulated execution log: ``(text, stream)`` pairs.""" - - -class PreviewModel: - """Data model for a single preview / install session. - - Holds all state that the :class:`SetupPreviewWidget` needs to - display and that must survive :class:`ActionCard` widget destruction. - The model is replaced wholesale when a new preview is loaded; during - an install it is updated in-place and outlives any UI refresh. - """ - - def __init__(self) -> None: - """Initialise a blank preview model.""" - self.phase: PreviewPhase = PreviewPhase.IDLE - self.preview: SetupResults | None = None - self.manifest_path: Path | None = None - self.manifest_key: str | None = None - self.project_directory: Path | None = None - self.plugin_installed: dict[str, bool] = {} - self.prerelease_overrides: set[str] = set() - self.action_states: list[ActionState] = [] - self._action_state_map: dict[tuple[object, ...], ActionState] = {} - self._action_state_map_len: int = 0 - self.upgradable_keys: set[tuple[object, ...]] = set() - self.checked_count: int = 0 - self.completed_count: int = 0 - self.temp_dir: str | None = None - - # -- Computed helpers -------------------------------------------------- - - def _ensure_action_state_map(self) -> dict[tuple[object, ...], ActionState]: - """Return the action-key → state lookup, rebuilding if stale.""" - if len(self.action_states) != self._action_state_map_len: - self._action_state_map = {action_key(s.action): s for s in self.action_states} - self._action_state_map_len = len(self.action_states) - return self._action_state_map - - @property - def actionable_count(self) -> int: - """Number of needed + upgradable actions.""" - needed = sum(1 for s in self.action_states if s.status == 'Needed') - upgradable = len(self.upgradable_keys) - return needed + upgradable - - @property - def install_enabled(self) -> bool: - """Whether the install button should be enabled.""" - if self.phase not in {PreviewPhase.READY}: - return False - return self.actionable_count > 0 or any(s.action.kind is None for s in self.action_states) - - def action_state_for(self, act: SetupAction) -> ActionState | None: - """Look up :class:`ActionState` by content key (O(1) amortized).""" - return self._ensure_action_state_map().get(action_key(act)) - - def has_same_manifest(self, key: str) -> bool: - """Return ``True`` if *key* matches the current manifest key.""" - return self.manifest_key is not None and self.manifest_key == normalize_manifest_key(key) - - -@dataclass(frozen=True, slots=True) -class InstallConfig: - """Optional execution parameters for :class:`InstallWorker`.""" - - project_directory: Path | None = None - strategy: SyncStrategy = SyncStrategy.MINIMAL - prerelease_packages: set[str] | None = field(default=None) - - -@dataclass(frozen=True, slots=True) -class InstallCallbacks: - """Callbacks for :func:`run_install` progress reporting.""" - - on_action_started: Callable[[SetupAction], None] | None = None - """Called when an action begins execution.""" - - on_sub_progress: Callable[[SetupAction, SubActionProgress], None] | None = None - """Called for sub-action progress events.""" - - on_progress: Callable[[SetupAction, SetupActionResult], None] | None = None - """Called when a single action completes.""" - - -async def run_install( - porringer: API, - manifest_path: Path, - config: InstallConfig | None = None, - callbacks: InstallCallbacks | None = None, - *, - plugins: DiscoveredPlugins | None = None, -) -> SetupResults: - """Execute setup actions via porringer and stream progress. - - Runs on the caller's event loop (typically the qasync main-thread - loop). Callbacks are invoked between ``await`` points so the GUI - stays responsive without cross-thread signalling. - - Args: - porringer: The porringer API instance. - manifest_path: Path to the manifest file to execute. - config: Optional execution parameters (directory, strategy, - prerelease overrides). - callbacks: Optional progress callbacks. - plugins: Pre-discovered plugins to pass through to porringer, - avoiding redundant discovery. - - Returns: - Aggregated :class:`SetupResults`. - """ - cfg = config or InstallConfig() - cb = callbacks or InstallCallbacks() - params = SetupParameters( - paths=[manifest_path], - project_directory=cfg.project_directory, - strategy=cfg.strategy, - prerelease_packages=cfg.prerelease_packages, - ) - actions: list[SetupAction] = [] - collected: list[SetupActionResult] = [] - manifest_result: SetupResults | None = None - - async for event in porringer.sync.execute_stream(params, plugins=plugins): - if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest: - manifest_result = event.manifest - actions = list(event.manifest.actions) - - if event.kind == ProgressEventKind.ACTION_STARTED and event.action and cb.on_action_started is not None: - cb.on_action_started(event.action) - - if ( - event.kind == ProgressEventKind.SUB_ACTION_PROGRESS - and event.action - and event.sub_action - and cb.on_sub_progress is not None - ): - cb.on_sub_progress(event.action, event.sub_action) - - if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action: - collected.append(event.result) - if cb.on_progress is not None: - cb.on_progress(event.action, event.result) - - return SetupResults( - actions=actions, - results=collected, - manifest_path=manifest_result.manifest_path if manifest_result else None, - metadata=manifest_result.metadata if manifest_result else None, - ) - - # --------------------------------------------------------------------------- # SetupPreviewWidget — reusable preview + install widget # --------------------------------------------------------------------------- @@ -1182,7 +962,7 @@ def closeEvent(self, event: Any) -> None: logger.info('Install preview window closing') temp = self._preview_widget.model.temp_dir if temp: - _safe_rmtree(temp) + safe_rmtree(temp) super().closeEvent(event) # --- Public API --- @@ -1208,206 +988,3 @@ def _on_metadata_ready(self, preview: object) -> None: """Update the window title when metadata arrives.""" if hasattr(preview, 'metadata') and preview.metadata and preview.metadata.name: self.setWindowTitle(f'Install Preview \u2014 {preview.metadata.name}') - - -@dataclass(frozen=True, slots=True) -class PreviewCallbacks: - """Callbacks for :func:`run_preview` progress reporting.""" - - on_manifest_parsed: Callable[[SetupResults, str, str], None] | None = None - """``(SetupResults, manifest_path, temp_dir)`` — after JSON load.""" - - on_plugins_queried: Callable[[dict[str, bool]], None] | None = None - """``(dict[str, bool])`` — plugin → installed mapping.""" - - on_preview_ready: Callable[[SetupResults, str, str], None] | None = None - """``(SetupResults, manifest_path, temp_dir)`` — CLI commands resolved.""" - - on_action_checked: Callable[[int, SetupActionResult], None] | None = None - """``(row_index, SetupActionResult)`` — per-action dry-run result.""" - - -@dataclass(frozen=True, slots=True) -class PreviewConfig: - """Optional execution parameters for :func:`run_preview`.""" - - project_directory: Path | None = None - detect_updates: bool = True - prerelease_packages: set[str] | None = None - - -@dataclass(slots=True) -class _DispatchState: - """Mutable accumulator for :func:`_dispatch_preview_event`.""" - - action_index: dict[int, int] = field(default_factory=dict) - got_parsed: bool = False - - -async def _resolve_manifest_path(url: str) -> tuple[Path, str | None]: - """Resolve *url* to a local manifest path, downloading if remote. - - Returns: - ``(manifest_path, temp_dir)`` — *temp_dir* is ``None`` for local - manifests and a temporary directory string for downloads. - - Raises: - FileNotFoundError: If a local path does not exist. - RuntimeError: If the download fails. - """ - local_path = resolve_local_path(url) - - if local_path is not None: - if not local_path.exists(): - msg = f'Manifest not found:\n{local_path}' - raise FileNotFoundError(msg) - return local_path, None - - temp_dir = tempfile.mkdtemp(prefix='synodic_install_') - dest = Path(temp_dir) / 'porringer.json' - - params = DownloadParameters(url=url, destination=dest, timeout=3) - result = await API.download(params) - - if not result.success: - _safe_rmtree(temp_dir) - msg = f'Failed to download manifest:\n{result.message}' - raise RuntimeError(msg) - - return dest, temp_dir - - -def _dispatch_preview_event( - event: ProgressEvent, - manifest_path: str, - temp_dir_str: str, - state: _DispatchState, - cb: PreviewCallbacks, -) -> None: - """Route a single preview stream event to the appropriate callback. - - Mutates *state* in-place with updated ``action_index`` / ``got_parsed``. - """ - if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest: - state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)} - if cb.on_manifest_parsed is not None: - cb.on_manifest_parsed(event.manifest, manifest_path, temp_dir_str) - state.got_parsed = True - return - - if event.kind == ProgressEventKind.PLUGINS_DISCOVERED and cb.on_plugins_queried is not None: - if event.plugin_availability is not None: - cb.on_plugins_queried(event.plugin_availability) - elif event.plugin_names is not None: - cb.on_plugins_queried({name: True for name in event.plugin_names}) - return - - if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest: - if not state.got_parsed: - state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)} - if cb.on_preview_ready is not None: - cb.on_preview_ready(event.manifest, manifest_path, temp_dir_str) - return - - if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action: - row = state.action_index.get(id(event.action)) - if row is not None and cb.on_action_checked is not None: - cb.on_action_checked(row, event.result) - - -async def run_preview( - porringer: API, - url: str, - *, - config: PreviewConfig | None = None, - callbacks: PreviewCallbacks | None = None, - plugins: DiscoveredPlugins | None = None, -) -> None: - """Download a manifest and perform a dry-run preview. - - Runs on the caller's event loop (typically the qasync main-thread - loop). Callbacks fire between ``await`` points so the GUI remains - responsive without cross-thread signalling. - - Combines two stages: - - 1. Download the manifest (if remote) — runs in a thread-pool executor. - 2. Run ``execute_stream`` with ``dry_run=True`` to stream events. - - Args: - porringer: The porringer API instance. - url: Manifest URL or local path. - config: Optional preview configuration. - callbacks: Optional preview callbacks. - plugins: Pre-discovered plugins to pass through to porringer, - avoiding redundant discovery. - """ - logger.info('run_preview starting for: %s', url) - temp_dir: str | None = None - cb = callbacks or PreviewCallbacks() - cfg = config or PreviewConfig() - try: - manifest_path, temp_dir = await _resolve_manifest_path(url) - - # Dry-run: parses manifest, resolves actions, and checks status - setup_params = SetupParameters( - paths=[manifest_path], - dry_run=True, - project_directory=cfg.project_directory, - detect_updates=cfg.detect_updates, - prerelease_packages=cfg.prerelease_packages, - ) - state = _DispatchState() - temp_dir_str = temp_dir or '' - manifest_path_str = str(manifest_path) - - async for event in porringer.sync.execute_stream(setup_params, plugins=plugins): - _dispatch_preview_event( - event, - manifest_path_str, - temp_dir_str, - state, - cb, - ) - - except asyncio.CancelledError: - if temp_dir: - _safe_rmtree(temp_dir) - raise - except Exception: - if temp_dir: - _safe_rmtree(temp_dir) - raise - - -def resolve_local_path(manifest_ref: str) -> Path | None: - r"""Return a ``Path`` if *manifest_ref* points to a local file, else ``None``. - - Recognised forms: - * ``file:///C:/path/to/porringer.json`` - * An absolute OS path (``C:\...`` or ``/...``) - * A relative path that exists on disk - """ - parsed = urlparse(manifest_ref) - - if parsed.scheme == 'file': - # file:///C:/Users/... → C:/Users/... - return Path(url2pathname(parsed.path)) - - if parsed.scheme in {'http', 'https'}: - return None - - # No scheme — treat as a filesystem path - candidate = Path(manifest_ref) - if candidate.is_absolute() or candidate.exists(): - return candidate - - return None - - -def _safe_rmtree(path: str) -> None: - """Remove a directory tree, ignoring errors.""" - try: - shutil.rmtree(path) - except OSError: - logger.debug('Failed to clean up temp dir: %s', path) diff --git a/synodic_client/application/screen/install_workers.py b/synodic_client/application/screen/install_workers.py new file mode 100644 index 0000000..810a40d --- /dev/null +++ b/synodic_client/application/screen/install_workers.py @@ -0,0 +1,258 @@ +"""Async worker coroutines for install and preview operations. + +Contains ``run_install``, ``run_preview``, and supporting helpers that +stream porringer events back to the GUI via callbacks. +""" + +from __future__ import annotations + +import asyncio +import logging +import tempfile +from pathlib import Path + +from porringer.api import API +from porringer.backend.command.core.discovery import DiscoveredPlugins +from porringer.schema import ( + DownloadParameters, + ProgressEvent, + ProgressEventKind, + SetupAction, + SetupActionResult, + SetupParameters, + SetupResults, +) + +from synodic_client.application.screen.schema import ( + InstallCallbacks, + InstallConfig, + PreviewCallbacks, + PreviewConfig, + _DispatchState, +) +from synodic_client.application.uri import resolve_local_path, safe_rmtree + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# run_install — execute setup actions via porringer +# --------------------------------------------------------------------------- + + +async def run_install( + porringer: API, + manifest_path: Path, + config: InstallConfig | None = None, + callbacks: InstallCallbacks | None = None, + *, + plugins: DiscoveredPlugins | None = None, +) -> SetupResults: + """Execute setup actions via porringer and stream progress. + + Runs on the caller's event loop (typically the qasync main-thread + loop). Callbacks are invoked between ``await`` points so the GUI + stays responsive without cross-thread signalling. + + Args: + porringer: The porringer API instance. + manifest_path: Path to the manifest file to execute. + config: Optional execution parameters (directory, strategy, + prerelease overrides). + callbacks: Optional progress callbacks. + plugins: Pre-discovered plugins to pass through to porringer, + avoiding redundant discovery. + + Returns: + Aggregated :class:`SetupResults`. + """ + cfg = config or InstallConfig() + cb = callbacks or InstallCallbacks() + params = SetupParameters( + paths=[manifest_path], + project_directory=cfg.project_directory, + strategy=cfg.strategy, + prerelease_packages=cfg.prerelease_packages, + ) + actions: list[SetupAction] = [] + collected: list[SetupActionResult] = [] + manifest_result: SetupResults | None = None + + async for event in porringer.sync.execute_stream(params, plugins=plugins): + if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest: + manifest_result = event.manifest + actions = list(event.manifest.actions) + + if event.kind == ProgressEventKind.ACTION_STARTED and event.action and cb.on_action_started is not None: + cb.on_action_started(event.action) + + if ( + event.kind == ProgressEventKind.SUB_ACTION_PROGRESS + and event.action + and event.sub_action + and cb.on_sub_progress is not None + ): + cb.on_sub_progress(event.action, event.sub_action) + + if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action: + collected.append(event.result) + if cb.on_progress is not None: + cb.on_progress(event.action, event.result) + + return SetupResults( + actions=actions, + results=collected, + manifest_path=manifest_result.manifest_path if manifest_result else None, + metadata=manifest_result.metadata if manifest_result else None, + ) + + +# --------------------------------------------------------------------------- +# _resolve_manifest_path — local or download +# --------------------------------------------------------------------------- + + +async def _resolve_manifest_path(url: str) -> tuple[Path, str | None]: + """Resolve *url* to a local manifest path, downloading if remote. + + Returns: + ``(manifest_path, temp_dir)`` — *temp_dir* is ``None`` for local + manifests and a temporary directory string for downloads. + + Raises: + FileNotFoundError: If a local path does not exist. + RuntimeError: If the download fails. + """ + local_path = resolve_local_path(url) + + if local_path is not None: + if not local_path.exists(): + msg = f'Manifest not found:\n{local_path}' + raise FileNotFoundError(msg) + return local_path, None + + temp_dir = tempfile.mkdtemp(prefix='synodic_install_') + dest = Path(temp_dir) / 'porringer.json' + + params = DownloadParameters(url=url, destination=dest, timeout=3) + result = await API.download(params) + + if not result.success: + safe_rmtree(temp_dir) + msg = f'Failed to download manifest:\n{result.message}' + raise RuntimeError(msg) + + return dest, temp_dir + + +# --------------------------------------------------------------------------- +# _dispatch_preview_event — route stream events to callbacks +# --------------------------------------------------------------------------- + + +def _dispatch_preview_event( + event: ProgressEvent, + manifest_path: str, + temp_dir_str: str, + state: _DispatchState, + cb: PreviewCallbacks, +) -> None: + """Route a single preview stream event to the appropriate callback. + + Mutates *state* in-place with updated ``action_index`` / ``got_parsed``. + """ + if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest: + state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)} + if cb.on_manifest_parsed is not None: + cb.on_manifest_parsed(event.manifest, manifest_path, temp_dir_str) + state.got_parsed = True + return + + if event.kind == ProgressEventKind.PLUGINS_DISCOVERED and cb.on_plugins_queried is not None: + if event.plugin_availability is not None: + cb.on_plugins_queried(event.plugin_availability) + elif event.plugin_names is not None: + cb.on_plugins_queried({name: True for name in event.plugin_names}) + return + + if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest: + if not state.got_parsed: + state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)} + if cb.on_preview_ready is not None: + cb.on_preview_ready(event.manifest, manifest_path, temp_dir_str) + return + + if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action: + row = state.action_index.get(id(event.action)) + if row is not None and cb.on_action_checked is not None: + cb.on_action_checked(row, event.result) + + +# --------------------------------------------------------------------------- +# run_preview — dry-run preview of a manifest +# --------------------------------------------------------------------------- + + +async def run_preview( + porringer: API, + url: str, + *, + config: PreviewConfig | None = None, + callbacks: PreviewCallbacks | None = None, + plugins: DiscoveredPlugins | None = None, +) -> None: + """Download a manifest and perform a dry-run preview. + + Runs on the caller's event loop (typically the qasync main-thread + loop). Callbacks fire between ``await`` points so the GUI remains + responsive without cross-thread signalling. + + Combines two stages: + + 1. Download the manifest (if remote) — runs in a thread-pool executor. + 2. Run ``execute_stream`` with ``dry_run=True`` to stream events. + + Args: + porringer: The porringer API instance. + url: Manifest URL or local path. + config: Optional preview configuration. + callbacks: Optional preview callbacks. + plugins: Pre-discovered plugins to pass through to porringer, + avoiding redundant discovery. + """ + logger.info('run_preview starting for: %s', url) + temp_dir: str | None = None + cb = callbacks or PreviewCallbacks() + cfg = config or PreviewConfig() + try: + manifest_path, temp_dir = await _resolve_manifest_path(url) + + # Dry-run: parses manifest, resolves actions, and checks status + setup_params = SetupParameters( + paths=[manifest_path], + dry_run=True, + project_directory=cfg.project_directory, + detect_updates=cfg.detect_updates, + prerelease_packages=cfg.prerelease_packages, + ) + state = _DispatchState() + temp_dir_str = temp_dir or '' + manifest_path_str = str(manifest_path) + + async for event in porringer.sync.execute_stream(setup_params, plugins=plugins): + _dispatch_preview_event( + event, + manifest_path_str, + temp_dir_str, + state, + cb, + ) + + except asyncio.CancelledError: + if temp_dir: + safe_rmtree(temp_dir) + raise + except Exception: + if temp_dir: + safe_rmtree(temp_dir) + raise diff --git a/synodic_client/application/screen/plugin_row.py b/synodic_client/application/screen/plugin_row.py new file mode 100644 index 0000000..f23aa71 --- /dev/null +++ b/synodic_client/application/screen/plugin_row.py @@ -0,0 +1,595 @@ +"""Plugin row widgets for the tools view. + +Contains the compact widget rows used in :class:`ToolsView` to display +installed plugins and packages: kind headers, provider headers, individual +package rows, project child rows, and filter chips. +""" + +from __future__ import annotations + +from porringer.schema import PluginInfo +from porringer.schema.plugin import PluginKind +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QWidget, +) + +from synodic_client.application.screen import _format_relative_time, plugin_kind_group_label +from synodic_client.application.screen.schema import PluginRowData, ProjectInstance +from synodic_client.application.screen.spinner import SpinnerCanvas +from synodic_client.application.theme import ( + FILTER_CHIP_STYLE, + PLUGIN_KIND_HEADER_STYLE, + PLUGIN_PROVIDER_NAME_STYLE, + PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE, + PLUGIN_PROVIDER_STATUS_MISSING_STYLE, + PLUGIN_PROVIDER_STYLE, + PLUGIN_PROVIDER_VERSION_STYLE, + PLUGIN_ROW_ERROR_STYLE, + PLUGIN_ROW_GLOBAL_STYLE, + PLUGIN_ROW_HOST_STYLE, + PLUGIN_ROW_NAME_STYLE, + PLUGIN_ROW_PROJECT_STYLE, + PLUGIN_ROW_PROJECT_TAG_STYLE, + PLUGIN_ROW_PROJECT_TAG_TRANSITIVE_STYLE, + PLUGIN_ROW_REMOVE_STYLE, + PLUGIN_ROW_STATUS_STYLE, + PLUGIN_ROW_STYLE, + PLUGIN_ROW_TIMESTAMP_STYLE, + PLUGIN_ROW_TOGGLE_STYLE, + PLUGIN_ROW_UPDATE_STYLE, + PLUGIN_ROW_UPDATE_WIDTH, + PLUGIN_ROW_VERSION_MIN_WIDTH, + PLUGIN_ROW_VERSION_STYLE, + PLUGIN_TOGGLE_STYLE, + PLUGIN_UPDATE_STYLE, + PROJECT_CHILD_NAME_STYLE, + PROJECT_CHILD_NAV_STYLE, + PROJECT_CHILD_PROJECT_STYLE, + PROJECT_CHILD_ROW_STYLE, + PROJECT_CHILD_TRANSITIVE_STYLE, + PROJECT_CHILD_VERSION_STYLE, +) + +# Row-spinner dimensions +_ROW_SPINNER_SIZE = 12 +_ROW_SPINNER_PEN = 2 + + +class _RowSpinner(SpinnerCanvas): + """Tiny spinning arc shown inline while checking for updates. + + Wraps :class:`SpinnerCanvas` with row-specific defaults and adds + convenience ``start()`` / ``stop()`` helpers that toggle a timer. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(size=_ROW_SPINNER_SIZE, pen_width=_ROW_SPINNER_PEN, parent=parent) + self._timer = QTimer(self) + self._timer.setInterval(50) + self._timer.timeout.connect(self.tick) + self.hide() + + def start(self) -> None: + """Show the spinner and start the animation.""" + self._angle = 0 + self.show() + self._timer.start() + + def stop(self) -> None: + """Stop the animation and hide.""" + self._timer.stop() + self.hide() + + +# --------------------------------------------------------------------------- +# Plugin kind header — uppercase section divider +# --------------------------------------------------------------------------- + + +class PluginKindHeader(QLabel): + """Uppercase, muted section divider for a plugin-kind group. + + Displays a label like ``TOOLS`` or ``PACKAGES`` with a subtle bottom + border, matching VS Code's sidebar heading style. + """ + + def __init__(self, kind: PluginKind, parent: QWidget | None = None) -> None: + """Initialize the kind header with an uppercase label.""" + super().__init__(plugin_kind_group_label(kind).upper(), parent) + self.setObjectName('pluginKindHeader') + self.setStyleSheet(PLUGIN_KIND_HEADER_STYLE) + + +# --------------------------------------------------------------------------- +# Plugin provider header — thin row for the managing plugin +# --------------------------------------------------------------------------- + + +class PluginProviderHeader(QFrame): + """Thin sub-header row identifying the plugin that provides a set of tools. + + Shows the plugin name, version, installed status, and — for updatable + kinds — ``Auto`` and ``Update`` buttons. The ``Update`` button is + only visible when *has_updates* is ``True``. + """ + + auto_update_toggled = Signal(str, bool) + """Emitted with ``(plugin_name, enabled)`` when the auto-update toggle changes.""" + + update_requested = Signal(str) + """Emitted with the plugin name when the per-plugin *Update* button is clicked.""" + + def __init__( + self, + plugin: PluginInfo, + auto_update: bool = True, + *, + show_controls: bool = False, + has_updates: bool = False, + parent: QWidget | None = None, + ) -> None: + """Initialize the provider header with plugin info and optional controls.""" + super().__init__(parent) + self.setObjectName('pluginProvider') + self.setStyleSheet(PLUGIN_PROVIDER_STYLE) + self._plugin_name = plugin.name + self._update_btn: QPushButton | None = None + self._checking_spinner: _RowSpinner | None = None + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + # Plugin name + name_label = QLabel(plugin.name) + name_label.setStyleSheet(PLUGIN_PROVIDER_NAME_STYLE) + layout.addWidget(name_label) + + # Version + version_text = ( + str(plugin.tool_version) + if plugin.tool_version is not None + else 'Installed' + if plugin.installed + else 'Not installed' + ) + version_label = QLabel(version_text) + version_label.setStyleSheet(PLUGIN_PROVIDER_VERSION_STYLE) + layout.addWidget(version_label) + + # Installed indicator + status_label = QLabel('\u25cf' if plugin.installed else '\u25cb') + status_label.setStyleSheet( + PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE if plugin.installed else PLUGIN_PROVIDER_STATUS_MISSING_STYLE + ) + status_label.setToolTip('Installed' if plugin.installed else 'Not installed') + layout.addWidget(status_label) + + layout.addStretch() + + # Transient inline error label (hidden by default) + self._status_label = QLabel() + self._status_label.setStyleSheet(PLUGIN_ROW_ERROR_STYLE) + self._status_label.hide() + layout.addWidget(self._status_label) + + # Auto / Update controls (only for updatable kinds) + if show_controls: + toggle_btn = QPushButton('Auto') + toggle_btn.setCheckable(True) + toggle_btn.setChecked(auto_update) + toggle_btn.setStyleSheet(PLUGIN_TOGGLE_STYLE) + toggle_btn.setToolTip('Enable automatic updates for this plugin') + toggle_btn.clicked.connect( + lambda checked: self.auto_update_toggled.emit(self._plugin_name, checked), + ) + layout.addWidget(toggle_btn) + + self._checking_spinner = _RowSpinner(self) + layout.addWidget(self._checking_spinner) + + update_btn = QPushButton('Update') + update_btn.setStyleSheet(PLUGIN_UPDATE_STYLE) + update_btn.setToolTip(f'Upgrade packages via {plugin.name} now') + update_btn.clicked.connect( + lambda: self.update_requested.emit(self._plugin_name), + ) + update_btn.setVisible(has_updates) + self._update_btn = update_btn + layout.addWidget(update_btn) + + if not plugin.installed: + toggle_btn.setEnabled(False) + toggle_btn.setChecked(False) + toggle_btn.setToolTip('Not installed \u2014 cannot auto-update') + update_btn.setEnabled(False) + update_btn.setToolTip('Not installed \u2014 cannot update') + + def set_updating(self, updating: bool) -> None: + """Toggle the button between *Updating…* and *Update* states.""" + if self._update_btn is None: + return + if updating: + self._update_btn.setText('Updating\u2026') + self._update_btn.setEnabled(False) + else: + self._update_btn.setText('Update') + self._update_btn.setEnabled(True) + + def set_checking(self, checking: bool) -> None: + """Show or hide the inline checking spinner.""" + if self._checking_spinner is None: + return + if checking: + self._checking_spinner.start() + if self._update_btn is not None: + self._update_btn.hide() + else: + self._checking_spinner.stop() + + def set_error(self, message: str) -> None: + """Show a transient inline error that auto-hides after ~5 seconds.""" + self._status_label.setText(message) + self._status_label.show() + QTimer.singleShot(5000, self._status_label.hide) + + def clear_error(self) -> None: + """Immediately hide the inline error label.""" + self._status_label.hide() + + +# --------------------------------------------------------------------------- +# Plugin row — compact package / tool entry +# --------------------------------------------------------------------------- + + +class PluginRow(QFrame): + """Compact row showing an individual package or tool managed by a plugin. + + Displays the package name, the project it belongs to, and its version. + The row highlights on hover using VS Code dark-theme colours. + + When *show_toggle* is ``True`` an inline **Auto** button lets the user + toggle per-package auto-update. If *is_global* is ``True`` and no + *project* is given, a muted ``(global)`` annotation is shown. + + When *host_tool* is non-empty a muted ``→ `` label appears + after the name indicating the package is injected into that host. + + When *has_update* is ``True`` a small inline **Update** button appears + so the user can upgrade this specific package on demand. + """ + + auto_update_toggled = Signal(str, str, bool) + """Emitted with ``(plugin_name, package_name, enabled)`` on toggle.""" + + update_requested = Signal(str, str) + """Emitted with ``(plugin_name, package_name)`` when update is clicked.""" + + remove_requested = Signal(str, str) + """Emitted with ``(plugin_name, package_name)`` when remove is clicked.""" + + navigate_to_project = Signal(str) + """Emitted with a project path when a manifest-managed package tooltip link is clicked.""" + + def __init__( + self, + data: PluginRowData, + *, + parent: QWidget | None = None, + ) -> None: + """Initialize a plugin row from bundled display data.""" + super().__init__(parent) + self.setObjectName('pluginRow') + self.setStyleSheet(PLUGIN_ROW_STYLE) + self._plugin_name = data.plugin_name + self._package_name = data.name + self._update_btn: QPushButton | None = None + self._remove_btn: QPushButton | None = None + self._checking_spinner: _RowSpinner | None = None + self._host_label: QLabel | None = None + self._project_paths: list[str] = list(data.project_paths) + self._project_labels: list[str] = [p.project_label for p in data.project_instances] + self._update_status_label: QLabel | None = None + self._timestamp_label: QLabel | None = None + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + + self._build_name_section(layout, data) + layout.addStretch() + self._build_controls(layout, data) + + # --- PluginRow construction helpers --- + + def _build_name_section(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the name, host-tool arrow, project tags, and global label.""" + name_label = QLabel(data.name) + name_label.setStyleSheet(PLUGIN_ROW_NAME_STYLE) + layout.addWidget(name_label) + + if data.host_tool: + self._host_label = QLabel(f'\u2192 {data.host_tool}') + self._host_label.setStyleSheet(PLUGIN_ROW_HOST_STYLE) + layout.addWidget(self._host_label) + + # Inline project-name tags (replaces ProjectChildRow) + if data.project_instances: + for proj in data.project_instances: + tag = QLabel(proj.project_label) + style = PLUGIN_ROW_PROJECT_TAG_TRANSITIVE_STYLE if proj.is_transitive else PLUGIN_ROW_PROJECT_TAG_STYLE + tag.setStyleSheet(style) + tag.setToolTip(f'{proj.project_path}' + (' (transitive)' if proj.is_transitive else '')) + layout.addWidget(tag) + elif data.project: + project_label = QLabel(data.project) + project_label.setStyleSheet(PLUGIN_ROW_PROJECT_STYLE) + layout.addWidget(project_label) + elif data.is_global: + global_label = QLabel('(global)') + global_label.setStyleSheet(PLUGIN_ROW_GLOBAL_STYLE) + layout.addWidget(global_label) + + def _build_controls(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add toggle, update, status, version, timestamp, and remove controls. + + Controls are always created in the same order with fixed widths + so that columns align vertically across all rows. Hidden + controls still reserve space. + """ + if data.show_toggle: + self._build_toggle(layout, data) + + # Update button — always created for alignment, hidden when no update + self._build_update_button(layout, data) + + # Inline auto-update status (e.g. "Up to date", "v1.2 available") + self._update_status_label = QLabel() + self._update_status_label.setStyleSheet(PLUGIN_ROW_STATUS_STYLE) + self._update_status_label.hide() + layout.addWidget(self._update_status_label) + + # Version + if data.version: + version_label = QLabel(data.version) + version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE) + version_label.setMinimumWidth(PLUGIN_ROW_VERSION_MIN_WIDTH) + version_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(version_label) + + # Timestamp + if data.last_updated: + self._timestamp_label = QLabel(_format_relative_time(data.last_updated)) + self._timestamp_label.setStyleSheet(PLUGIN_ROW_TIMESTAMP_STYLE) + self._timestamp_label.setToolTip(f'Last updated: {data.last_updated}') + layout.addWidget(self._timestamp_label) + + # Transient inline error label (hidden by default) + self._error_label = QLabel() + self._error_label.setStyleSheet(PLUGIN_ROW_ERROR_STYLE) + self._error_label.hide() + layout.addWidget(self._error_label) + + self._build_remove_button(layout, data) + + def _build_toggle(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the auto-update toggle and inline checking spinner.""" + toggle_btn = QPushButton('Auto') + toggle_btn.setCheckable(True) + toggle_btn.setChecked(data.auto_update) + toggle_btn.setStyleSheet(PLUGIN_ROW_TOGGLE_STYLE) + toggle_btn.setToolTip('Auto-update this package') + toggle_btn.clicked.connect( + lambda checked: self.auto_update_toggled.emit( + self._plugin_name, + self._package_name, + checked, + ), + ) + layout.addWidget(toggle_btn) + + self._checking_spinner = _RowSpinner(self) + layout.addWidget(self._checking_spinner) + + def _build_update_button(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the per-package update button (always created, visibility toggled).""" + update_btn = QPushButton('Update') + update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) + update_btn.setFixedWidth(PLUGIN_ROW_UPDATE_WIDTH) + update_btn.setToolTip(f'Update {data.name}') + update_btn.clicked.connect( + lambda: self.update_requested.emit(self._plugin_name, self._package_name), + ) + update_btn.setVisible(data.has_update) + self._update_btn = update_btn + layout.addWidget(update_btn) + + def _build_remove_button(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the remove button — enabled only for global packages.""" + remove_btn = QPushButton('\u00d7') + remove_btn.setFixedSize(18, 18) + remove_btn.setStyleSheet(PLUGIN_ROW_REMOVE_STYLE) + remove_btn.setCursor(Qt.CursorShape.PointingHandCursor) + if data.is_global: + remove_btn.setToolTip(f'Remove {data.name}') + remove_btn.clicked.connect( + lambda: self.remove_requested.emit(self._plugin_name, self._package_name), + ) + else: + remove_btn.setEnabled(False) + tooltip = f"Managed by project '{data.project}'" if data.project else 'Managed by a project manifest' + remove_btn.setToolTip(tooltip) + remove_btn.setCursor(Qt.CursorShape.ArrowCursor) + self._remove_btn = remove_btn + layout.addWidget(remove_btn) + + def set_updating(self, updating: bool) -> None: + """Toggle the button between *Updating…* and *Update* states.""" + if self._update_btn is None: + return + if updating: + self._update_btn.setText('Updating\u2026') + self._update_btn.setEnabled(False) + else: + self._update_btn.setText('Update') + self._update_btn.setEnabled(True) + + def set_checking(self, checking: bool) -> None: + """Show or hide the inline checking spinner.""" + if self._checking_spinner is None: + return + if checking: + self._checking_spinner.start() + if self._update_btn is not None: + self._update_btn.hide() + else: + self._checking_spinner.stop() + + def set_removing(self, removing: bool) -> None: + """Toggle the remove button between *Removing…* and *×* states.""" + if self._remove_btn is None: + return + if removing: + self._remove_btn.setText('Removing\u2026') + self._remove_btn.setEnabled(False) + else: + self._remove_btn.setText('\u00d7') + self._remove_btn.setEnabled(True) + + def set_update_status(self, text: str, style: str = '') -> None: + """Show inline auto-update check status (e.g. 'Up to date').""" + if self._update_status_label is None: + return + self._update_status_label.setText(text) + if style: + self._update_status_label.setStyleSheet(style) + self._update_status_label.setVisible(bool(text)) + + def update_timestamp(self) -> None: + """Refresh the relative time display on the timestamp label.""" + if self._timestamp_label is not None: + tip = self._timestamp_label.toolTip() + # Extract ISO timestamp from tooltip + prefix = 'Last updated: ' + if tip.startswith(prefix): + iso = tip[len(prefix) :] + self._timestamp_label.setText(_format_relative_time(iso)) + + def set_error(self, message: str) -> None: + """Show a transient inline error that auto-hides after ~5 seconds.""" + self._error_label.setText(message) + self._error_label.show() + QTimer.singleShot(5000, self._error_label.hide) + + def clear_error(self) -> None: + """Immediately hide the inline error label.""" + self._error_label.hide() + + +# --------------------------------------------------------------------------- +# Project child row — indented sub-row for project-scoped packages +# --------------------------------------------------------------------------- + + +class ProjectChildRow(QFrame): + """Indented sub-row showing a project-scoped instance of a package. + + Displays the project label, version, and an optional ``(transitive)`` + annotation for packages not declared in the project manifest. + A small navigate button switches to the Projects tab. + + These rows appear directly below the parent :class:`PluginRow` and + are read-only — no update, remove, or auto-update controls. + """ + + navigate_to_project = Signal(str) + """Emitted with a project path when the navigate button is clicked.""" + + def __init__( + self, + project: ProjectInstance, + *, + package_name: str = '', + parent: QWidget | None = None, + ) -> None: + """Initialize the project child row. + + Args: + project: The project instance data to display. + package_name: Package name (displayed dimmed). + parent: Optional parent widget. + """ + super().__init__(parent) + self.setObjectName('projectChildRow') + self.setStyleSheet(PROJECT_CHILD_ROW_STYLE) + self._project_path = project.project_path + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + # Package name (dimmed) + if package_name: + name_label = QLabel(package_name) + name_label.setStyleSheet(PROJECT_CHILD_NAME_STYLE) + layout.addWidget(name_label) + + # Project label + project_label = QLabel(project.project_label) + project_label.setStyleSheet(PROJECT_CHILD_PROJECT_STYLE) + layout.addWidget(project_label) + + # Transitive annotation + if project.is_transitive: + transitive_label = QLabel('(transitive)') + transitive_label.setStyleSheet(PROJECT_CHILD_TRANSITIVE_STYLE) + layout.addWidget(transitive_label) + + layout.addStretch() + + # Version + if project.version: + version_label = QLabel(project.version) + version_label.setStyleSheet(PROJECT_CHILD_VERSION_STYLE) + layout.addWidget(version_label) + + # Navigate button + nav_btn = QPushButton('\u2192') + nav_btn.setFixedSize(18, 18) + nav_btn.setStyleSheet(PROJECT_CHILD_NAV_STYLE) + nav_btn.setCursor(Qt.CursorShape.PointingHandCursor) + nav_btn.setToolTip(f'Open project: {project.project_label}') + nav_btn.clicked.connect(lambda: self.navigate_to_project.emit(self._project_path)) + layout.addWidget(nav_btn) + + +# --------------------------------------------------------------------------- +# Filter chip — toggleable pill for plugin filtering +# --------------------------------------------------------------------------- + + +class FilterChip(QPushButton): + """Small toggleable pill button representing a single plugin filter. + + All chips start *checked* (active). The user deselects chips to + hide packages from that plugin — subtractive filtering. + """ + + toggled_with_name = Signal(str, bool) + """Emitted with ``(plugin_name, checked)`` when the chip is toggled.""" + + def __init__(self, plugin_name: str, parent: QWidget | None = None) -> None: + """Initialize a filter chip for the given plugin name.""" + super().__init__(plugin_name, parent) + self._plugin_name = plugin_name + self.setCheckable(True) + self.setChecked(True) + self.setStyleSheet(FILTER_CHIP_STYLE) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.toggled.connect(lambda checked: self.toggled_with_name.emit(self._plugin_name, checked)) diff --git a/synodic_client/application/screen/projects.py b/synodic_client/application/screen/projects.py new file mode 100644 index 0000000..b366cbc --- /dev/null +++ b/synodic_client/application/screen/projects.py @@ -0,0 +1,268 @@ +"""ProjectsView — widget for managing project directories and their manifests.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from porringer.api import API +from porringer.backend.command.core.discovery import DiscoveredPlugins +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from synodic_client.application.data import DataCoordinator +from synodic_client.application.screen.install import SetupPreviewWidget +from synodic_client.application.screen.schema import PreviewPhase +from synodic_client.application.screen.sidebar import ManifestSidebar +from synodic_client.application.screen.spinner import SpinnerWidget +from synodic_client.application.theme import COMPACT_MARGINS +from synodic_client.resolution import ResolvedConfig + +logger = logging.getLogger(__name__) + + +class ProjectsView(QWidget): + """Widget for managing project directories and previewing their manifests. + + Displays a vertical sidebar of cached project directories on the + left with a stacked widget on the right showing one + :class:`SetupPreviewWidget` per manifest. All manifests are loaded + in parallel on first refresh; switching between them is instant. + """ + + def __init__( + self, + porringer: API, + config: ResolvedConfig, + parent: QWidget | None = None, + *, + coordinator: DataCoordinator | None = None, + ) -> None: + """Initialize the projects view. + + Args: + porringer: The porringer API instance. + config: Resolved configuration. + parent: Optional parent widget. + coordinator: Shared data coordinator for validated directory + data. + """ + super().__init__(parent) + self._porringer = porringer + self._config = config + self._coordinator = coordinator + self._refresh_in_progress = False + self._pending_select: Path | None = None + self._widgets: dict[Path, SetupPreviewWidget] = {} + self._init_ui() + + def _init_ui(self) -> None: + """Build the sidebar + stacked widget layout.""" + outer = QHBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + # Left — sidebar + self._sidebar = ManifestSidebar() + self._sidebar.add_requested.connect(self._on_add) + self._sidebar.remove_requested.connect(self._on_remove) + self._sidebar.selection_changed.connect(self._on_selection_changed) + outer.addWidget(self._sidebar) + + # Right — stacked previews + empty placeholder + right = QVBoxLayout() + right.setContentsMargins(*COMPACT_MARGINS) + right.setSpacing(0) + + self._stack = QStackedWidget() + right.addWidget(self._stack, stretch=1) + + # Empty placeholder shown when there are no manifests + self._empty_placeholder = QLabel('No projects. Click + Add Project to get started.') + self._empty_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty_placeholder.setStyleSheet('color: grey; font-size: 13px;') + self._stack.addWidget(self._empty_placeholder) + + outer.addLayout(right, stretch=1) + + self._loading_spinner = SpinnerWidget('Loading projects\u2026', parent=self) + + # --- Public API --- + + def refresh(self) -> None: + """Schedule an asynchronous refresh of the cached directories.""" + if self._refresh_in_progress: + return + asyncio.create_task(self._async_refresh()) + + async def _async_refresh(self) -> None: + """Refresh the sidebar and stacked widgets from the porringer cache.""" + self._refresh_in_progress = True + self._loading_spinner.start() + self._sidebar.set_enabled(False) + + try: + previous = self._pending_select or self._sidebar.selected_path + self._pending_select = None + + if self._coordinator is not None: + snapshot = await self._coordinator.refresh() + results = snapshot.validated_directories + discovered = snapshot.discovered + else: + loop = asyncio.get_running_loop() + results = await loop.run_in_executor( + None, + lambda: self._porringer.cache.list_directories( + validate=True, + check_manifest=True, + ), + ) + discovered = None + + directories: list[tuple[Path, str, bool]] = [] + current_paths: set[Path] = set() + for result in results: + d = result.directory + valid = bool(result.exists and result.has_manifest is not False) + path = Path(d.path) + directories.append((path, d.name or '', valid)) + current_paths.add(path) + + # Remove widgets for directories no longer in cache + self._remove_stale_widgets(current_paths) + + # Grab pre-discovered plugins so each widget can skip redundant discovery + + # Create new widgets for new directories + self._create_directory_widgets(directories, discovered) + + # Rebuild sidebar + self._sidebar.set_directories(directories) + self._sidebar.select(previous) + + # Push latest discovered plugins to all existing widgets + if discovered is not None: + for w in self._widgets.values(): + w._discovered_plugins = discovered + + # Load all stacked widgets in parallel + for path, _name, valid in directories: + widget = self._widgets.get(path) + if widget is not None and valid: + widget.load( + str(path), + project_directory=path if path.is_dir() else path.parent, + detect_updates=self._config.detect_updates, + ) + + except Exception: + logger.exception('Failed to refresh projects') + finally: + self._loading_spinner.stop() + self._sidebar.set_enabled(True) + self._refresh_in_progress = False + + # --- Event handlers --- + + def _remove_stale_widgets(self, current_paths: set[Path]) -> None: + """Remove stacked widgets for directories no longer in the cache.""" + for path in list(self._widgets): + if path not in current_paths: + widget = self._widgets.pop(path) + self._stack.removeWidget(widget) + widget.reset() + widget.deleteLater() + + def _create_directory_widgets( + self, + directories: list[tuple[Path, str, bool]], + discovered: DiscoveredPlugins | None, + ) -> None: + """Create :class:`SetupPreviewWidget` instances for new valid directories.""" + for path, _name, valid in directories: + if path not in self._widgets and valid: + widget = SetupPreviewWidget( + self._porringer, + self, + show_close=False, + config=self._config, + ) + widget._discovered_plugins = discovered + widget.install_finished.connect(self._on_install_finished) + widget.phase_changed.connect( + lambda phase, p=path: self._on_widget_phase_changed(p, phase), + ) + self._widgets[path] = widget + self._stack.addWidget(widget) + + def _on_selection_changed(self, path: Path) -> None: + """Handle sidebar selection — switch the stacked widget.""" + widget = self._widgets.get(path) + if widget is not None: + self._stack.setCurrentWidget(widget) + else: + self._stack.setCurrentWidget(self._empty_placeholder) + + def _on_widget_phase_changed(self, path: Path, phase: PreviewPhase) -> None: + """Update the sidebar item's phase indicator.""" + item = self._sidebar.get_item(path) + if item is not None: + item.set_phase(phase) + + def _on_add(self) -> None: + """Open a file picker and immediately cache the chosen directory.""" + filenames = self._porringer.sync.manifest_filenames() + filter_str = 'Manifests (' + ' '.join(filenames) + ');;All Files (*)' + chosen, _ = QFileDialog.getOpenFileName( + self, + 'Select Manifest File', + '', + filter_str, + ) + if not chosen: + return + + selected = Path(chosen) + directory = selected if selected.is_dir() else selected.parent + + try: + self._porringer.cache.add_directory(directory) + logger.info('Cached new project directory: %s', directory) + except ValueError: + logger.debug('Directory already cached: %s', directory) + + if self._coordinator is not None: + self._coordinator.invalidate() + self._pending_select = directory + self.refresh() + + def _on_remove(self, path: Path) -> None: + """Remove a directory from the porringer cache.""" + self._porringer.cache.remove_directory(path) + logger.info('Removed project directory from cache: %s', path) + + # Tear down the widget immediately + widget = self._widgets.pop(path, None) + if widget is not None: + self._stack.removeWidget(widget) + widget.reset() + widget.deleteLater() + + if self._coordinator is not None: + self._coordinator.invalidate() + self.refresh() + + def _on_install_finished(self, _results: object) -> None: + """Refresh after a successful install.""" + if self._coordinator is not None: + self._coordinator.invalidate() + self.refresh() diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py new file mode 100644 index 0000000..0784a29 --- /dev/null +++ b/synodic_client/application/screen/schema.py @@ -0,0 +1,363 @@ +"""Screen-layer data models and enums. + +Contains all dataclasses, enums, and plain data classes used by the +install preview, tools view, update banner, and related screen widgets. +Keeping them in a dedicated module avoids circular imports between +widget files. +""" + +from __future__ import annotations + +import enum +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum, auto +from pathlib import Path + +from porringer.schema import ( + PluginInfo, + SetupAction, + SetupActionResult, + SetupResults, + SubActionProgress, + SyncStrategy, +) + +from synodic_client.application.screen.action_card import action_key +from synodic_client.application.uri import normalize_manifest_key + +# --------------------------------------------------------------------------- +# Package gathering & display (from screen.py) +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class PackageEntry: + """A single package returned by a gather query. + + Replaces ad-hoc tuples returned by ``_gather_packages`` and + ``_gather_tool_plugins``. + """ + + name: str + """Package name (e.g. ``"pdm"``, ``"ruff"``).""" + + project_label: str = '' + """Human-readable project directory label, or empty for global packages.""" + + version: str = '' + """Installed version string, or empty if unknown.""" + + host_tool: str = '' + """Name of the host package when injected (e.g. ``"pdm"``), otherwise empty.""" + + project_path: str = '' + """Directory path string for project-scoped packages, or empty for global ones.""" + + +@dataclass(slots=True) +class ProjectInstance: + """A single project-scoped occurrence of a package. + + Represents the package as found in one project venv. Multiple + instances may exist when the same package appears in several + cached projects. + """ + + project_label: str + """Human-readable project directory label.""" + + project_path: str + """Filesystem path of the project directory.""" + + version: str = '' + """Installed version string in this project.""" + + is_transitive: bool = False + """``True`` when the package is not declared in the project manifest.""" + + +@dataclass(slots=True) +class DisplayPackage: + """Two-tier view of a package for the ToolsView widget tree. + + Replaces :class:`MergedPackage` with an explicit global/project + split. The ``global_version`` indicates whether the package is + installed in the global environment; ``project_instances`` lists + each project venv where it was found. + """ + + name: str + """Package name (e.g. ``"ruff"``).""" + + global_version: str | None = None + """Version in the global environment, or ``None`` when not global.""" + + is_global: bool = False + """``True`` when the package is installed globally.""" + + host_tool: str = '' + """Host-tool annotation for injected packages.""" + + project_instances: list[ProjectInstance] = field(default_factory=list) + """Project-scoped occurrences of this package.""" + + +@dataclass(slots=True) +class PluginRowData: + """Bundled display data for constructing a ``PluginRow``. + + Groups the many display parameters into a single object + to keep the constructor signature concise. + """ + + name: str + """Package or tool name.""" + + project: str = '' + """Comma-separated project labels, or empty for global / bare rows.""" + + version: str = '' + """Installed version string.""" + + plugin_name: str = '' + """Name of the managing plugin (e.g. ``"pipx"``).""" + + auto_update: bool = False + """Current per-package auto-update toggle state.""" + + show_toggle: bool = False + """Whether to show the inline *Auto* toggle button.""" + + has_update: bool = False + """Whether an update is available for this package.""" + + is_global: bool = False + """``True`` when the package is globally installed.""" + + host_tool: str = '' + """Host-tool name for injected packages.""" + + project_paths: list[str] = field(default_factory=list) + """Filesystem paths for project-scoped packages.""" + + project_instances: list[ProjectInstance] = field(default_factory=list) + """Project-scoped occurrences of this package, displayed as inline tags.""" + + last_updated: str = '' + """ISO 8601 timestamp of the last successful update, or empty.""" + + +@dataclass(slots=True) +class _RefreshData: + """Internal data bundle returned by ``ToolsView._gather_refresh_data``.""" + + plugins: list[PluginInfo] + """All discovered plugins.""" + + packages_map: dict[str, list[PackageEntry]] + """Mapping of plugin name → gathered packages.""" + + manifest_packages: dict[str, set[str]] + """Mapping of plugin name → manifest-referenced package names.""" + + +# --------------------------------------------------------------------------- +# Install preview data models (from install.py) +# --------------------------------------------------------------------------- + + +class PreviewPhase(enum.Enum): + """Lifecycle phase of a ``SetupPreviewWidget``. + + The widget transitions through these phases and uses them to decide + whether certain operations (like reloading the preview or toggling + buttons) are allowed. Having an explicit enum replaces the previous + ``_installing`` boolean flag and status-label-text-based implicit state. + """ + + IDLE = 'idle' + """No preview loaded.""" + + LOADING = 'loading' + """Skeleton placeholders displayed; preview worker running.""" + + PREVIEWING = 'previewing' + """Cards populated; dry-run status checks in progress.""" + + READY = 'ready' + """Dry-run complete; install button may be enabled.""" + + INSTALLING = 'installing' + """Install worker running.""" + + DONE = 'done' + """Install finished; execution logs visible.""" + + ERROR = 'error' + """Preview or install failed.""" + + +@dataclass +class ActionState: + """Per-action data that survives widget rebuilds. + + Each entry stores the authoritative execution log so that + ``ActionCard`` widgets can be destroyed and recreated + without losing output. + """ + + action: SetupAction + """The porringer setup action.""" + + status: str = 'Checking\u2026' + """Human-readable dry-run status label.""" + + log_lines: list[tuple[str, str | None]] = field(default_factory=list) + """Accumulated execution log: ``(text, stream)`` pairs.""" + + +class PreviewModel: + """Data model for a single preview / install session. + + Holds all state that the ``SetupPreviewWidget`` needs to + display and that must survive ``ActionCard`` widget destruction. + The model is replaced wholesale when a new preview is loaded; during + an install it is updated in-place and outlives any UI refresh. + """ + + def __init__(self) -> None: + """Initialise a blank preview model.""" + self._action_key = action_key + self._normalize = normalize_manifest_key + + self.phase: PreviewPhase = PreviewPhase.IDLE + self.preview: SetupResults | None = None + self.manifest_path: Path | None = None + self.manifest_key: str | None = None + self.project_directory: Path | None = None + self.plugin_installed: dict[str, bool] = {} + self.prerelease_overrides: set[str] = set() + self.action_states: list[ActionState] = [] + self._action_state_map: dict[tuple[object, ...], ActionState] = {} + self._action_state_map_len: int = 0 + self.upgradable_keys: set[tuple[object, ...]] = set() + self.checked_count: int = 0 + self.completed_count: int = 0 + self.temp_dir: str | None = None + + # -- Computed helpers -------------------------------------------------- + + def _ensure_action_state_map(self) -> dict[tuple[object, ...], ActionState]: + """Return the action-key → state lookup, rebuilding if stale.""" + if len(self.action_states) != self._action_state_map_len: + self._action_state_map = {self._action_key(s.action): s for s in self.action_states} + self._action_state_map_len = len(self.action_states) + return self._action_state_map + + @property + def actionable_count(self) -> int: + """Number of needed + upgradable actions.""" + needed = sum(1 for s in self.action_states if s.status == 'Needed') + upgradable = len(self.upgradable_keys) + return needed + upgradable + + @property + def install_enabled(self) -> bool: + """Whether the install button should be enabled.""" + if self.phase not in {PreviewPhase.READY}: + return False + return self.actionable_count > 0 or any(s.action.kind is None for s in self.action_states) + + def action_state_for(self, act: SetupAction) -> ActionState | None: + """Look up :class:`ActionState` by content key (O(1) amortized).""" + return self._ensure_action_state_map().get(self._action_key(act)) + + def has_same_manifest(self, key: str) -> bool: + """Return ``True`` if *key* matches the current manifest key.""" + return self.manifest_key is not None and self.manifest_key == self._normalize(key) + + +@dataclass(frozen=True, slots=True) +class InstallConfig: + """Optional execution parameters for the install worker.""" + + project_directory: Path | None = None + strategy: SyncStrategy = SyncStrategy.MINIMAL + prerelease_packages: set[str] | None = field(default=None) + + +@dataclass(frozen=True, slots=True) +class InstallCallbacks: + """Callbacks for :func:`run_install` progress reporting.""" + + on_action_started: Callable[[SetupAction], None] | None = None + """Called when an action begins execution.""" + + on_sub_progress: Callable[[SetupAction, SubActionProgress], None] | None = None + """Called for sub-action progress events.""" + + on_progress: Callable[[SetupAction, SetupActionResult], None] | None = None + """Called when a single action completes.""" + + +@dataclass(frozen=True, slots=True) +class PreviewCallbacks: + """Callbacks for :func:`run_preview` progress reporting.""" + + on_manifest_parsed: Callable[[SetupResults, str, str], None] | None = None + """``(SetupResults, manifest_path, temp_dir)`` — after JSON load.""" + + on_plugins_queried: Callable[[dict[str, bool]], None] | None = None + """``(dict[str, bool])`` — plugin → installed mapping.""" + + on_preview_ready: Callable[[SetupResults, str, str], None] | None = None + """``(SetupResults, manifest_path, temp_dir)`` — CLI commands resolved.""" + + on_action_checked: Callable[[int, SetupActionResult], None] | None = None + """``(row_index, SetupActionResult)`` — per-action dry-run result.""" + + +@dataclass(frozen=True, slots=True) +class PreviewConfig: + """Optional execution parameters for :func:`run_preview`.""" + + project_directory: Path | None = None + detect_updates: bool = True + prerelease_packages: set[str] | None = None + + +@dataclass(slots=True) +class _DispatchState: + """Mutable accumulator for :func:`_dispatch_preview_event`.""" + + action_index: dict[int, int] = field(default_factory=dict) + got_parsed: bool = False + + +# --------------------------------------------------------------------------- +# Update banner data models (from update_banner.py) +# --------------------------------------------------------------------------- + + +class UpdateBannerState(Enum): + """Visual states for the update banner.""" + + HIDDEN = auto() + DOWNLOADING = auto() + READY = auto() + ERROR = auto() + + +@dataclass(frozen=True, slots=True) +class _BannerConfig: + """Bundled visual configuration for a banner state transition.""" + + state: UpdateBannerState + style: str + icon: str + text: str + text_style: str + version: str = '' + action_label: str = '' + show_progress: bool = False diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 0be9ae0..4146d1e 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -3,12 +3,10 @@ import asyncio import logging from collections import OrderedDict -from dataclasses import dataclass, field from pathlib import Path from porringer.api import API from porringer.backend.builder import Builder -from porringer.backend.command.core.discovery import DiscoveredPlugins from porringer.core.plugin_schema.plugin_manager import PluginManager from porringer.core.plugin_schema.project_environment import ProjectEnvironment from porringer.schema import ( @@ -21,18 +19,13 @@ SyncStrategy, ) from porringer.schema.plugin import PluginKind -from PySide6.QtCore import QRect, Qt, QTimer, Signal -from PySide6.QtGui import QPainter, QPen +from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtWidgets import ( - QFileDialog, - QFrame, QHBoxLayout, - QLabel, QLineEdit, QMainWindow, QPushButton, QScrollArea, - QStackedWidget, QTabWidget, QVBoxLayout, QWidget, @@ -40,41 +33,29 @@ from synodic_client.application.data import DataCoordinator from synodic_client.application.icon import app_icon -from synodic_client.application.screen import plugin_kind_group_label -from synodic_client.application.screen.install import PreviewPhase, SetupPreviewWidget -from synodic_client.application.screen.sidebar import ManifestSidebar +from synodic_client.application.screen.plugin_row import ( + FilterChip, + PluginKindHeader, + PluginProviderHeader, + PluginRow, +) +from synodic_client.application.screen.projects import ProjectsView +from synodic_client.application.screen.schema import ( + DisplayPackage, + PackageEntry, + PluginRowData, + ProjectInstance, + _RefreshData, +) from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.screen.update_banner import UpdateBanner from synodic_client.application.theme import ( COMPACT_MARGINS, FILTER_CHIP_SPACING, - FILTER_CHIP_STYLE, MAIN_WINDOW_MIN_SIZE, - PLUGIN_KIND_HEADER_STYLE, - PLUGIN_PROVIDER_NAME_STYLE, - PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE, - PLUGIN_PROVIDER_STATUS_MISSING_STYLE, - PLUGIN_PROVIDER_STYLE, - PLUGIN_PROVIDER_VERSION_STYLE, - PLUGIN_ROW_ERROR_STYLE, - PLUGIN_ROW_GLOBAL_STYLE, - PLUGIN_ROW_HOST_STYLE, - PLUGIN_ROW_NAME_STYLE, - PLUGIN_ROW_PROJECT_STYLE, - PLUGIN_ROW_REMOVE_STYLE, - PLUGIN_ROW_STYLE, - PLUGIN_ROW_TOGGLE_STYLE, - PLUGIN_ROW_UPDATE_STYLE, - PLUGIN_ROW_VERSION_STYLE, + PLUGIN_ROW_STATUS_AVAILABLE_STYLE, + PLUGIN_ROW_STATUS_UP_TO_DATE_STYLE, PLUGIN_SECTION_SPACING, - PLUGIN_TOGGLE_STYLE, - PLUGIN_UPDATE_STYLE, - PROJECT_CHILD_NAME_STYLE, - PROJECT_CHILD_NAV_STYLE, - PROJECT_CHILD_PROJECT_STYLE, - PROJECT_CHILD_ROW_STYLE, - PROJECT_CHILD_TRANSITIVE_STYLE, - PROJECT_CHILD_VERSION_STYLE, SEARCH_INPUT_STYLE, SETTINGS_GEAR_STYLE, ) @@ -85,14 +66,7 @@ # Plugin kinds that support auto-update and per-plugin upgrade. _UPDATABLE_KINDS = frozenset({PluginKind.TOOL, PluginKind.PACKAGE}) -# Inline row-spinner constants -_ROW_SPINNER_SIZE = 12 -_ROW_SPINNER_PEN = 2 -_ROW_SPINNER_INTERVAL = 50 -_ROW_SPINNER_ARC = 90 -_FULL_CIRCLE_DEG = 360 - -# Preferred display ordering — Tools first, then alphabetical for the rest. +# Preferred display ordering — Tools first, then alphabetical for the rest. _KIND_DISPLAY_ORDER: dict[PluginKind, int] = { PluginKind.TOOL: 0, PluginKind.PACKAGE: 1, @@ -102,639 +76,6 @@ } -# --------------------------------------------------------------------------- -# Data models for package gathering and display -# --------------------------------------------------------------------------- - - -@dataclass(slots=True) -class PackageEntry: - """A single package returned by a gather query. - - Replaces ad-hoc tuples returned by ``_gather_packages`` and - ``_gather_tool_plugins``. - """ - - name: str - """Package name (e.g. ``"pdm"``, ``"ruff"``).""" - - project_label: str = '' - """Human-readable project directory label, or empty for global packages.""" - - version: str = '' - """Installed version string, or empty if unknown.""" - - host_tool: str = '' - """Name of the host package when injected (e.g. ``"pdm"``), otherwise empty.""" - - project_path: str = '' - """Directory path string for project-scoped packages, or empty for global ones.""" - - -@dataclass(slots=True) -class ProjectInstance: - """A single project-scoped occurrence of a package. - - Represents the package as found in one project venv. Multiple - instances may exist when the same package appears in several - cached projects. - """ - - project_label: str - """Human-readable project directory label.""" - - project_path: str - """Filesystem path of the project directory.""" - - version: str = '' - """Installed version string in this project.""" - - is_transitive: bool = False - """``True`` when the package is not declared in the project manifest.""" - - -@dataclass(slots=True) -class DisplayPackage: - """Two-tier view of a package for the ToolsView widget tree. - - Replaces :class:`MergedPackage` with an explicit global/project - split. The ``global_version`` indicates whether the package is - installed in the global environment; ``project_instances`` lists - each project venv where it was found. - """ - - name: str - """Package name (e.g. ``"ruff"``).""" - - global_version: str | None = None - """Version in the global environment, or ``None`` when not global.""" - - is_global: bool = False - """``True`` when the package is installed globally.""" - - host_tool: str = '' - """Host-tool annotation for injected packages.""" - - project_instances: list[ProjectInstance] = field(default_factory=list) - """Project-scoped occurrences of this package.""" - - -@dataclass(slots=True) -class PluginRowData: - """Bundled display data for constructing a :class:`PluginRow`. - - Groups the many display parameters into a single object - to keep the constructor signature concise. - """ - - name: str - """Package or tool name.""" - - project: str = '' - """Comma-separated project labels, or empty for global / bare rows.""" - - version: str = '' - """Installed version string.""" - - plugin_name: str = '' - """Name of the managing plugin (e.g. ``"pipx"``).""" - - auto_update: bool = False - """Current per-package auto-update toggle state.""" - - show_toggle: bool = False - """Whether to show the inline *Auto* toggle button.""" - - has_update: bool = False - """Whether an update is available for this package.""" - - is_global: bool = False - """``True`` when the package is globally installed.""" - - host_tool: str = '' - """Host-tool name for injected packages.""" - - project_paths: list[str] = field(default_factory=list) - """Filesystem paths for project-scoped packages.""" - - -@dataclass(slots=True) -class _RefreshData: - """Internal data bundle returned by :meth:`ToolsView._gather_refresh_data`.""" - - plugins: list[PluginInfo] - """All discovered plugins.""" - - packages_map: dict[str, list[PackageEntry]] - """Mapping of plugin name → gathered packages.""" - - manifest_packages: dict[str, set[str]] - """Mapping of plugin name → manifest-referenced package names.""" - - -# --------------------------------------------------------------------------- -# _RowSpinner — tiny inline spinner for plugin rows -# --------------------------------------------------------------------------- - - -class _RowSpinner(QWidget): - """Tiny spinning arc shown inline while checking for updates.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._angle = 0 - self.setFixedSize(_ROW_SPINNER_SIZE, _ROW_SPINNER_SIZE) - self._timer = QTimer(self) - self._timer.setInterval(_ROW_SPINNER_INTERVAL) - self._timer.timeout.connect(self._tick) - self.hide() - - def paintEvent(self, _event: object) -> None: - """Draw the muted track and animated highlight arc.""" - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - m = _ROW_SPINNER_PEN // 2 + 1 - rect = QRect(m, m, _ROW_SPINNER_SIZE - 2 * m, _ROW_SPINNER_SIZE - 2 * m) - for colour, span in ((self.palette().mid(), _FULL_CIRCLE_DEG), (self.palette().highlight(), _ROW_SPINNER_ARC)): - pen = QPen(colour, _ROW_SPINNER_PEN) - pen.setCapStyle(Qt.PenCapStyle.RoundCap) - painter.setPen(pen) - if span == _FULL_CIRCLE_DEG: - painter.drawEllipse(rect) - else: - painter.drawArc(rect, self._angle * 16, span * 16) - painter.end() - - def _tick(self) -> None: - self._angle = (self._angle - 10) % 360 - self.update() - - def start(self) -> None: - """Show the spinner and start the animation.""" - self._angle = 0 - self.show() - self._timer.start() - - def stop(self) -> None: - """Stop the animation and hide.""" - self._timer.stop() - self.hide() - - -# --------------------------------------------------------------------------- -# Plugin kind header — uppercase section divider -# --------------------------------------------------------------------------- - - -class PluginKindHeader(QLabel): - """Uppercase, muted section divider for a plugin-kind group. - - Displays a label like ``TOOLS`` or ``PACKAGES`` with a subtle bottom - border, matching VS Code's sidebar heading style. - """ - - def __init__(self, kind: PluginKind, parent: QWidget | None = None) -> None: - """Initialize the kind header with an uppercase label.""" - super().__init__(plugin_kind_group_label(kind).upper(), parent) - self.setObjectName('pluginKindHeader') - self.setStyleSheet(PLUGIN_KIND_HEADER_STYLE) - - -# --------------------------------------------------------------------------- -# Plugin provider header — thin row for the managing plugin -# --------------------------------------------------------------------------- - - -class PluginProviderHeader(QFrame): - """Thin sub-header row identifying the plugin that provides a set of tools. - - Shows the plugin name, version, installed status, and — for updatable - kinds — ``Auto`` and ``Update`` buttons. The ``Update`` button is - only visible when *has_updates* is ``True``. - """ - - auto_update_toggled = Signal(str, bool) - """Emitted with ``(plugin_name, enabled)`` when the auto-update toggle changes.""" - - update_requested = Signal(str) - """Emitted with the plugin name when the per-plugin *Update* button is clicked.""" - - def __init__( - self, - plugin: PluginInfo, - auto_update: bool = True, - *, - show_controls: bool = False, - has_updates: bool = False, - parent: QWidget | None = None, - ) -> None: - """Initialize the provider header with plugin info and optional controls.""" - super().__init__(parent) - self.setObjectName('pluginProvider') - self.setStyleSheet(PLUGIN_PROVIDER_STYLE) - self._plugin_name = plugin.name - self._update_btn: QPushButton | None = None - self._checking_spinner: _RowSpinner | None = None - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) - - # Plugin name - name_label = QLabel(plugin.name) - name_label.setStyleSheet(PLUGIN_PROVIDER_NAME_STYLE) - layout.addWidget(name_label) - - # Version - version_text = ( - str(plugin.tool_version) - if plugin.tool_version is not None - else 'Installed' - if plugin.installed - else 'Not installed' - ) - version_label = QLabel(version_text) - version_label.setStyleSheet(PLUGIN_PROVIDER_VERSION_STYLE) - layout.addWidget(version_label) - - # Installed indicator - status_label = QLabel('\u25cf' if plugin.installed else '\u25cb') - status_label.setStyleSheet( - PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE if plugin.installed else PLUGIN_PROVIDER_STATUS_MISSING_STYLE - ) - status_label.setToolTip('Installed' if plugin.installed else 'Not installed') - layout.addWidget(status_label) - - layout.addStretch() - - # Transient inline error label (hidden by default) - self._status_label = QLabel() - self._status_label.setStyleSheet(PLUGIN_ROW_ERROR_STYLE) - self._status_label.hide() - layout.addWidget(self._status_label) - - # Auto / Update controls (only for updatable kinds) - if show_controls: - toggle_btn = QPushButton('Auto') - toggle_btn.setCheckable(True) - toggle_btn.setChecked(auto_update) - toggle_btn.setStyleSheet(PLUGIN_TOGGLE_STYLE) - toggle_btn.setToolTip('Enable automatic updates for this plugin') - toggle_btn.clicked.connect( - lambda checked: self.auto_update_toggled.emit(self._plugin_name, checked), - ) - layout.addWidget(toggle_btn) - - self._checking_spinner = _RowSpinner(self) - layout.addWidget(self._checking_spinner) - - update_btn = QPushButton('Update') - update_btn.setStyleSheet(PLUGIN_UPDATE_STYLE) - update_btn.setToolTip(f'Upgrade packages via {plugin.name} now') - update_btn.clicked.connect( - lambda: self.update_requested.emit(self._plugin_name), - ) - update_btn.setVisible(has_updates) - self._update_btn = update_btn - layout.addWidget(update_btn) - - if not plugin.installed: - toggle_btn.setEnabled(False) - toggle_btn.setChecked(False) - toggle_btn.setToolTip('Not installed \u2014 cannot auto-update') - update_btn.setEnabled(False) - update_btn.setToolTip('Not installed \u2014 cannot update') - - def set_updating(self, updating: bool) -> None: - """Toggle the button between *Updating…* and *Update* states.""" - if self._update_btn is None: - return - if updating: - self._update_btn.setText('Updating\u2026') - self._update_btn.setEnabled(False) - else: - self._update_btn.setText('Update') - self._update_btn.setEnabled(True) - - def set_checking(self, checking: bool) -> None: - """Show or hide the inline checking spinner.""" - if self._checking_spinner is None: - return - if checking: - self._checking_spinner.start() - if self._update_btn is not None: - self._update_btn.hide() - else: - self._checking_spinner.stop() - - def set_error(self, message: str) -> None: - """Show a transient inline error that auto-hides after ~5 seconds.""" - self._status_label.setText(message) - self._status_label.show() - QTimer.singleShot(5000, self._status_label.hide) - - def clear_error(self) -> None: - """Immediately hide the inline error label.""" - self._status_label.hide() - - -# --------------------------------------------------------------------------- -# Plugin row — compact package / tool entry -# --------------------------------------------------------------------------- - - -class PluginRow(QFrame): - """Compact row showing an individual package or tool managed by a plugin. - - Displays the package name, the project it belongs to, and its version. - The row highlights on hover using VS Code dark-theme colours. - - When *show_toggle* is ``True`` an inline **Auto** button lets the user - toggle per-package auto-update. If *is_global* is ``True`` and no - *project* is given, a muted ``(global)`` annotation is shown. - - When *host_tool* is non-empty a muted ``→ `` label appears - after the name indicating the package is injected into that host. - - When *has_update* is ``True`` a small inline **Update** button appears - so the user can upgrade this specific package on demand. - """ - - auto_update_toggled = Signal(str, str, bool) - """Emitted with ``(plugin_name, package_name, enabled)`` on toggle.""" - - update_requested = Signal(str, str) - """Emitted with ``(plugin_name, package_name)`` when update is clicked.""" - - remove_requested = Signal(str, str) - """Emitted with ``(plugin_name, package_name)`` when remove is clicked.""" - - navigate_to_project = Signal(str) - """Emitted with a project path when a manifest-managed package tooltip link is clicked.""" - - def __init__( - self, - data: PluginRowData, - *, - parent: QWidget | None = None, - ) -> None: - """Initialize a plugin row from bundled display data.""" - super().__init__(parent) - self.setObjectName('pluginRow') - self.setStyleSheet(PLUGIN_ROW_STYLE) - self._plugin_name = data.plugin_name - self._package_name = data.name - self._update_btn: QPushButton | None = None - self._remove_btn: QPushButton | None = None - self._checking_spinner: _RowSpinner | None = None - self._host_label: QLabel | None = None - self._project_paths: list[str] = list(data.project_paths) - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(10) - - self._build_name_section(layout, data) - layout.addStretch() - self._build_controls(layout, data) - - # --- PluginRow construction helpers --- - - def _build_name_section(self, layout: QHBoxLayout, data: PluginRowData) -> None: - """Add the name, optional host-tool arrow, and project/global labels.""" - name_label = QLabel(data.name) - name_label.setStyleSheet(PLUGIN_ROW_NAME_STYLE) - layout.addWidget(name_label) - - if data.host_tool: - self._host_label = QLabel(f'\u2192 {data.host_tool}') - self._host_label.setStyleSheet(PLUGIN_ROW_HOST_STYLE) - layout.addWidget(self._host_label) - - if data.project: - project_label = QLabel(data.project) - project_label.setStyleSheet(PLUGIN_ROW_PROJECT_STYLE) - layout.addWidget(project_label) - elif data.is_global: - global_label = QLabel('(global)') - global_label.setStyleSheet(PLUGIN_ROW_GLOBAL_STYLE) - layout.addWidget(global_label) - - def _build_controls(self, layout: QHBoxLayout, data: PluginRowData) -> None: - """Add toggle, update, version, and remove controls.""" - if data.show_toggle: - self._build_toggle(layout, data) - if data.has_update: - self._build_update_button(layout, data) - if data.version: - version_label = QLabel(data.version) - version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE) - layout.addWidget(version_label) - - # Transient inline error label (hidden by default) - self._status_label = QLabel() - self._status_label.setStyleSheet(PLUGIN_ROW_ERROR_STYLE) - self._status_label.hide() - layout.addWidget(self._status_label) - - self._build_remove_button(layout, data) - - def _build_toggle(self, layout: QHBoxLayout, data: PluginRowData) -> None: - """Add the auto-update toggle and inline checking spinner.""" - toggle_btn = QPushButton('Auto') - toggle_btn.setCheckable(True) - toggle_btn.setChecked(data.auto_update) - toggle_btn.setStyleSheet(PLUGIN_ROW_TOGGLE_STYLE) - toggle_btn.setToolTip('Auto-update this package') - toggle_btn.clicked.connect( - lambda checked: self.auto_update_toggled.emit( - self._plugin_name, - self._package_name, - checked, - ), - ) - layout.addWidget(toggle_btn) - - self._checking_spinner = _RowSpinner(self) - layout.addWidget(self._checking_spinner) - - def _build_update_button(self, layout: QHBoxLayout, data: PluginRowData) -> None: - """Add the per-package update button.""" - update_btn = QPushButton('Update') - update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) - update_btn.setToolTip(f'Update {data.name}') - update_btn.clicked.connect( - lambda: self.update_requested.emit(self._plugin_name, self._package_name), - ) - self._update_btn = update_btn - layout.addWidget(update_btn) - - def _build_remove_button(self, layout: QHBoxLayout, data: PluginRowData) -> None: - """Add the remove button — enabled only for global packages.""" - remove_btn = QPushButton('\u00d7') - remove_btn.setFixedSize(18, 18) - remove_btn.setStyleSheet(PLUGIN_ROW_REMOVE_STYLE) - remove_btn.setCursor(Qt.CursorShape.PointingHandCursor) - if data.is_global: - remove_btn.setToolTip(f'Remove {data.name}') - remove_btn.clicked.connect( - lambda: self.remove_requested.emit(self._plugin_name, self._package_name), - ) - else: - remove_btn.setEnabled(False) - tooltip = f"Managed by project '{data.project}'" if data.project else 'Managed by a project manifest' - remove_btn.setToolTip(tooltip) - remove_btn.setCursor(Qt.CursorShape.ArrowCursor) - self._remove_btn = remove_btn - layout.addWidget(remove_btn) - - def set_updating(self, updating: bool) -> None: - """Toggle the button between *Updating…* and *Update* states.""" - if self._update_btn is None: - return - if updating: - self._update_btn.setText('Updating\u2026') - self._update_btn.setEnabled(False) - else: - self._update_btn.setText('Update') - self._update_btn.setEnabled(True) - - def set_checking(self, checking: bool) -> None: - """Show or hide the inline checking spinner.""" - if self._checking_spinner is None: - return - if checking: - self._checking_spinner.start() - if self._update_btn is not None: - self._update_btn.hide() - else: - self._checking_spinner.stop() - - def set_removing(self, removing: bool) -> None: - """Toggle the remove button between *Removing…* and *×* states.""" - if self._remove_btn is None: - return - if removing: - self._remove_btn.setText('Removing\u2026') - self._remove_btn.setEnabled(False) - else: - self._remove_btn.setText('\u00d7') - self._remove_btn.setEnabled(True) - - def set_error(self, message: str) -> None: - """Show a transient inline error that auto-hides after ~5 seconds.""" - self._status_label.setText(message) - self._status_label.show() - QTimer.singleShot(5000, self._status_label.hide) - - def clear_error(self) -> None: - """Immediately hide the inline error label.""" - self._status_label.hide() - - -# --------------------------------------------------------------------------- -# Project child row — indented sub-row for project-scoped packages -# --------------------------------------------------------------------------- - - -class ProjectChildRow(QFrame): - """Indented sub-row showing a project-scoped instance of a package. - - Displays the project label, version, and an optional ``(transitive)`` - annotation for packages not declared in the project manifest. - A small navigate button switches to the Projects tab. - - These rows appear directly below the parent :class:`PluginRow` and - are read-only — no update, remove, or auto-update controls. - """ - - navigate_to_project = Signal(str) - """Emitted with a project path when the navigate button is clicked.""" - - def __init__( - self, - project: ProjectInstance, - *, - package_name: str = '', - parent: QWidget | None = None, - ) -> None: - """Initialize the project child row. - - Args: - project: The project instance data to display. - package_name: Package name (displayed dimmed). - parent: Optional parent widget. - """ - super().__init__(parent) - self.setObjectName('projectChildRow') - self.setStyleSheet(PROJECT_CHILD_ROW_STYLE) - self._project_path = project.project_path - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) - - # Package name (dimmed) - if package_name: - name_label = QLabel(package_name) - name_label.setStyleSheet(PROJECT_CHILD_NAME_STYLE) - layout.addWidget(name_label) - - # Project label - project_label = QLabel(project.project_label) - project_label.setStyleSheet(PROJECT_CHILD_PROJECT_STYLE) - layout.addWidget(project_label) - - # Transitive annotation - if project.is_transitive: - transitive_label = QLabel('(transitive)') - transitive_label.setStyleSheet(PROJECT_CHILD_TRANSITIVE_STYLE) - layout.addWidget(transitive_label) - - layout.addStretch() - - # Version - if project.version: - version_label = QLabel(project.version) - version_label.setStyleSheet(PROJECT_CHILD_VERSION_STYLE) - layout.addWidget(version_label) - - # Navigate button - nav_btn = QPushButton('\u2192') - nav_btn.setFixedSize(18, 18) - nav_btn.setStyleSheet(PROJECT_CHILD_NAV_STYLE) - nav_btn.setCursor(Qt.CursorShape.PointingHandCursor) - nav_btn.setToolTip(f'Open project: {project.project_label}') - nav_btn.clicked.connect(lambda: self.navigate_to_project.emit(self._project_path)) - layout.addWidget(nav_btn) - - -# --------------------------------------------------------------------------- -# Filter chip — toggleable pill for plugin filtering -# --------------------------------------------------------------------------- - - -class FilterChip(QPushButton): - """Small toggleable pill button representing a single plugin filter. - - All chips start *checked* (active). The user deselects chips to - hide packages from that plugin — subtractive filtering. - """ - - toggled_with_name = Signal(str, bool) - """Emitted with ``(plugin_name, checked)`` when the chip is toggled.""" - - def __init__(self, plugin_name: str, parent: QWidget | None = None) -> None: - """Initialize a filter chip for the given plugin name.""" - super().__init__(plugin_name, parent) - self._plugin_name = plugin_name - self.setCheckable(True) - self.setChecked(True) - self.setStyleSheet(FILTER_CHIP_STYLE) - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.toggled.connect(lambda checked: self.toggled_with_name.emit(self._plugin_name, checked)) - - class ToolsView(QWidget): """Central update hub showing installed tools and packages. @@ -786,8 +127,9 @@ def __init__( self._refresh_in_progress = False self._check_in_progress = False self._updates_checked = False - self._updates_available: dict[str, set[str]] = {} + self._updates_available: dict[str, dict[str, str]] = {} self._directories: list[ManifestDirectory] = [] + self._timestamp_timer: QTimer | None = None self._init_ui() def _init_ui(self) -> None: @@ -795,7 +137,7 @@ def _init_ui(self) -> None: outer = QVBoxLayout(self) outer.setContentsMargins(*COMPACT_MARGINS) - # Toolbar — search input left, action buttons right + # Toolbar — search input left, action buttons right toolbar = QHBoxLayout() self._search_input = QLineEdit() @@ -819,7 +161,7 @@ def _init_ui(self) -> None: toolbar.addWidget(update_all_btn) outer.addLayout(toolbar) - # Filter chips row — auto-populated from discovered plugins + # Filter chips row — auto-populated from discovered plugins chip_container = QWidget() self._chip_layout = QHBoxLayout(chip_container) self._chip_layout.setContentsMargins(0, 0, 0, 0) @@ -843,6 +185,12 @@ def _init_ui(self) -> None: self._loading_spinner = SpinnerWidget('Loading tools\u2026', parent=self) + # Periodic timer to refresh relative timestamps (every 60s) + self._timestamp_timer = QTimer(self) + self._timestamp_timer.setInterval(60_000) + self._timestamp_timer.timeout.connect(self._refresh_timestamps) + self._timestamp_timer.start() + # --- Public API --- def refresh(self) -> None: @@ -907,7 +255,7 @@ async def _gather_refresh_data(self) -> _RefreshData: packages_map = {name: task.result() for name, task in pkg_tasks.items()} # Merge tool-managed sub-plugins into the environment plugin - # that owns the host tool (e.g. cppython → pipx's pdm entry). + # that owns the host tool (e.g. cppython → pipx's pdm entry). tool_plugins = tool_plugins_task.result() for host_tool, sub_packages in tool_plugins.items(): for env_packages in packages_map.values(): @@ -987,14 +335,12 @@ def _build_plugin_section( ) -> None: """Build the provider header and package rows for a single plugin. - For each package a top-level :class:`PluginRow` is created for - the global instance (with update/remove/toggle controls). - Directly below it, indented :class:`ProjectChildRow` widgets - show each project-scoped occurrence — read-only with a navigate - button to switch to the Projects tab. + For each package a :class:`PluginRow` is created with inline + project-name tags for project-scoped occurrences. Separate + ``ProjectChildRow`` widgets are no longer used. """ auto_val = auto_update_map.get(plugin.name, True) - plugin_updates = self._updates_available.get(plugin.name, set()) + plugin_updates = self._updates_available.get(plugin.name, {}) provider = PluginProviderHeader( plugin, @@ -1010,10 +356,12 @@ 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 {} if display_packages: for pkg in display_packages: pkg_auto = self._resolve_package_auto_update(auto_val, pkg.name, pkg.is_global) + ts_key = f'{plugin.name}/{pkg.name}' row = self._create_connected_row( PluginRowData( name=pkg.name, @@ -1024,19 +372,11 @@ def _build_plugin_section( has_update=pkg.name in plugin_updates, is_global=pkg.is_global, host_tool=pkg.host_tool, + project_instances=list(pkg.project_instances), + last_updated=tool_timestamps.get(ts_key, ''), ), ) self._insert_section_widget(row) - - # Project child rows — always expanded inline - for proj in pkg.project_instances: - child = ProjectChildRow( - proj, - package_name='' if pkg.is_global else pkg.name, - parent=self._container, - ) - child.navigate_to_project.connect(self.navigate_to_project_requested.emit) - self._insert_section_widget(child) else: version_text = str(plugin.tool_version) if plugin.tool_version is not None else '' row = PluginRow(PluginRowData(name=plugin.name, version=version_text), parent=self._container) @@ -1131,7 +471,7 @@ def _rebuild_chips(self) -> None: seen.add(name) plugin_names.append(name) - # Create chips — insert before the trailing stretch + # Create chips — insert before the trailing stretch stretch_idx = self._chip_layout.count() - 1 for name in plugin_names: chip = FilterChip(name, parent=self._chip_layout.parentWidget()) @@ -1187,7 +527,6 @@ def _apply_filter(self, _text: str | None = None) -> None: kind_has_visible = False current_provider: PluginProviderHeader | None = None provider_has_visible_child = False - parent_row_visible = False for widget in self._section_widgets: if isinstance(widget, PluginKindHeader): @@ -1211,18 +550,18 @@ def _apply_filter(self, _text: str | None = None) -> None: elif isinstance(widget, PluginRow): if not self._is_plugin_active(widget._plugin_name, active): widget.setVisible(False) - parent_row_visible = False continue - name_match = not query or query in widget._package_name.lower() or query in widget._plugin_name.lower() + # Match search query against package name, plugin name, and project labels + name_match = not query or ( + query in widget._package_name.lower() + or query in widget._plugin_name.lower() + or any(query in lbl.lower() for lbl in widget._project_labels) + ) widget.setVisible(name_match) - parent_row_visible = name_match if name_match: provider_has_visible_child = True - elif isinstance(widget, ProjectChildRow): - widget.setVisible(parent_row_visible) - # Finalise last provider and kind kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child) if current_kind_header is not None: @@ -1261,7 +600,7 @@ async def _gather_packages( A global query (``project_path=None``) is always issued so that globally-scoped plugins (pipx, apt, brew) report their packages - — including injected packages — even when no directories are + — including injected packages — even when no directories are cached. Per-directory queries run in parallel alongside it to capture project-scoped packages. @@ -1302,7 +641,7 @@ async def _list_one(directory: ManifestDirectory) -> None: packages.extend( PackageEntry( name=str(pkg.name), - project_label=directory.name or str(directory.path), + project_label=directory.name or Path(directory.path).stem, version=str(pkg.version) if pkg.version else '', host_tool=pkg.relation.host if pkg.relation else '', project_path=str(directory.path), @@ -1516,7 +855,7 @@ async def _run_inline_update_check(self) -> None: async def _check_for_updates( self, directories: list[ManifestDirectory], - ) -> dict[str, set[str]]: + ) -> dict[str, dict[str, str]]: """Detect available updates across cached manifests. When a :class:`DataCoordinator` is available the efficient @@ -1524,19 +863,19 @@ async def _check_for_updates( Falls back to per-directory ``execute_stream`` dry-runs otherwise. - Returns a mapping of ``{plugin_name: {package_names…}}`` for - packages that have a newer version available. + Returns a mapping of ``{plugin_name: {package_name: latest_version}}`` + for packages that have a newer version available. """ if self._coordinator is not None: return await self._check_updates_via_coordinator() # Legacy per-directory fallback - available: dict[str, set[str]] = {} + available: dict[str, dict[str, str]] = {} async def _check_one(directory: ManifestDirectory) -> None: partial = await self._check_directory_updates(directory) for installer, packages in partial.items(): - available.setdefault(installer, set()).update(packages) + available.setdefault(installer, {}).update(packages) async with asyncio.TaskGroup() as tg: for d in directories: @@ -1544,27 +883,28 @@ async def _check_one(directory: ManifestDirectory) -> None: return available - async def _check_updates_via_coordinator(self) -> dict[str, set[str]]: + async def _check_updates_via_coordinator(self) -> dict[str, dict[str, str]]: """Use the coordinator's ``check_updates`` for efficient detection.""" assert self._coordinator is not None results = await self._coordinator.check_updates() - available: dict[str, set[str]] = {} + available: dict[str, dict[str, str]] = {} for cr in results: if cr.success: - updated = {pi.name for pi in cr.packages if pi.update_available} - if updated: - available[cr.plugin] = updated + for pi in cr.packages: + if pi.update_available: + latest = str(pi.latest_version) if hasattr(pi, 'latest_version') and pi.latest_version else '' + available.setdefault(cr.plugin, {})[pi.name] = latest return available async def _check_directory_updates( self, directory: ManifestDirectory, - ) -> dict[str, set[str]]: + ) -> dict[str, dict[str, str]]: """Check a single directory for available updates (dry-run). Legacy fallback used when no coordinator is available. """ - available: dict[str, set[str]] = {} + available: dict[str, dict[str, str]] = {} try: path = Path(directory.path) filenames = self._porringer.sync.manifest_filenames() @@ -1592,9 +932,9 @@ async def _check_directory_updates( ): action = event.result.action if action.installer and action.package: - available.setdefault(action.installer, set()).add( - str(action.package.name), - ) + pkg_name = str(action.package.name) + latest = event.result.available_version or '' + available.setdefault(action.installer, {})[pkg_name] = latest except Exception: logger.debug( 'Could not detect updates for %s', @@ -1630,38 +970,29 @@ async def _deferred_update_check( self._check_in_progress = False def _apply_update_badges(self) -> None: - """Walk existing widgets and show/hide Update buttons based on detection results.""" + """Walk existing widgets and show/hide Update buttons + set inline status.""" current_plugin: str = '' for widget in self._section_widgets: if isinstance(widget, PluginProviderHeader): current_plugin = widget._plugin_name - plugin_updates = self._updates_available.get(current_plugin, set()) + plugin_updates = self._updates_available.get(current_plugin, {}) has = bool(plugin_updates) if widget._update_btn is not None: widget._update_btn.setVisible(has) elif isinstance(widget, PluginRow) and widget._plugin_name: - plugin_updates = self._updates_available.get(widget._plugin_name, set()) - has = widget._package_name in plugin_updates + plugin_updates = self._updates_available.get(widget._plugin_name, {}) + latest_version = plugin_updates.get(widget._package_name) + has_update = latest_version is not None + if widget._update_btn is not None: - widget._update_btn.setVisible(has) - elif has: - # Need to create the button that wasn't built at render time - self._inject_update_button(widget) + widget._update_btn.setVisible(has_update) - @staticmethod - def _inject_update_button(row: PluginRow) -> None: - """Dynamically add an Update button to a row that was built without one.""" - update_btn = QPushButton('Update') - update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) - update_btn.setToolTip(f'Update {row._package_name}') - update_btn.clicked.connect( - lambda: row.update_requested.emit(row._plugin_name, row._package_name), - ) - row._update_btn = update_btn - # Insert before the version label (last widget) if present, else append - layout = row.layout() - if isinstance(layout, QHBoxLayout): - layout.insertWidget(max(layout.count() - 1, 0), update_btn) + # Set inline status text + if has_update: + version_text = f'v{latest_version} available' if latest_version else 'Update available' + widget.set_update_status(version_text, PLUGIN_ROW_STATUS_AVAILABLE_STYLE) + elif self._updates_checked: + widget.set_update_status('Up to date', PLUGIN_ROW_STATUS_UP_TO_DATE_STYLE) def _set_all_checking(self, checking: bool) -> None: """Show or hide inline checking spinners on all plugin rows.""" @@ -1669,8 +1000,14 @@ def _set_all_checking(self, checking: bool) -> None: if isinstance(widget, (PluginProviderHeader, PluginRow)): widget.set_checking(checking) + def _refresh_timestamps(self) -> None: + """Refresh relative time labels on all plugin rows (called by timer).""" + for widget in self._section_widgets: + if isinstance(widget, PluginRow): + widget.update_timestamp() + def set_plugin_updating(self, plugin_name: str, updating: bool) -> None: - """Toggle the *Updating…* state on the header for *plugin_name*.""" + """Toggle the *Updating…* state on the header for *plugin_name*.""" for widget in self._section_widgets: if isinstance(widget, PluginProviderHeader) and widget._plugin_name == plugin_name: widget.set_updating(updating) @@ -1682,7 +1019,7 @@ def set_package_updating( package_name: str, updating: bool, ) -> None: - """Toggle the *Updating…* state on a specific package row.""" + """Toggle the *Updating…* state on a specific package row.""" for widget in self._section_widgets: if ( isinstance(widget, PluginRow) @@ -1698,7 +1035,7 @@ def set_package_removing( package_name: str, removing: bool, ) -> None: - """Toggle the *Removing…* state on a specific package row.""" + """Toggle the *Removing…* state on a specific package row.""" for widget in self._section_widgets: if ( isinstance(widget, PluginRow) @@ -1732,245 +1069,6 @@ def set_plugin_error(self, plugin_name: str, message: str) -> None: break -class ProjectsView(QWidget): - """Widget for managing project directories and previewing their manifests. - - Displays a vertical sidebar of cached project directories on the - left with a stacked widget on the right showing one - :class:`SetupPreviewWidget` per manifest. All manifests are loaded - in parallel on first refresh; switching between them is instant. - """ - - def __init__( - self, - porringer: API, - config: ResolvedConfig, - parent: QWidget | None = None, - *, - coordinator: DataCoordinator | None = None, - ) -> None: - """Initialize the projects view. - - Args: - porringer: The porringer API instance. - config: Resolved configuration. - parent: Optional parent widget. - coordinator: Shared data coordinator for validated directory - data. - """ - super().__init__(parent) - self._porringer = porringer - self._config = config - self._coordinator = coordinator - self._refresh_in_progress = False - self._pending_select: Path | None = None - self._widgets: dict[Path, SetupPreviewWidget] = {} - self._init_ui() - - def _init_ui(self) -> None: - """Build the sidebar + stacked widget layout.""" - outer = QHBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - outer.setSpacing(0) - - # Left — sidebar - self._sidebar = ManifestSidebar() - self._sidebar.add_requested.connect(self._on_add) - self._sidebar.remove_requested.connect(self._on_remove) - self._sidebar.selection_changed.connect(self._on_selection_changed) - outer.addWidget(self._sidebar) - - # Right — stacked previews + empty placeholder - right = QVBoxLayout() - right.setContentsMargins(*COMPACT_MARGINS) - right.setSpacing(0) - - self._stack = QStackedWidget() - right.addWidget(self._stack, stretch=1) - - # Empty placeholder shown when there are no manifests - self._empty_placeholder = QLabel('No projects. Click + Add Project to get started.') - self._empty_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._empty_placeholder.setStyleSheet('color: grey; font-size: 13px;') - self._stack.addWidget(self._empty_placeholder) - - outer.addLayout(right, stretch=1) - - self._loading_spinner = SpinnerWidget('Loading projects\u2026', parent=self) - - # --- Public API --- - - def refresh(self) -> None: - """Schedule an asynchronous refresh of the cached directories.""" - if self._refresh_in_progress: - return - asyncio.create_task(self._async_refresh()) - - async def _async_refresh(self) -> None: - """Refresh the sidebar and stacked widgets from the porringer cache.""" - self._refresh_in_progress = True - self._loading_spinner.start() - self._sidebar.set_enabled(False) - - try: - previous = self._pending_select or self._sidebar.selected_path - self._pending_select = None - - if self._coordinator is not None: - snapshot = await self._coordinator.refresh() - results = snapshot.validated_directories - discovered = snapshot.discovered - else: - loop = asyncio.get_running_loop() - results = await loop.run_in_executor( - None, - lambda: self._porringer.cache.list_directories( - validate=True, - check_manifest=True, - ), - ) - discovered = None - - directories: list[tuple[Path, str, bool]] = [] - current_paths: set[Path] = set() - for result in results: - d = result.directory - valid = bool(result.exists and result.has_manifest is not False) - path = Path(d.path) - directories.append((path, d.name or '', valid)) - current_paths.add(path) - - # Remove widgets for directories no longer in cache - self._remove_stale_widgets(current_paths) - - # Grab pre-discovered plugins so each widget can skip redundant discovery - - # Create new widgets for new directories - self._create_directory_widgets(directories, discovered) - - # Rebuild sidebar - self._sidebar.set_directories(directories) - self._sidebar.select(previous) - - # Push latest discovered plugins to all existing widgets - if discovered is not None: - for w in self._widgets.values(): - w._discovered_plugins = discovered - - # Load all stacked widgets in parallel - for path, _name, valid in directories: - widget = self._widgets.get(path) - if widget is not None and valid: - widget.load( - str(path), - project_directory=path if path.is_dir() else path.parent, - detect_updates=self._config.detect_updates, - ) - - except Exception: - logger.exception('Failed to refresh projects') - finally: - self._loading_spinner.stop() - self._sidebar.set_enabled(True) - self._refresh_in_progress = False - - # --- Event handlers --- - - def _remove_stale_widgets(self, current_paths: set[Path]) -> None: - """Remove stacked widgets for directories no longer in the cache.""" - for path in list(self._widgets): - if path not in current_paths: - widget = self._widgets.pop(path) - self._stack.removeWidget(widget) - widget.reset() - widget.deleteLater() - - def _create_directory_widgets( - self, - directories: list[tuple[Path, str, bool]], - discovered: DiscoveredPlugins | None, - ) -> None: - """Create :class:`SetupPreviewWidget` instances for new valid directories.""" - for path, _name, valid in directories: - if path not in self._widgets and valid: - widget = SetupPreviewWidget( - self._porringer, - self, - show_close=False, - config=self._config, - ) - widget._discovered_plugins = discovered - widget.install_finished.connect(self._on_install_finished) - widget.phase_changed.connect( - lambda phase, p=path: self._on_widget_phase_changed(p, phase), - ) - self._widgets[path] = widget - self._stack.addWidget(widget) - - def _on_selection_changed(self, path: Path) -> None: - """Handle sidebar selection — switch the stacked widget.""" - widget = self._widgets.get(path) - if widget is not None: - self._stack.setCurrentWidget(widget) - else: - self._stack.setCurrentWidget(self._empty_placeholder) - - def _on_widget_phase_changed(self, path: Path, phase: PreviewPhase) -> None: - """Update the sidebar item's phase indicator.""" - item = self._sidebar.get_item(path) - if item is not None: - item.set_phase(phase) - - def _on_add(self) -> None: - """Open a file picker and immediately cache the chosen directory.""" - filenames = self._porringer.sync.manifest_filenames() - filter_str = 'Manifests (' + ' '.join(filenames) + ');;All Files (*)' - chosen, _ = QFileDialog.getOpenFileName( - self, - 'Select Manifest File', - '', - filter_str, - ) - if not chosen: - return - - selected = Path(chosen) - directory = selected if selected.is_dir() else selected.parent - - try: - self._porringer.cache.add_directory(directory) - logger.info('Cached new project directory: %s', directory) - except ValueError: - logger.debug('Directory already cached: %s', directory) - - if self._coordinator is not None: - self._coordinator.invalidate() - self._pending_select = directory - self.refresh() - - def _on_remove(self, path: Path) -> None: - """Remove a directory from the porringer cache.""" - self._porringer.cache.remove_directory(path) - logger.info('Removed project directory from cache: %s', path) - - # Tear down the widget immediately - widget = self._widgets.pop(path, None) - if widget is not None: - self._stack.removeWidget(widget) - widget.reset() - widget.deleteLater() - - if self._coordinator is not None: - self._coordinator.invalidate() - self.refresh() - - def _on_install_finished(self, _results: object) -> None: - """Refresh after a successful install.""" - if self._coordinator is not None: - self._coordinator.invalidate() - self.refresh() - - class MainWindow(QMainWindow): """Main window for the application.""" @@ -2003,7 +1101,7 @@ def __init__( self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE) self.setWindowIcon(app_icon()) - # Update banner — always available, starts hidden. + # Update banner — always available, starts hidden. self._update_banner = UpdateBanner(self) @property diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index d6fbe19..4fb9791 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -28,14 +28,13 @@ ) from synodic_client.application.icon import app_icon +from synodic_client.application.screen import _format_relative_time from synodic_client.application.screen.card import CardFrame from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE from synodic_client.logging import log_path from synodic_client.resolution import ResolvedConfig, update_user_config +from synodic_client.schema import GITHUB_REPO_URL from synodic_client.startup import is_startup_registered, register_startup, remove_startup -from synodic_client.updater import ( - GITHUB_REPO_URL, -) logger = logging.getLogger(__name__) @@ -187,6 +186,11 @@ def _add_update_controls(self, content: QVBoxLayout) -> None: row.addStretch() content.addLayout(row) + # Last client update timestamp + self._last_client_update_label = QLabel('') + self._last_client_update_label.setStyleSheet('color: #808080; font-size: 11px;') + content.addWidget(self._last_client_update_label) + def _build_startup_section(self) -> CardFrame: """Construct the *Startup* settings card.""" card = CardFrame('Startup') @@ -234,6 +238,14 @@ def sync_from_config(self) -> None: self._auto_apply_check.setChecked(config.auto_apply) self._auto_start_check.setChecked(is_startup_registered()) + # Last client update timestamp + if config.last_client_update: + relative = _format_relative_time(config.last_client_update) + self._last_client_update_label.setText(f'Last updated: {relative}') + self._last_client_update_label.setToolTip(f'Last updated: {config.last_client_update}') + else: + self._last_client_update_label.setText('') + def set_update_status(self, text: str, style: str = '') -> None: """Set the inline status text next to the *Check for Updates* button. diff --git a/synodic_client/application/screen/sidebar.py b/synodic_client/application/screen/sidebar.py index 91fd012..47d118a 100644 --- a/synodic_client/application/screen/sidebar.py +++ b/synodic_client/application/screen/sidebar.py @@ -23,7 +23,7 @@ QWidget, ) -from synodic_client.application.screen.install import PreviewPhase +from synodic_client.application.screen.schema import PreviewPhase from synodic_client.application.theme import ( SIDEBAR_ADD_STYLE, SIDEBAR_CLOSE_STYLE, diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py index b775fd0..4d582bc 100644 --- a/synodic_client/application/screen/spinner.py +++ b/synodic_client/application/screen/spinner.py @@ -1,12 +1,13 @@ -"""Animated loading spinner widget. +"""Animated loading spinner widgets. -Provides :class:`SpinnerWidget` — a palette-aware spinning arc with an -optional text label. Call ``start()`` to show and ``stop()`` to hide. +Provides :class:`SpinnerCanvas` — a lightweight, palette-aware spinning +arc that can be sized and styled for any context — and +:class:`SpinnerWidget` — a self-positioning overlay variant with an +optional text label. -When constructed with a *parent*, the spinner automatically installs -itself as a floating overlay that tracks the parent's geometry. -Consumers never need to override ``resizeEvent``, manage z-order, or -set a size policy — just call ``start()`` / ``stop()``. +:class:`SpinnerCanvas` is used directly in plugin rows and action cards +where only a small inline indicator is needed. :class:`SpinnerWidget` +wraps a canvas and centres itself over its parent for modal-style use. """ from __future__ import annotations @@ -15,31 +16,58 @@ from PySide6.QtGui import QPainter, QPen from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget -_SIZE = 24 -_PEN = 3 +_DEFAULT_SIZE = 24 +_DEFAULT_PEN = 3 _INTERVAL = 50 _ARC = 90 _FULL_CIRCLE = 360 -class _Canvas(QWidget): - """Fixed-size widget that paints the spinning arc.""" +class SpinnerCanvas(QWidget): + """Fixed-size widget that paints a spinning arc. - def __init__(self, parent: QWidget | None = None) -> None: + Fully parameterised so that different call-sites can share the + identical paint logic with varying dimensions. + + Args: + size: Diameter of the spinner in pixels. + pen_width: Stroke width for the arc. + interval: Timer tick interval in milliseconds. + parent: Optional parent widget. + """ + + def __init__( + self, + size: int = _DEFAULT_SIZE, + pen_width: int = _DEFAULT_PEN, + interval: int = _INTERVAL, + parent: QWidget | None = None, + ) -> None: + """Create a spinner canvas. + + Args: + size: Diameter of the spinner in pixels. + pen_width: Stroke width for the arc. + interval: Timer tick interval in milliseconds. + parent: Optional parent widget. + """ super().__init__(parent) self._angle = 0 - self.setFixedSize(_SIZE, _SIZE) + self._size = size + self._pen_width = pen_width + self._interval = interval + self.setFixedSize(size, size) def paintEvent(self, _event: object) -> None: """Draw a muted track circle and the animated highlight arc.""" painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - m = _PEN // 2 + 1 - rect = QRect(m, m, _SIZE - 2 * m, _SIZE - 2 * m) + m = self._pen_width // 2 + 1 + rect = QRect(m, m, self._size - 2 * m, self._size - 2 * m) for colour, span in ((self.palette().mid(), _FULL_CIRCLE), (self.palette().highlight(), _ARC)): - pen = QPen(colour, _PEN) + pen = QPen(colour, self._pen_width) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) if span == _FULL_CIRCLE: @@ -76,7 +104,7 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: super().__init__(parent) self.hide() - self._canvas = _Canvas(self) + self._canvas = SpinnerCanvas(parent=self) self._timer = QTimer(self) self._timer.setInterval(_INTERVAL) self._timer.timeout.connect(self._canvas.tick) diff --git a/synodic_client/application/screen/tool_update_controller.py b/synodic_client/application/screen/tool_update_controller.py new file mode 100644 index 0000000..d8c207c --- /dev/null +++ b/synodic_client/application/screen/tool_update_controller.py @@ -0,0 +1,393 @@ +"""Tool update orchestration extracted from TrayScreen. + +:class:`ToolUpdateOrchestrator` owns the background tool update +lifecycle — periodic polling, single-plugin / single-package updates, +and package removal — delegating actual work to +:func:`~synodic_client.application.workers.run_tool_updates` and +:func:`~synodic_client.application.workers.run_package_remove`. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from datetime import UTC, datetime + +from porringer.api import API +from porringer.schema.execution import SetupActionResult +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QSystemTrayIcon + +from synodic_client.application.schema import ToolUpdateResult +from synodic_client.application.screen.screen import MainWindow, ToolsView +from synodic_client.application.workers import ( + run_package_remove, + run_tool_updates, +) +from synodic_client.config import load_user_config +from synodic_client.resolution import ( + ResolvedConfig, + resolve_auto_update_scope, + resolve_update_config, + update_user_config, +) + +logger = logging.getLogger(__name__) + + +class ToolUpdateOrchestrator: + """Background tool update lifecycle manager. + + Handles periodic tool-update polling, per-plugin and per-package + update requests, and package removal. All async work is scheduled + on the qasync event loop. + + Args: + window: The main application window (provides porringer / coordinator). + config_resolver: Callable returning the current resolved config. + tray: System tray icon for displaying notification messages. + """ + + def __init__( + self, + window: MainWindow, + config_resolver: Callable[[], ResolvedConfig], + tray: QSystemTrayIcon, + ) -> None: + """Set up the controller. + + Args: + window: The main application window. + config_resolver: Callable returning the current resolved config. + tray: System tray icon for notification messages. + """ + self._window = window + self._resolve_config = config_resolver + self._tray = tray + self._tool_task: asyncio.Task[None] | None = None + self._tool_update_timer: QTimer | None = None + + # -- Timer management -- + + @staticmethod + def _restart_timer( + current: QTimer | None, + interval_minutes: int, + slot: Callable[[], None], + label: str, + ) -> QTimer | None: + """Stop *current* and return a new periodic timer, or ``None``. + + Args: + current: The existing timer to stop (may be ``None``). + interval_minutes: Interval in minutes. ``0`` disables. + slot: The callable to invoke on each tick. + label: Human-readable name for log messages. + + Returns: + A running ``QTimer``, or ``None`` when disabled. + """ + if current is not None: + current.stop() + + if interval_minutes <= 0: + logger.info('%s is disabled', label) + return None + + timer = QTimer() + timer.setInterval(interval_minutes * 60 * 1000) + timer.timeout.connect(slot) + timer.start() + logger.info('%s enabled (every %d minute(s))', label, interval_minutes) + return timer + + def restart_tool_update_timer(self) -> None: + """Start (or restart) the periodic tool update timer from config.""" + config = resolve_update_config(self._resolve_config()) + self._tool_update_timer = self._restart_timer( + self._tool_update_timer, + config.tool_update_interval_minutes, + self.on_tool_update, + 'Automatic tool updating', + ) + + # -- ToolsView signal wiring -- + + def connect_tools_view(self, tools_view: ToolsView) -> None: + """Wire ToolsView signals once the view is lazily created.""" + tools_view.update_all_requested.connect(self.on_tool_update) + tools_view.plugin_update_requested.connect(self.on_single_plugin_update) + tools_view.package_update_requested.connect(self.on_single_package_update) + tools_view.package_remove_requested.connect(self.on_single_package_remove) + + # -- Full tool update -- + + def on_tool_update(self) -> None: + """Trigger a background re-sync of manifest-declared tools.""" + porringer = self._window.porringer + if porringer is None: + logger.warning('Tool update skipped: porringer not available') + return + + logger.info('Starting periodic tool update check') + self._tool_task = asyncio.create_task(self._do_tool_update(porringer)) + + async def _do_tool_update(self, porringer: API) -> None: + """Resolve enabled plugins off-thread, then run the tool update.""" + config = self._resolve_config() + coordinator = self._window.coordinator + + if coordinator is not None: + snapshot = await coordinator.refresh() + all_plugins = snapshot.plugins + discovered = snapshot.discovered + else: + all_plugins = await porringer.plugin.list() + discovered = None + + all_names = [p.name for p in all_plugins if p.installed] + enabled_plugins, include_packages = resolve_auto_update_scope( + config, + all_names, + ) + + try: + result = await run_tool_updates( + porringer, + plugins=enabled_plugins, + include_packages=include_packages, + discovered_plugins=discovered, + ) + if coordinator is not None: + coordinator.invalidate() + self._on_tool_update_finished(result) + except Exception as exc: + logger.exception('Tool update failed') + self._on_tool_update_error(str(exc)) + + # -- Single plugin update -- + + def on_single_plugin_update(self, plugin_name: str) -> None: + """Upgrade a single plugin across all cached projects.""" + porringer = self._window.porringer + if porringer is None: + logger.warning('Single plugin update skipped: porringer not available') + return + + logger.info('Starting update for plugin: %s', plugin_name) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_plugin_updating(plugin_name, True) + self._tool_task = asyncio.create_task( + self._async_single_plugin_update(porringer, plugin_name), + ) + + async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> None: + """Run a single-plugin tool update and route results.""" + config = self._resolve_config() + mapping = config.plugin_auto_update or {} + pkg_entry = mapping.get(plugin_name) + coordinator = self._window.coordinator + discovered = coordinator.discovered_plugins if coordinator is not None else None + + # Resolve per-package filtering for this plugin + include_packages: set[str] | None = None + if isinstance(pkg_entry, dict): + enabled_pkgs = {name for name, enabled in pkg_entry.items() if enabled} + if enabled_pkgs: + include_packages = enabled_pkgs + + try: + result = await run_tool_updates( + porringer, + plugins={plugin_name}, + include_packages=include_packages, + discovered_plugins=discovered, + ) + if coordinator is not None: + coordinator.invalidate() + self._on_tool_update_finished(result, updating_plugin=plugin_name, manual=True) + except Exception as exc: + logger.exception('Tool update failed') + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_plugin_updating(plugin_name, False) + tools_view.set_plugin_error(plugin_name, f'Update failed: {exc}') + + # -- Single package update -- + + def on_single_package_update(self, plugin_name: str, package_name: str) -> None: + """Upgrade a single package managed by *plugin_name*.""" + porringer = self._window.porringer + if porringer is None: + logger.warning('Single package update skipped: porringer not available') + return + + logger.info('Starting update for %s/%s', plugin_name, package_name) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_updating(plugin_name, package_name, True) + self._tool_task = asyncio.create_task( + self._async_single_package_update(porringer, plugin_name, package_name), + ) + + async def _async_single_package_update( + self, + porringer: API, + plugin_name: str, + package_name: str, + ) -> None: + """Run a single-package tool update and route results.""" + coordinator = self._window.coordinator + discovered = coordinator.discovered_plugins if coordinator is not None else None + try: + result = await run_tool_updates( + porringer, + plugins={plugin_name}, + include_packages={package_name}, + discovered_plugins=discovered, + ) + if coordinator is not None: + coordinator.invalidate() + self._on_tool_update_finished( + result, + updating_package=(plugin_name, package_name), + manual=True, + ) + except Exception as exc: + logger.exception('Package update failed') + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_updating(plugin_name, package_name, False) + tools_view.set_package_error(plugin_name, package_name, f'Update failed: {exc}') + + # -- Shared completion handler -- + + def _on_tool_update_finished( + self, + result: ToolUpdateResult, + *, + updating_plugin: str | None = None, + updating_package: tuple[str, str] | None = None, + manual: bool = False, + ) -> None: + """Handle tool update completion.""" + logger.info( + 'Tool update completed: %d manifest(s), %d updated, %d already latest, %d failed', + result.manifests_processed, + result.updated, + result.already_latest, + result.failed, + ) + + # Persist timestamps for updated packages + if result.updated_packages: + now = datetime.now(UTC).isoformat() + existing = dict(load_user_config().last_tool_updates or {}) + plugin_name = updating_plugin or (updating_package[0] if updating_package else '') + for pkg_name in result.updated_packages: + key = f'{plugin_name}/{pkg_name}' if plugin_name else pkg_name + existing[key] = now + update_user_config(last_tool_updates=existing) + + # Clear updating state on widgets + tools_view = self._window.tools_view + if tools_view is not None: + if updating_plugin is not None: + tools_view.set_plugin_updating(updating_plugin, False) + if updating_package is not None: + tools_view.set_package_updating(*updating_package, False) + # Refresh to pick up version changes and re-detect updates + tools_view._updates_checked = False + tools_view.refresh() + + if manual: + self._window.show() + + def _on_tool_update_error(self, error: str) -> None: + """Handle tool update error.""" + logger.error('Tool update failed: %s', error) + self._tray.showMessage( + 'Tool Update Error', + f'An error occurred during tool update: {error}', + QSystemTrayIcon.MessageIcon.Warning, + ) + + # -- Package removal -- + + def on_single_package_remove(self, plugin_name: str, package_name: str) -> None: + """Remove a single global package managed by *plugin_name*.""" + porringer = self._window.porringer + if porringer is None: + logger.warning('Package remove skipped: porringer not available') + return + + logger.info('Starting removal for %s/%s', plugin_name, package_name) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, True) + self._tool_task = asyncio.create_task( + self._async_single_package_remove(porringer, plugin_name, package_name), + ) + + async def _async_single_package_remove( + self, + porringer: API, + plugin_name: str, + package_name: str, + ) -> None: + """Run a single-package removal and route results.""" + coordinator = self._window.coordinator + discovered = coordinator.discovered_plugins if coordinator is not None else None + try: + result = await run_package_remove( + porringer, + plugin_name, + package_name, + discovered_plugins=discovered, + ) + logger.info( + 'Removal result for %s/%s: success=%s, skipped=%s, skip_reason=%s, message=%s', + plugin_name, + package_name, + result.success, + result.skipped, + result.skip_reason, + result.message, + ) + if coordinator is not None: + coordinator.invalidate() + self._on_package_remove_finished(result, plugin_name, package_name) + except Exception as exc: + logger.exception('Package removal failed') + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, False) + tools_view.set_package_error(plugin_name, package_name, f'Failed to remove {package_name}: {exc}') + + def _on_package_remove_finished( + self, + result: SetupActionResult, + plugin_name: str, + package_name: str, + ) -> None: + """Handle package removal completion.""" + tools_view = self._window.tools_view + + if not result.success or result.skipped: + detail = result.message or 'Unknown error' + logger.warning('Package removal failed for %s/%s: %s', plugin_name, package_name, detail) + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, False) + tools_view.set_package_error(plugin_name, package_name, f'Could not remove {package_name}: {detail}') + return + + logger.info('Package removal completed for %s/%s', plugin_name, package_name) + + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, False) + tools_view._updates_checked = False + tools_view.refresh() + + self._window.show() diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index bf4e2c6..16fa599 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -1,451 +1,137 @@ -"""Tray screen for the application.""" - -import asyncio -import logging -from collections.abc import Callable - -from porringer.api import API -from porringer.schema.execution import SetupActionResult -from PySide6.QtCore import QTimer -from PySide6.QtGui import QAction -from PySide6.QtWidgets import ( - QApplication, - QMenu, - QSystemTrayIcon, -) - -from synodic_client.application.icon import app_icon -from synodic_client.application.screen.screen import MainWindow, ToolsView -from synodic_client.application.screen.settings import SettingsWindow -from synodic_client.application.update_controller import UpdateController -from synodic_client.application.workers import ( - ToolUpdateResult, - run_package_remove, - run_tool_updates, -) -from synodic_client.client import Client -from synodic_client.resolution import ( - ResolvedConfig, - resolve_auto_update_scope, - resolve_config, - resolve_update_config, -) - -logger = logging.getLogger(__name__) - - -class TrayScreen: - """Tray screen for the application.""" - - def __init__( - self, - app: QApplication, - client: Client, - window: MainWindow, - config: ResolvedConfig | None = None, - ) -> None: - """Initialize the tray icon. - - Args: - app: The running ``QApplication``. - client: The Synodic Client service. - window: The main application window. - config: Optional pre-resolved configuration. When ``None``, - the configuration is resolved from disk on demand. - """ - self._app = app - self._client = client - self._window = window - self._config = config - self._tool_task: asyncio.Task[None] | None = None - - self.tray_icon = app_icon() - - self.tray = QSystemTrayIcon() - self.tray.setIcon(self.tray_icon) - self.tray.activated.connect(self._on_tray_activated) - self.tray.setVisible(True) - - self._build_menu(app, window) - - # Settings window (created once, shown/hidden on demand) - self._settings_window = SettingsWindow( - self._resolve_config(), - version=str(self._client.version), - ) - self._settings_window.settings_changed.connect(self._on_settings_changed) - - # MainWindow gear button → open settings - window.settings_requested.connect(self._show_settings) - - # Update controller — owns the self-update lifecycle & timer - self._banner = window.update_banner - self._update_controller = UpdateController( - app, - client, - self._banner, - self._settings_window, - config, - ) - - # Periodic tool update checking - self._tool_update_timer: QTimer | None = None - self._restart_tool_update_timer() - - # Connect ToolsView signals — deferred because ToolsView is created lazily - window.tools_view_created.connect(self._connect_tools_view) - - def _build_menu(self, app: QApplication, window: MainWindow) -> None: - """Build the tray context menu.""" - self.menu = QMenu() - - self.open_action = QAction('Open', self.menu) - self.menu.addAction(self.open_action) - self.open_action.triggered.connect(window.show) - - self.menu.addSeparator() - - self.settings_action = QAction('Settings\u2026', self.menu) - self.settings_action.triggered.connect(self._show_settings) - self.menu.addAction(self.settings_action) - - self.menu.addSeparator() - - self.quit_action = QAction('Quit', self.menu) - self.quit_action.triggered.connect(app.quit) - self.menu.addAction(self.quit_action) - - self.tray.setContextMenu(self.menu) - - # -- Deferred ToolsView wiring -- - - def _connect_tools_view(self, tools_view: ToolsView) -> None: - """Wire ToolsView signals once the view is lazily created.""" - tools_view.update_all_requested.connect(self._on_tool_update) - tools_view.plugin_update_requested.connect(self._on_single_plugin_update) - tools_view.package_update_requested.connect(self._on_single_package_update) - tools_view.package_remove_requested.connect(self._on_single_package_remove) - - # -- Config helpers -- - - def _resolve_config(self) -> ResolvedConfig: - """Return the injected config or resolve from disk.""" - if self._config is not None: - return self._config - return resolve_config() - - @staticmethod - def _restart_timer( - current: QTimer | None, - interval_minutes: int, - slot: Callable[[], None], - label: str, - ) -> QTimer | None: - """Stop *current* and return a new periodic timer, or ``None``. - - Args: - current: The existing timer to stop (may be ``None``). - interval_minutes: Interval in minutes. ``0`` disables. - slot: The callable to invoke on each tick. - label: Human-readable name for log messages. - - Returns: - A running ``QTimer``, or ``None`` when disabled. - """ - if current is not None: - current.stop() - - if interval_minutes <= 0: - logger.info('%s is disabled', label) - return None - - timer = QTimer() - timer.setInterval(interval_minutes * 60 * 1000) - timer.timeout.connect(slot) - timer.start() - logger.info('%s enabled (every %d minute(s))', label, interval_minutes) - return timer - - def _restart_tool_update_timer(self) -> None: - """Start (or restart) the periodic tool update timer from config.""" - config = resolve_update_config(self._resolve_config()) - self._tool_update_timer = self._restart_timer( - self._tool_update_timer, - config.tool_update_interval_minutes, - self._on_tool_update, - 'Automatic tool updating', - ) - - def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: - """Handle tray icon activation (e.g. double-click).""" - if reason == QSystemTrayIcon.ActivationReason.DoubleClick: - self._window.show() - self._window.raise_() - self._window.activateWindow() - - def _show_settings(self) -> None: - """Show the settings window.""" - self._settings_window.show() - - def _on_settings_changed(self, config: ResolvedConfig) -> None: - """React to a change made in the settings window.""" - self._config = config - # Delegate updater reinit + immediate check to the controller - self._update_controller.on_settings_changed(config) - # Restart tool-update timer with new config - self._restart_tool_update_timer() - - # -- Tool update helpers -- - - def _on_tool_update(self) -> None: - """Trigger a background re-sync of manifest-declared tools.""" - porringer = self._window.porringer - if porringer is None: - logger.warning('Tool update skipped: porringer not available') - return - - logger.info('Starting periodic tool update check') - self._tool_task = asyncio.create_task(self._do_tool_update(porringer)) - - async def _do_tool_update(self, porringer: API) -> None: - """Resolve enabled plugins off-thread, then run the tool update.""" - config = self._resolve_config() - coordinator = self._window.coordinator - - if coordinator is not None: - snapshot = await coordinator.refresh() - all_plugins = snapshot.plugins - discovered = snapshot.discovered - else: - all_plugins = await porringer.plugin.list() - discovered = None - - all_names = [p.name for p in all_plugins if p.installed] - enabled_plugins, include_packages = resolve_auto_update_scope( - config, - all_names, - ) - - try: - result = await run_tool_updates( - porringer, - plugins=enabled_plugins, - include_packages=include_packages, - discovered_plugins=discovered, - ) - if coordinator is not None: - coordinator.invalidate() - self._on_tool_update_finished(result) - except Exception as exc: - logger.exception('Tool update failed') - self._on_tool_update_error(str(exc)) - - def _on_single_plugin_update(self, plugin_name: str) -> None: - """Upgrade a single plugin across all cached projects.""" - porringer = self._window.porringer - if porringer is None: - logger.warning('Single plugin update skipped: porringer not available') - return - - logger.info('Starting update for plugin: %s', plugin_name) - tools_view = self._window.tools_view - if tools_view is not None: - tools_view.set_plugin_updating(plugin_name, True) - self._tool_task = asyncio.create_task( - self._async_single_plugin_update(porringer, plugin_name), - ) - - async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> None: - """Run a single-plugin tool update and route results.""" - config = self._resolve_config() - mapping = config.plugin_auto_update or {} - pkg_entry = mapping.get(plugin_name) - coordinator = self._window.coordinator - discovered = coordinator.discovered_plugins if coordinator is not None else None - - # Resolve per-package filtering for this plugin - include_packages: set[str] | None = None - if isinstance(pkg_entry, dict): - enabled_pkgs = {name for name, enabled in pkg_entry.items() if enabled} - if enabled_pkgs: - include_packages = enabled_pkgs - - try: - result = await run_tool_updates( - porringer, - plugins={plugin_name}, - include_packages=include_packages, - discovered_plugins=discovered, - ) - if coordinator is not None: - coordinator.invalidate() - self._on_tool_update_finished(result, updating_plugin=plugin_name, manual=True) - except Exception as exc: - logger.exception('Tool update failed') - tools_view = self._window.tools_view - if tools_view is not None: - tools_view.set_plugin_updating(plugin_name, False) - tools_view.set_plugin_error(plugin_name, f'Update failed: {exc}') - - def _on_single_package_update(self, plugin_name: str, package_name: str) -> None: - """Upgrade a single package managed by *plugin_name*.""" - porringer = self._window.porringer - if porringer is None: - logger.warning('Single package update skipped: porringer not available') - return - - logger.info('Starting update for %s/%s', plugin_name, package_name) - tools_view = self._window.tools_view - if tools_view is not None: - tools_view.set_package_updating(plugin_name, package_name, True) - self._tool_task = asyncio.create_task( - self._async_single_package_update(porringer, plugin_name, package_name), - ) - - async def _async_single_package_update( - self, - porringer: API, - plugin_name: str, - package_name: str, - ) -> None: - """Run a single-package tool update and route results.""" - coordinator = self._window.coordinator - discovered = coordinator.discovered_plugins if coordinator is not None else None - try: - result = await run_tool_updates( - porringer, - plugins={plugin_name}, - include_packages={package_name}, - discovered_plugins=discovered, - ) - if coordinator is not None: - coordinator.invalidate() - self._on_tool_update_finished( - result, - updating_package=(plugin_name, package_name), - manual=True, - ) - except Exception as exc: - logger.exception('Package update failed') - tools_view = self._window.tools_view - if tools_view is not None: - tools_view.set_package_updating(plugin_name, package_name, False) - tools_view.set_package_error(plugin_name, package_name, f'Update failed: {exc}') - - def _on_tool_update_finished( - self, - result: ToolUpdateResult, - *, - updating_plugin: str | None = None, - updating_package: tuple[str, str] | None = None, - manual: bool = False, - ) -> None: - """Handle tool update completion.""" - logger.info( - 'Tool update completed: %d manifest(s), %d updated, %d already latest, %d failed', - result.manifests_processed, - result.updated, - result.already_latest, - result.failed, - ) - - # Clear updating state on widgets - tools_view = self._window.tools_view - if tools_view is not None: - if updating_plugin is not None: - tools_view.set_plugin_updating(updating_plugin, False) - if updating_package is not None: - tools_view.set_package_updating(*updating_package, False) - # Refresh to pick up version changes and re-detect updates - tools_view._updates_checked = False - tools_view.refresh() - - if manual: - self._window.show() - - def _on_tool_update_error(self, error: str) -> None: - """Handle tool update error.""" - logger.error('Tool update failed: %s', error) - self.tray.showMessage( - 'Tool Update Error', - f'An error occurred during tool update: {error}', - QSystemTrayIcon.MessageIcon.Warning, - ) - - # -- Package removal -- - - def _on_single_package_remove(self, plugin_name: str, package_name: str) -> None: - """Remove a single global package managed by *plugin_name*.""" - porringer = self._window.porringer - if porringer is None: - logger.warning('Package remove skipped: porringer not available') - return - - logger.info('Starting removal for %s/%s', plugin_name, package_name) - tools_view = self._window.tools_view - if tools_view is not None: - tools_view.set_package_removing(plugin_name, package_name, True) - self._tool_task = asyncio.create_task( - self._async_single_package_remove(porringer, plugin_name, package_name), - ) - - async def _async_single_package_remove( - self, - porringer: API, - plugin_name: str, - package_name: str, - ) -> None: - """Run a single-package removal and route results.""" - coordinator = self._window.coordinator - discovered = coordinator.discovered_plugins if coordinator is not None else None - try: - result = await run_package_remove( - porringer, - plugin_name, - package_name, - discovered_plugins=discovered, - ) - logger.info( - 'Removal result for %s/%s: success=%s, skipped=%s, skip_reason=%s, message=%s', - plugin_name, - package_name, - result.success, - result.skipped, - result.skip_reason, - result.message, - ) - if coordinator is not None: - coordinator.invalidate() - self._on_package_remove_finished(result, plugin_name, package_name) - except Exception as exc: - logger.exception('Package removal failed') - tools_view = self._window.tools_view - if tools_view is not None: - tools_view.set_package_removing(plugin_name, package_name, False) - tools_view.set_package_error(plugin_name, package_name, f'Failed to remove {package_name}: {exc}') - - def _on_package_remove_finished( - self, - result: SetupActionResult, - plugin_name: str, - package_name: str, - ) -> None: - """Handle package removal completion.""" - tools_view = self._window.tools_view - - if not result.success or result.skipped: - detail = result.message or 'Unknown error' - logger.warning('Package removal failed for %s/%s: %s', plugin_name, package_name, detail) - if tools_view is not None: - tools_view.set_package_removing(plugin_name, package_name, False) - tools_view.set_package_error(plugin_name, package_name, f'Could not remove {package_name}: {detail}') - return - - logger.info('Package removal completed for %s/%s', plugin_name, package_name) - - if tools_view is not None: - tools_view.set_package_removing(plugin_name, package_name, False) - tools_view._updates_checked = False - tools_view.refresh() - - self._window.show() +"""Tray screen for the application.""" + +import logging + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QApplication, + QMenu, + QSystemTrayIcon, +) + +from synodic_client.application.icon import app_icon +from synodic_client.application.screen.screen import MainWindow +from synodic_client.application.screen.settings import SettingsWindow +from synodic_client.application.screen.tool_update_controller import ToolUpdateOrchestrator +from synodic_client.application.update_controller import UpdateController +from synodic_client.client import Client +from synodic_client.resolution import ( + ResolvedConfig, + resolve_config, +) + +logger = logging.getLogger(__name__) + + +class TrayScreen: + """Tray screen for the application.""" + + def __init__( + self, + app: QApplication, + client: Client, + window: MainWindow, + config: ResolvedConfig | None = None, + ) -> None: + """Initialize the tray icon. + + Args: + app: The running ``QApplication``. + client: The Synodic Client service. + window: The main application window. + config: Optional pre-resolved configuration. When ``None``, + the configuration is resolved from disk on demand. + """ + self._app = app + self._client = client + self._window = window + self._config = config + + self.tray_icon = app_icon() + + self.tray = QSystemTrayIcon() + self.tray.setIcon(self.tray_icon) + self.tray.activated.connect(self._on_tray_activated) + self.tray.setVisible(True) + + self._build_menu(app, window) + + # Settings window (created once, shown/hidden on demand) + self._settings_window = SettingsWindow( + self._resolve_config(), + version=str(self._client.version), + ) + self._settings_window.settings_changed.connect(self._on_settings_changed) + + # MainWindow gear button -> open settings + window.settings_requested.connect(self._show_settings) + + # Update controller - owns the self-update lifecycle & timer + self._banner = window.update_banner + self._update_controller = UpdateController( + app, + client, + self._banner, + self._settings_window, + config, + ) + + # Tool update orchestrator - owns tool/package update lifecycle + self._tool_orchestrator = ToolUpdateOrchestrator( + window, + self._resolve_config, + self.tray, + ) + self._tool_orchestrator.restart_tool_update_timer() + + # Connect ToolsView signals - deferred because ToolsView is created lazily + window.tools_view_created.connect(self._tool_orchestrator.connect_tools_view) + + def _build_menu(self, app: QApplication, window: MainWindow) -> None: + """Build the tray context menu.""" + self.menu = QMenu() + + self.open_action = QAction('Open', self.menu) + self.menu.addAction(self.open_action) + self.open_action.triggered.connect(window.show) + + self.menu.addSeparator() + + self.settings_action = QAction('Settings\u2026', self.menu) + self.settings_action.triggered.connect(self._show_settings) + self.menu.addAction(self.settings_action) + + self.menu.addSeparator() + + self.quit_action = QAction('Quit', self.menu) + self.quit_action.triggered.connect(app.quit) + self.menu.addAction(self.quit_action) + + self.tray.setContextMenu(self.menu) + + # -- Config helpers -- + + def _resolve_config(self) -> ResolvedConfig: + """Return the injected config or resolve from disk.""" + if self._config is not None: + return self._config + return resolve_config() + + def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: + """Handle tray icon activation (e.g. double-click).""" + if reason == QSystemTrayIcon.ActivationReason.DoubleClick: + self._window.show() + self._window.raise_() + self._window.activateWindow() + + def _show_settings(self) -> None: + """Show the settings window.""" + self._settings_window.show() + + def _on_settings_changed(self, config: ResolvedConfig) -> None: + """React to a change made in the settings window.""" + self._config = config + # Delegate updater reinit + immediate check to the controller + self._update_controller.on_settings_changed(config) + # Restart tool-update timer with new config + self._tool_orchestrator.restart_tool_update_timer() diff --git a/synodic_client/application/screen/update_banner.py b/synodic_client/application/screen/update_banner.py index 851d7bf..f1bdcf8 100644 --- a/synodic_client/application/screen/update_banner.py +++ b/synodic_client/application/screen/update_banner.py @@ -15,8 +15,6 @@ from __future__ import annotations import logging -from dataclasses import dataclass -from enum import Enum, auto from PySide6.QtCore import ( QEasingCurve, @@ -36,6 +34,7 @@ QWidget, ) +from synodic_client.application.screen.schema import UpdateBannerState, _BannerConfig from synodic_client.application.theme import ( UPDATE_BANNER_ANIMATION_MS, UPDATE_BANNER_BTN_STYLE, @@ -52,29 +51,6 @@ logger = logging.getLogger(__name__) -class UpdateBannerState(Enum): - """Visual states for the update banner.""" - - HIDDEN = auto() - DOWNLOADING = auto() - READY = auto() - ERROR = auto() - - -@dataclass(frozen=True, slots=True) -class _BannerConfig: - """Bundled visual configuration for a banner state transition.""" - - state: UpdateBannerState - style: str - icon: str - text: str - text_style: str - version: str = '' - action_label: str = '' - show_progress: bool = False - - # Height of the banner content (progress variant is slightly taller). _BANNER_HEIGHT = 38 _BANNER_HEIGHT_WITH_PROGRESS = 44 diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 1adcd8d..0ed0dd5 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -181,7 +181,7 @@ PLUGIN_ROW_UPDATE_STYLE = ( 'QPushButton { padding: 1px 4px; border: 1px solid palette(mid); border-radius: 2px;' - ' font-size: 10px; min-width: 48px; max-width: 60px; }' + ' font-size: 10px; min-width: 52px; max-width: 52px; }' 'QPushButton:disabled { color: palette(mid); border-color: palette(mid); background: transparent; }' ) """Small inline update button for individual package rows.""" @@ -198,6 +198,39 @@ PLUGIN_ROW_ERROR_STYLE = 'font-size: 11px; color: #f48771;' """Transient inline error label shown on a row after a failed action.""" +PLUGIN_ROW_STATUS_STYLE = 'font-size: 10px; color: #808080;' +"""Muted inline status text shown after an auto-update check (e.g. 'Up to date').""" + +PLUGIN_ROW_STATUS_UP_TO_DATE_STYLE = 'font-size: 10px; color: #89d185;' +"""Green status text for 'Up to date'.""" + +PLUGIN_ROW_STATUS_AVAILABLE_STYLE = 'font-size: 10px; color: #cca700;' +"""Amber status text for 'vX.Y available'.""" + +PLUGIN_ROW_TIMESTAMP_STYLE = 'font-size: 10px; color: #666666;' +"""Muted relative timestamp label (e.g. '5m ago').""" + +PLUGIN_ROW_PROJECT_TAG_STYLE = ( + 'QLabel { font-size: 10px; color: #aaaaaa; background: #333333; border-radius: 8px; padding: 1px 6px; }' +) +"""Compact pill-shaped project name tag for inline dependency display.""" + +PLUGIN_ROW_PROJECT_TAG_TRANSITIVE_STYLE = ( + 'QLabel { font-size: 10px; color: #808080; background: #2a2a2a;' + ' border-radius: 8px; padding: 1px 6px; font-style: italic; }' +) +"""Dimmed italic project tag for transitive (non-manifest) dependencies.""" + +# Fixed column widths for visual alignment across rows +PLUGIN_ROW_AUTO_WIDTH = 36 +"""Fixed width for the inline Auto toggle button.""" + +PLUGIN_ROW_UPDATE_WIDTH = 52 +"""Fixed width for the inline Update button.""" + +PLUGIN_ROW_VERSION_MIN_WIDTH = 60 +"""Minimum width for the version label column.""" + PLUGIN_ROW_SPACING = 1 """Pixels between individual tool/package rows.""" diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index 1971415..cdbc33e 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -10,6 +10,7 @@ import asyncio import logging +from datetime import UTC, datetime from typing import TYPE_CHECKING from PySide6.QtCore import QTimer @@ -26,8 +27,9 @@ ResolvedConfig, resolve_config, resolve_update_config, + update_user_config, ) -from synodic_client.updater import UpdateInfo +from synodic_client.schema import UpdateInfo if TYPE_CHECKING: from synodic_client.application.screen.settings import SettingsWindow @@ -272,6 +274,9 @@ def _on_download_finished(self, success: bool, version: str) -> None: self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE) return + # Persist the client update timestamp + update_user_config(last_client_update=datetime.now(UTC).isoformat()) + if self._auto_apply: # Silently apply and restart — no banner, no user interaction logger.info('Auto-applying update v%s', version) diff --git a/synodic_client/application/uri.py b/synodic_client/application/uri.py index a2c2867..65f19d8 100644 --- a/synodic_client/application/uri.py +++ b/synodic_client/application/uri.py @@ -1,6 +1,12 @@ -"""URI parsing utilities for the ``synodic://`` scheme.""" +"""URI parsing and path utilities for the ``synodic://`` scheme.""" +import logging +import shutil +from pathlib import Path from urllib.parse import parse_qs, urlparse +from urllib.request import url2pathname + +logger = logging.getLogger(__name__) def parse_uri(uri: str) -> dict[str, str | list[str]]: @@ -22,3 +28,53 @@ def parse_uri(uri: str) -> dict[str, str | list[str]]: } result.update(parse_qs(parsed.query)) return result + + +def normalize_manifest_key(path_or_url: str) -> str: + """Return a canonical key for a manifest path or URL. + + Local paths are resolved to absolute form so that the same manifest on + disk always maps to the same config entry regardless of how it was + referenced (relative, symlinked, etc.). Remote URLs are returned + unchanged. + """ + parsed = urlparse(path_or_url) + if parsed.scheme in {'http', 'https'}: + return path_or_url + try: + return str(Path(path_or_url).resolve()) + except Exception: + return path_or_url + + +def resolve_local_path(manifest_ref: str) -> Path | None: + r"""Return a ``Path`` if *manifest_ref* points to a local file, else ``None``. + + Recognised forms: + * ``file:///C:/path/to/porringer.json`` + * An absolute OS path (``C:\...`` or ``/...``) + * A relative path that exists on disk + """ + parsed = urlparse(manifest_ref) + + if parsed.scheme == 'file': + # file:///C:/Users/... → C:/Users/... + return Path(url2pathname(parsed.path)) + + if parsed.scheme in {'http', 'https'}: + return None + + # No scheme — treat as a filesystem path + candidate = Path(manifest_ref) + if candidate.is_absolute() or candidate.exists(): + return candidate + + return None + + +def safe_rmtree(path: str) -> None: + """Remove a directory tree, ignoring errors.""" + try: + shutil.rmtree(path) + except OSError: + logger.debug('Failed to clean up temp dir: %s', path) diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index 3c2fae5..e53613f 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -9,7 +9,6 @@ import asyncio import logging from collections.abc import Callable -from dataclasses import dataclass, field from pathlib import Path from porringer.api import API @@ -18,8 +17,9 @@ from porringer.schema import ProgressEventKind, SetupParameters, SkipReason, SyncStrategy from porringer.schema.execution import SetupActionResult +from synodic_client.application.schema import ToolUpdateResult from synodic_client.client import Client -from synodic_client.updater import UpdateInfo +from synodic_client.schema import UpdateInfo logger = logging.getLogger(__name__) @@ -64,18 +64,6 @@ def progress_callback(percentage: int) -> None: return await loop.run_in_executor(None, _run) -@dataclass(slots=True) -class ToolUpdateResult: - """Summary of a tool-update run across cached manifests.""" - - manifests_processed: int = 0 - updated: int = 0 - already_latest: int = 0 - failed: int = 0 - updated_packages: set[str] = field(default_factory=set) - """Package names that were successfully upgraded.""" - - async def run_tool_updates( porringer: API, plugins: set[str] | None = None, diff --git a/synodic_client/client.py b/synodic_client/client.py index 6ac056e..63dd522 100644 --- a/synodic_client/client.py +++ b/synodic_client/client.py @@ -10,7 +10,8 @@ from packaging.version import Version -from synodic_client.updater import UpdateConfig, UpdateInfo, Updater +from synodic_client.schema import UpdateConfig, UpdateInfo +from synodic_client.updater import Updater logger = logging.getLogger(__name__) diff --git a/synodic_client/config.py b/synodic_client/config.py index af0f9ec..7dacaae 100644 --- a/synodic_client/config.py +++ b/synodic_client/config.py @@ -20,7 +20,7 @@ import sys from pathlib import Path -from pydantic import BaseModel +from synodic_client.schema import BuildConfig, UserConfig logger = logging.getLogger(__name__) @@ -55,91 +55,6 @@ def is_dev_mode() -> bool: return _DevMode.enabled -# --------------------------------------------------------------------------- -# BuildConfig — read-only, lives next to the executable -# --------------------------------------------------------------------------- - - -class BuildConfig(BaseModel): - """Read-only configuration embedded next to the executable. - - Written by the packaging script (e.g. ``pdm run package -- --local-source``). - Only contains the two fields the build system needs to seed. - """ - - # URL or local file path for Velopack releases. - update_source: str | None = None - - # Update channel: "stable" or "dev". - update_channel: str | None = None - - -# --------------------------------------------------------------------------- -# UserConfig — read-write, lives in the OS data directory -# --------------------------------------------------------------------------- - - -class UserConfig(BaseModel): - """User-scoped configuration persisted in the OS application data directory. - - On Windows: ``%LOCALAPPDATA%/Synodic/config.json``. - - Every field is always saved. There are no sparse/unset semantics — - the on-disk file is a complete snapshot of the user's preferences. - """ - - # URL or local file path for Velopack releases. - # None means use the default GitHub release source. - update_source: str | None = None - - # Update channel: "stable" or "dev". - # None means auto-detect from sys.frozen. - update_channel: str | None = None - - # Interval in minutes between automatic update checks. - # 0 disables automatic checking. None uses the default (30 minutes). - auto_update_interval_minutes: int | None = None - - # Interval in minutes between tool update checks. - # 0 disables automatic checking. None uses the default (20 minutes). - tool_update_interval_minutes: int | None = None - - # Per-plugin and per-package auto-update toggle. - # - # Maps plugin name to: - # - ``True`` — all packages under this plugin auto-update (default). - # - ``False`` — the entire plugin is disabled from auto-update. - # - ``dict[str, bool]`` — per-package overrides within this plugin. - # Packages with ``True`` auto-update; ``False`` are skipped. - # Packages not listed inherit the manifest-aware default (ON for - # manifest-referenced packages, OFF for global packages). - # - # ``None`` or absent means all plugins auto-update with manifest-aware defaults. - plugin_auto_update: dict[str, bool | dict[str, bool]] | None = None - - # Check for updates during dry-run previews. When True the preview - # will query package indices for newer versions. - detect_updates: bool = True - - # Per-manifest pre-release overrides. Outer key is a normalised - # manifest path (or URL for remote manifests) produced by - # ``normalize_manifest_key()``. Inner value is a sorted list of - # package names (case-insensitive) that should be checked for - # pre-release updates even when the manifest does not set - # ``include_prereleases: true`` on the package. ``None`` means - # no overrides anywhere. - prerelease_packages: dict[str, list[str]] | None = None - - # Whether downloaded updates should be applied and restarted - # automatically without user interaction. None resolves to True. - auto_apply: bool | None = None - - # Whether the application should start automatically with the OS. - # None means use the default (enabled). Explicitly False disables - # auto-startup. - auto_start: bool | None = None - - # --------------------------------------------------------------------------- # File I/O # --------------------------------------------------------------------------- diff --git a/synodic_client/protocol.py b/synodic_client/protocol.py index 4bc4d20..04f25e8 100644 --- a/synodic_client/protocol.py +++ b/synodic_client/protocol.py @@ -73,3 +73,16 @@ def register_protocol(exe_path: str) -> None: def remove_protocol() -> None: """Remove the ``synodic://`` URI protocol handler registration (no-op on non-Windows).""" logger.warning('Protocol removal is only supported on Windows (current: %s)', sys.platform) + + +def extract_uri_from_args(args: list[str] | None = None) -> str | None: + """Return the first ``synodic://`` URI from *args*, or ``None``. + + Args: + args: Command-line arguments to scan. Defaults to + ``sys.argv[1:]`` when not supplied. + """ + for a in args if args is not None else sys.argv[1:]: + if a.lower().startswith(f'{PROTOCOL_NAME}://'): + return a + return None diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index f3a11c3..ee81c70 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -16,53 +16,26 @@ import logging import sys -from dataclasses import dataclass from synodic_client.config import ( - UserConfig, load_build_config, load_user_config, save_user_config, ) -from synodic_client.updater import ( +from synodic_client.schema import ( DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, GITHUB_REPO_URL, + ResolvedConfig, UpdateChannel, UpdateConfig, - github_release_asset_url, + UserConfig, ) +from synodic_client.updater import github_release_asset_url logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# ResolvedConfig — immutable runtime snapshot -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class ResolvedConfig: - """Immutable runtime configuration snapshot. - - Constructed by :func:`resolve_config` from the merged - ``BuildConfig`` + ``UserConfig`` layers. Every field has a - concrete, non-``None`` value (except ``update_source`` and - ``prerelease_packages`` where ``None`` is a valid semantic value - meaning "use default" / "no overrides"). - """ - - update_source: str | None - update_channel: str - auto_update_interval_minutes: int - tool_update_interval_minutes: int - plugin_auto_update: dict[str, bool | dict[str, bool]] | None - detect_updates: bool - prerelease_packages: dict[str, list[str]] | None - auto_apply: bool - auto_start: bool - - # --------------------------------------------------------------------------- # Seed — one-time build → user config propagation # --------------------------------------------------------------------------- @@ -152,6 +125,8 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig: prerelease_packages=user.prerelease_packages, auto_apply=auto_apply, auto_start=auto_start, + last_client_update=user.last_client_update, + last_tool_updates=user.last_tool_updates, ) @@ -208,34 +183,6 @@ def resolve_update_config(config: ResolvedConfig) -> UpdateConfig: ) -def resolve_enabled_plugins( - config: ResolvedConfig, - all_plugin_names: list[str], -) -> list[str] | None: - """Derive the include-list of plugins that should auto-update. - - Returns the list of plugin names whose auto-update is **not** disabled. - If all plugins are enabled (the common case), returns ``None`` to - indicate "no filtering". - - Args: - config: A resolved configuration snapshot. - all_plugin_names: Every known plugin name. - - Returns: - A list of enabled plugin names, or ``None`` when all are enabled. - """ - mapping = config.plugin_auto_update - if not mapping: - return None - - disabled = {name for name, enabled in mapping.items() if enabled is False} - if not disabled: - return None - - return [n for n in all_plugin_names if n not in disabled] - - def resolve_auto_update_scope( config: ResolvedConfig, all_plugin_names: list[str], diff --git a/synodic_client/schema.py b/synodic_client/schema.py new file mode 100644 index 0000000..4ba3f56 --- /dev/null +++ b/synodic_client/schema.py @@ -0,0 +1,235 @@ +"""Core data models for the Synodic Client. + +Contains configuration schemas (Pydantic), update-lifecycle enums and +dataclasses, and the immutable runtime configuration snapshot. These +types are intentionally decoupled from I/O, business logic, and UI so +that every layer can import them without circular dependencies. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from enum import Enum, StrEnum, auto +from typing import Any + +from packaging.version import Version +from pydantic import BaseModel + +# --------------------------------------------------------------------------- +# BuildConfig — read-only, lives next to the executable +# --------------------------------------------------------------------------- + + +class BuildConfig(BaseModel): + """Read-only configuration embedded next to the executable. + + Written by the packaging script (e.g. ``pdm run package -- --local-source``). + Only contains the two fields the build system needs to seed. + """ + + # URL or local file path for Velopack releases. + update_source: str | None = None + + # Update channel: "stable" or "dev". + update_channel: str | None = None + + +# --------------------------------------------------------------------------- +# UserConfig — read-write, lives in the OS data directory +# --------------------------------------------------------------------------- + + +class UserConfig(BaseModel): + """User-scoped configuration persisted in the OS application data directory. + + On Windows: ``%LOCALAPPDATA%/Synodic/config.json``. + + Every field is always saved. There are no sparse/unset semantics — + the on-disk file is a complete snapshot of the user's preferences. + """ + + # URL or local file path for Velopack releases. + # None means use the default GitHub release source. + update_source: str | None = None + + # Update channel: "stable" or "dev". + # None means auto-detect from sys.frozen. + update_channel: str | None = None + + # Interval in minutes between automatic update checks. + # 0 disables automatic checking. None uses the default (30 minutes). + auto_update_interval_minutes: int | None = None + + # Interval in minutes between tool update checks. + # 0 disables automatic checking. None uses the default (20 minutes). + tool_update_interval_minutes: int | None = None + + # Per-plugin and per-package auto-update toggle. + # + # Maps plugin name to: + # - ``True`` — all packages under this plugin auto-update (default). + # - ``False`` — the entire plugin is disabled from auto-update. + # - ``dict[str, bool]`` — per-package overrides within this plugin. + # Packages with ``True`` auto-update; ``False`` are skipped. + # Packages not listed inherit the manifest-aware default (ON for + # manifest-referenced packages, OFF for global packages). + # + # ``None`` or absent means all plugins auto-update with manifest-aware defaults. + plugin_auto_update: dict[str, bool | dict[str, bool]] | None = None + + # Check for updates during dry-run previews. When True the preview + # will query package indices for newer versions. + detect_updates: bool = True + + # Per-manifest pre-release overrides. Outer key is a normalised + # manifest path (or URL for remote manifests) produced by + # ``normalize_manifest_key()``. Inner value is a sorted list of + # package names (case-insensitive) that should be checked for + # pre-release updates even when the manifest does not set + # ``include_prereleases: true`` on the package. ``None`` means + # no overrides anywhere. + prerelease_packages: dict[str, list[str]] | None = None + + # Whether downloaded updates should be applied and restarted + # automatically without user interaction. None resolves to True. + auto_apply: bool | None = None + + # Whether the application should start automatically with the OS. + # None means use the default (enabled). Explicitly False disables + # auto-startup. + auto_start: bool | None = None + + # ISO 8601 timestamp of the last successful client self-update. + # None means no update has been recorded. + last_client_update: str | None = None + + # Per-package timestamps of the last successful tool update. + # Maps "plugin/package" → ISO 8601 timestamp. None means no + # tool updates have been recorded. + last_tool_updates: dict[str, str] | None = None + + +# --------------------------------------------------------------------------- +# Update channel & state enums +# --------------------------------------------------------------------------- + + +class UpdateChannel(StrEnum): + """Update channel selection.""" + + STABLE = 'stable' + DEVELOPMENT = 'development' + + +class UpdateState(Enum): + """State of an update operation.""" + + NO_UPDATE = auto() + UPDATE_AVAILABLE = auto() + DOWNLOADING = auto() + DOWNLOADED = auto() + APPLYING = auto() + APPLIED = auto() + FAILED = auto() + + +# --------------------------------------------------------------------------- +# Update dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class UpdateInfo: + """Information about an available update.""" + + available: bool + current_version: Version + latest_version: Version | None = None + error: str | None = None + + # Internal: Velopack update info for download/apply + _velopack_info: Any = field(default=None, repr=False) + + +# Default interval for automatic update checks (minutes) +DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES = 5 + +# Default interval for tool update checks (minutes) +DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES = 5 + +# GitHub repository base URL. Transformed into a release-asset URL +# by :func:`~synodic_client.updater.github_release_asset_url` at resolution +# time so that Velopack's ``HttpSource`` can fetch +# ``releases.{channel}.json`` from the correct GitHub Releases download path. +GITHUB_REPO_URL = 'https://github.com/synodic/synodic-client' + +_PLATFORM_SUFFIXES: dict[str, str] = { + 'win32': 'win', + 'linux': 'linux', + 'darwin': 'osx', +} + + +def platform_suffix() -> str: + """Return the Velopack channel suffix for the current platform.""" + try: + return _PLATFORM_SUFFIXES[sys.platform] + except KeyError: + raise RuntimeError(f'Unsupported platform for updates: {sys.platform}') from None + + +@dataclass +class UpdateConfig: + """Configuration for the updater.""" + + # GitHub repository URL for Velopack to discover releases + repo_url: str = 'https://github.com/synodic/synodic-client' + + # Channel determines whether to use dev or stable releases + channel: UpdateChannel = UpdateChannel.STABLE + + # Interval in minutes between automatic update checks (0 = disabled) + auto_update_interval_minutes: int = DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES + + # Interval in minutes between tool update checks (0 = disabled) + tool_update_interval_minutes: int = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES + + @property + def channel_name(self) -> str: + """Get the channel name for Velopack. + + Combines the update track (dev/stable) with a platform suffix + so each OS has its own release manifest and nupkg files. + """ + base = 'dev' if self.channel == UpdateChannel.DEVELOPMENT else 'stable' + return f'{base}-{platform_suffix()}' + + +# --------------------------------------------------------------------------- +# ResolvedConfig — immutable runtime snapshot +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ResolvedConfig: + """Immutable runtime configuration snapshot. + + Constructed by :func:`~synodic_client.resolution.resolve_config` from + the merged ``BuildConfig`` + ``UserConfig`` layers. Every field has a + concrete, non-``None`` value (except ``update_source`` and + ``prerelease_packages`` where ``None`` is a valid semantic value + meaning "use default" / "no overrides"). + """ + + update_source: str | None + update_channel: str + auto_update_interval_minutes: int + tool_update_interval_minutes: int + plugin_auto_update: dict[str, bool | dict[str, bool]] | None + detect_updates: bool + prerelease_packages: dict[str, list[str]] | None + auto_apply: bool + auto_start: bool + last_client_update: str | None + last_tool_updates: dict[str, str] | None diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 9bbc4a3..43a24ed 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -10,24 +10,22 @@ import logging import sys from collections.abc import Callable -from dataclasses import dataclass, field -from enum import Enum, StrEnum, auto from typing import Any import velopack from packaging.version import Version from synodic_client.protocol import remove_protocol +from synodic_client.schema import ( + UpdateChannel, + UpdateConfig, + UpdateInfo, + UpdateState, +) from synodic_client.startup import remove_startup logger = logging.getLogger(__name__) -# GitHub repository base URL. Transformed into a release-asset URL -# by :func:`github_release_asset_url` at resolution time so that -# Velopack's ``HttpSource`` can fetch ``releases.{channel}.json`` -# from the correct GitHub Releases download path. -GITHUB_REPO_URL = 'https://github.com/synodic/synodic-client' - # Fixed tag used for rolling development releases on GitHub. _DEV_RELEASE_TAG = 'dev' @@ -99,88 +97,6 @@ def github_release_asset_url(repo_url: str, channel: UpdateChannel) -> str: # Map sys.platform values to Velopack channel suffixes -_PLATFORM_SUFFIXES: dict[str, str] = { - 'win32': 'win', - 'linux': 'linux', - 'darwin': 'osx', -} - - -def platform_suffix() -> str: - """Return the Velopack channel suffix for the current platform.""" - try: - return _PLATFORM_SUFFIXES[sys.platform] - except KeyError: - raise RuntimeError(f'Unsupported platform for updates: {sys.platform}') from None - - -class UpdateChannel(StrEnum): - """Update channel selection.""" - - STABLE = 'stable' - DEVELOPMENT = 'development' - - -class UpdateState(Enum): - """State of an update operation.""" - - NO_UPDATE = auto() - UPDATE_AVAILABLE = auto() - DOWNLOADING = auto() - DOWNLOADED = auto() - APPLYING = auto() - APPLIED = auto() - FAILED = auto() - - -@dataclass -class UpdateInfo: - """Information about an available update.""" - - available: bool - current_version: Version - latest_version: Version | None = None - error: str | None = None - - # Internal: Velopack update info for download/apply - _velopack_info: Any = field(default=None, repr=False) - - -# Default interval for automatic update checks (minutes) -DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES = 5 - -# Default interval for tool update checks (minutes) -DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES = 5 - - -@dataclass -class UpdateConfig: - """Configuration for the updater.""" - - # GitHub repository URL for Velopack to discover releases - repo_url: str = GITHUB_REPO_URL - - # Channel determines whether to use dev or stable releases - channel: UpdateChannel = UpdateChannel.STABLE - - # Interval in minutes between automatic update checks (0 = disabled) - auto_update_interval_minutes: int = DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES - - # Interval in minutes between tool update checks (0 = disabled) - tool_update_interval_minutes: int = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES - - @property - def channel_name(self) -> str: - """Get the channel name for Velopack. - - Combines the update track (dev/stable) with a platform suffix - so each OS has its own release manifest and nupkg files. - """ - base = 'dev' if self.channel == UpdateChannel.DEVELOPMENT else 'stable' - suffix = platform_suffix() - return f'{base}-{suffix}' - - class Updater: """Handles self-update operations using Velopack.""" @@ -467,7 +383,10 @@ def _on_before_uninstall(version: str) -> None: logger.warning('Auto-startup removal failed during uninstall hook', exc_info=True) -_velopack_initialized = False +class _VelopackState: + """Module-level mutable state (avoids ``global`` statements).""" + + initialized: bool = False def initialize_velopack() -> None: @@ -484,10 +403,9 @@ def initialize_velopack() -> None: The SDK's callback hooks only accept ``PyCFunction`` — add an uninstall hook here when that is fixed upstream. """ - global _velopack_initialized # noqa: PLW0603 - if _velopack_initialized: + if _VelopackState.initialized: return - _velopack_initialized = True + _VelopackState.initialized = True logger.info('Initializing Velopack (exe=%s)', sys.executable) try: diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index c935f94..83c703e 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -12,17 +12,15 @@ from porringer.schema.plugin import PluginInfo, PluginKind from PySide6.QtWidgets import QLabel, QPushButton -from synodic_client.application.screen.screen import ( +from synodic_client.application.screen.plugin_row import ( FilterChip, - PackageEntry, PluginKindHeader, PluginProviderHeader, PluginRow, - PluginRowData, ProjectChildRow, - ProjectInstance, - ToolsView, ) +from synodic_client.application.screen.schema import PackageEntry, PluginRowData, ProjectInstance +from synodic_client.application.screen.screen import ToolsView from synodic_client.resolution import ResolvedConfig # Named constants for expected counts (avoids PLR2004) @@ -43,6 +41,8 @@ def _make_config() -> ResolvedConfig: prerelease_packages=None, auto_apply=True, auto_start=False, + last_client_update=None, + last_tool_updates=None, ) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 60cc851..d6e03a5 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -24,17 +24,11 @@ format_cli_command, skip_reason_label, ) -from synodic_client.application.screen.install import ( - InstallConfig, - PreviewCallbacks, - PreviewConfig, - normalize_manifest_key, - resolve_local_path, - run_install, - run_preview, -) +from synodic_client.application.screen.install_workers import run_install, run_preview +from synodic_client.application.screen.schema import InstallConfig, PreviewCallbacks, PreviewConfig +from synodic_client.application.uri import normalize_manifest_key, resolve_local_path -_DOWNLOAD_PATCH = 'synodic_client.application.screen.install.API.download' +_DOWNLOAD_PATCH = 'synodic_client.application.screen.install_workers.API.download' _EXPECTED_CHECKED_COUNT = 2 diff --git a/tests/unit/qt/test_log_panel.py b/tests/unit/qt/test_log_panel.py index 61f2415..812f661 100644 --- a/tests/unit/qt/test_log_panel.py +++ b/tests/unit/qt/test_log_panel.py @@ -17,13 +17,14 @@ ) from porringer.schema.plugin import PluginKind -from synodic_client.application.screen.install import InstallCallbacks, run_install +from synodic_client.application.screen.install_workers import run_install from synodic_client.application.screen.log_panel import ( CHEVRON_DOWN, CHEVRON_RIGHT, ActionLogSection, ExecutionLogPanel, ) +from synodic_client.application.screen.schema import InstallCallbacks from synodic_client.application.theme import ( LOG_COLOR_ERROR, LOG_COLOR_PHASE, diff --git a/tests/unit/qt/test_preview_model.py b/tests/unit/qt/test_preview_model.py index 35d6cb2..19ef933 100644 --- a/tests/unit/qt/test_preview_model.py +++ b/tests/unit/qt/test_preview_model.py @@ -9,12 +9,8 @@ from porringer.schema.plugin import PluginKind from synodic_client.application.screen.action_card import action_key -from synodic_client.application.screen.install import ( - ActionState, - PreviewModel, - PreviewPhase, - normalize_manifest_key, -) +from synodic_client.application.screen.schema import ActionState, PreviewModel, PreviewPhase +from synodic_client.application.uri import normalize_manifest_key # --------------------------------------------------------------------------- # Helpers diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index c7a9832..930eecc 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -8,7 +8,7 @@ from synodic_client.application.screen.settings import SettingsWindow from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE from synodic_client.resolution import ResolvedConfig -from synodic_client.updater import DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES +from synodic_client.schema import DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES # --------------------------------------------------------------------------- # Helpers @@ -27,6 +27,8 @@ def _make_config(**overrides: Any) -> ResolvedConfig: 'prerelease_packages': None, 'auto_apply': True, 'auto_start': True, + 'last_client_update': None, + 'last_tool_updates': None, } defaults.update(overrides) return ResolvedConfig(**defaults) diff --git a/tests/unit/qt/test_sidebar.py b/tests/unit/qt/test_sidebar.py index 7d8eae6..69cc933 100644 --- a/tests/unit/qt/test_sidebar.py +++ b/tests/unit/qt/test_sidebar.py @@ -4,7 +4,7 @@ from pathlib import Path -from synodic_client.application.screen.install import PreviewPhase +from synodic_client.application.screen.schema import PreviewPhase from synodic_client.application.screen.sidebar import ManifestItem, ManifestSidebar from synodic_client.application.theme import SIDEBAR_WIDTH diff --git a/tests/unit/qt/test_tray_window_show.py b/tests/unit/qt/test_tray_window_show.py index 433ac64..3c88162 100644 --- a/tests/unit/qt/test_tray_window_show.py +++ b/tests/unit/qt/test_tray_window_show.py @@ -6,8 +6,8 @@ import pytest +from synodic_client.application.schema import ToolUpdateResult from synodic_client.application.screen.tray import TrayScreen -from synodic_client.application.workers import ToolUpdateResult @pytest.fixture @@ -15,7 +15,7 @@ def tray_screen(): """Build a minimal ``TrayScreen`` with mocked collaborators.""" with ( patch('synodic_client.application.screen.tray.resolve_config'), - patch('synodic_client.application.screen.tray.resolve_update_config') as mock_ucfg, + patch('synodic_client.application.screen.tool_update_controller.resolve_update_config') as mock_ucfg, patch('synodic_client.application.screen.tray.UpdateController'), ): # Disable timers by setting intervals to 0 @@ -41,21 +41,21 @@ class TestToolUpdateWindowShow: def test_auto_update_does_not_show_window(tray_screen) -> None: """Periodic (automatic) tool update must not bring the window forward.""" result = ToolUpdateResult(manifests_processed=1, updated=1) - tray_screen._on_tool_update_finished(result) + tray_screen._tool_orchestrator._on_tool_update_finished(result) tray_screen._window.show.assert_not_called() @staticmethod def test_manual_plugin_update_shows_window(tray_screen) -> None: """A user-initiated single-plugin update should show the window.""" result = ToolUpdateResult(manifests_processed=1, updated=1) - tray_screen._on_tool_update_finished(result, updating_plugin='pipx', manual=True) + tray_screen._tool_orchestrator._on_tool_update_finished(result, updating_plugin='pipx', manual=True) tray_screen._window.show.assert_called_once() @staticmethod def test_manual_package_update_shows_window(tray_screen) -> None: """A user-initiated single-package update should show the window.""" result = ToolUpdateResult(manifests_processed=1, updated=1) - tray_screen._on_tool_update_finished( + tray_screen._tool_orchestrator._on_tool_update_finished( result, updating_package=('pipx', 'ruff'), manual=True, @@ -66,5 +66,5 @@ def test_manual_package_update_shows_window(tray_screen) -> None: def test_auto_update_with_no_changes_does_not_show(tray_screen) -> None: """An automatic check with nothing to update must stay hidden.""" result = ToolUpdateResult(manifests_processed=1, already_latest=1) - tray_screen._on_tool_update_finished(result) + tray_screen._tool_orchestrator._on_tool_update_finished(result) tray_screen._window.show.assert_not_called() diff --git a/tests/unit/qt/test_update_banner.py b/tests/unit/qt/test_update_banner.py index 952dc61..0bac321 100644 --- a/tests/unit/qt/test_update_banner.py +++ b/tests/unit/qt/test_update_banner.py @@ -2,7 +2,8 @@ from __future__ import annotations -from synodic_client.application.screen.update_banner import UpdateBanner, UpdateBannerState +from synodic_client.application.screen.schema import UpdateBannerState +from synodic_client.application.screen.update_banner import UpdateBanner _PROGRESS_MAX = 100 _TEST_PROGRESS = 42 diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index f480e81..b18e6cd 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -15,7 +15,7 @@ ) from synodic_client.application.update_controller import UpdateController from synodic_client.resolution import ResolvedConfig -from synodic_client.updater import ( +from synodic_client.schema import ( DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, UpdateInfo, @@ -38,6 +38,8 @@ def _make_config(**overrides: Any) -> ResolvedConfig: 'prerelease_packages': None, 'auto_apply': True, 'auto_start': True, + 'last_client_update': None, + 'last_tool_updates': None, } defaults.update(overrides) return ResolvedConfig(**defaults) diff --git a/tests/unit/qt/test_update_feedback.py b/tests/unit/qt/test_update_feedback.py index f408ba7..d2b019e 100644 --- a/tests/unit/qt/test_update_feedback.py +++ b/tests/unit/qt/test_update_feedback.py @@ -8,7 +8,8 @@ from porringer.schema import PluginInfo from porringer.schema.plugin import PluginKind -from synodic_client.application.screen.screen import PluginProviderHeader, PluginRow, PluginRowData +from synodic_client.application.screen.plugin_row import PluginProviderHeader, PluginRow +from synodic_client.application.screen.schema import PluginRowData def _make_plugin( @@ -166,9 +167,10 @@ class TestPluginRowUpdates: @staticmethod def test_no_update_button_by_default() -> None: - """With has_update=False the row has no update button.""" + """With has_update=False the update button exists but is hidden.""" row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', show_toggle=True)) - assert row._update_btn is None + assert row._update_btn is not None + assert row._update_btn.isHidden() @staticmethod def test_update_button_visible_when_has_update() -> None: @@ -236,11 +238,11 @@ def test_update_requested_signal() -> None: @staticmethod def test_set_updating_noop_without_button() -> None: - """set_updating is a no-op when no update button exists.""" + """set_updating works even when has_update was False (button is hidden).""" row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', show_toggle=True)) # Should not raise row.set_updating(True) - assert row._update_btn is None + assert row._update_btn is not None @staticmethod def test_set_checking_shows_spinner() -> None: diff --git a/tests/unit/test_client_updater.py b/tests/unit/test_client_updater.py index 606ed78..b23b8e5 100644 --- a/tests/unit/test_client_updater.py +++ b/tests/unit/test_client_updater.py @@ -6,7 +6,7 @@ from packaging.version import Version from synodic_client.client import Client -from synodic_client.updater import UpdateConfig, UpdateInfo +from synodic_client.schema import UpdateConfig, UpdateInfo @pytest.fixture diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index dceed31..fec8059 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -13,7 +13,7 @@ @pytest.fixture(autouse=True) def _reset_preamble_guard() -> None: """Reset the idempotency guard before each test.""" - init_mod._preamble_done = False + init_mod._PreambleState.done = False class TestRunStartupPreamble: diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index 3742ec0..3133485 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -4,21 +4,21 @@ from typing import Any from unittest.mock import patch -from synodic_client.config import BuildConfig, UserConfig from synodic_client.resolution import ( ResolvedConfig, resolve_auto_update_scope, resolve_config, - resolve_enabled_plugins, resolve_update_config, seed_user_config_from_build, update_user_config, ) -from synodic_client.updater import ( +from synodic_client.schema import ( DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, GITHUB_REPO_URL, + BuildConfig, UpdateChannel, + UserConfig, ) # --------------------------------------------------------------------------- @@ -38,6 +38,8 @@ def _make_resolved(**overrides: Any) -> ResolvedConfig: 'prerelease_packages': None, 'auto_apply': True, 'auto_start': True, + 'last_client_update': None, + 'last_tool_updates': None, } defaults.update(overrides) return ResolvedConfig(**defaults) @@ -217,54 +219,6 @@ def test_saves_changed_field() -> None: assert saved.update_channel == 'dev' -# --------------------------------------------------------------------------- -# resolve_enabled_plugins -# --------------------------------------------------------------------------- - - -class TestResolveEnabledPlugins: - """Tests for resolve_enabled_plugins.""" - - @staticmethod - def test_none_when_no_mapping() -> None: - """Verify None is returned when plugin_auto_update is unset.""" - config = _make_resolved() - result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) - assert result is None - - @staticmethod - def test_none_when_all_enabled() -> None: - """Verify None when all entries are True.""" - config = _make_resolved(plugin_auto_update={'pip': True, 'pipx': True}) - result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) - assert result is None - - @staticmethod - def test_filters_disabled_plugins() -> None: - """Verify disabled plugins are excluded from the list.""" - config = _make_resolved(plugin_auto_update={'pipx': False}) - result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) - assert result is not None - assert 'pipx' not in result - assert 'pip' in result - assert 'git' in result - - @staticmethod - def test_empty_mapping_returns_none() -> None: - """Verify an empty dict behaves like None.""" - config = _make_resolved(plugin_auto_update={}) - result = resolve_enabled_plugins(config, ['pip']) - assert result is None - - @staticmethod - def test_nested_dict_is_not_false() -> None: - """Verify a nested dict entry is not treated as disabled.""" - config = _make_resolved(plugin_auto_update={'uv': {'ruff': True}}) - result = resolve_enabled_plugins(config, ['uv', 'pip']) - # 'uv' has a dict value (not False) so it should still be enabled - assert result is None - - # --------------------------------------------------------------------------- # resolve_auto_update_scope # --------------------------------------------------------------------------- diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 6110f7d..9185a42 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -7,17 +7,12 @@ from packaging.version import Version import synodic_client.updater as updater_mod +from synodic_client.schema import GITHUB_REPO_URL, UpdateChannel, UpdateConfig, UpdateInfo, UpdateState, platform_suffix from synodic_client.updater import ( - GITHUB_REPO_URL, - UpdateChannel, - UpdateConfig, - UpdateInfo, Updater, - UpdateState, github_release_asset_url, initialize_velopack, pep440_to_semver, - platform_suffix, ) @@ -422,7 +417,7 @@ class TestInitializeVelopack: @pytest.fixture(autouse=True) def _reset_velopack_guard() -> None: """Reset the idempotency guard before each test.""" - updater_mod._velopack_initialized = False + updater_mod._VelopackState.initialized = False @staticmethod def test_initialize_success() -> None: diff --git a/tests/unit/test_workers.py b/tests/unit/test_workers.py index 7fcd00d..bf586a7 100644 --- a/tests/unit/test_workers.py +++ b/tests/unit/test_workers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from synodic_client.application.workers import ToolUpdateResult +from synodic_client.application.schema import ToolUpdateResult class TestToolUpdateResult: diff --git a/tool/scripts/package.py b/tool/scripts/package.py index c28e69f..f903c6f 100644 --- a/tool/scripts/package.py +++ b/tool/scripts/package.py @@ -19,7 +19,8 @@ import typer from synodic_client import __version__ -from synodic_client.updater import pep440_to_semver, platform_suffix +from synodic_client.schema import platform_suffix +from synodic_client.updater import pep440_to_semver from tool.scripts.common import ICON_FILE, MAIN_EXE, OUTPUT_DIR, PACK_DIR, PACK_ID, build, kill_running_instances, run app = typer.Typer(help='Package Synodic Client with PyInstaller and Velopack.')