From 3be7f3758ad297392dd83fe107d36a65101b1f7a Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 11:12:04 -0800 Subject: [PATCH 01/12] Update updater.py --- synodic_client/updater.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 5721c50..081b875 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -344,6 +344,7 @@ def _get_velopack_manager(self) -> Any: try: options = velopack.UpdateOptions( AllowVersionDowngrade=False, + MaximumDeltasBeforeFallback=10, # required by the SDK ) options.ExplicitChannel = self._config.channel_name From 3599c48b77a64d5ac02f0ebe2a0d193792cb9acd Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 12:06:57 -0800 Subject: [PATCH 02/12] Simplify Dev Releases --- .github/workflows/release-build.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 68a4ba3..85c04df 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -95,16 +95,10 @@ jobs: path: releases merge-multiple: true - - name: Remove conflicting dev release assets + - name: Clear dev release assets if: needs.get-version.outputs.is-dev == 'true' continue-on-error: true - run: | - for asset in $(gh release view dev --json assets -q '.assets[].name' 2>/dev/null); do - case "$asset" in - *-full.nupkg|*-delta.nupkg) ;; # Keep old nupkgs for delta chain - *) gh release delete-asset dev "$asset" --yes ;; - esac - done + run: gh release view dev --json assets -q '.assets[].name' | xargs -I {} gh release delete-asset dev {} --yes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} From 7c8eac80daf701284e8cfd02852b145e4c0e3f17 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 15:01:50 -0800 Subject: [PATCH 03/12] Use Manifest API --- pdm.lock | 30 ++++----- pyproject.toml | 4 +- synodic_client/application/icon.py | 2 - synodic_client/application/screen/screen.py | 70 ++++++++++++--------- synodic_client/application/screen/tray.py | 10 +-- synodic_client/application/theme.py | 2 - 6 files changed, 63 insertions(+), 55 deletions(-) diff --git a/pdm.lock b/pdm.lock index b584da6..9fe4796 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:bd9e98e0d50e1c54a0b99a288604b6f0abff6018bd52d4bf2caced3e73927265" +content_hash = "sha256:aa8731ea981ed6883f7863dd41759ff250b8f136de4263acbfd2661d09f7a3b3" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -272,7 +272,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev20" +version = "0.2.1.dev21" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -285,8 +285,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev20-py3-none-any.whl", hash = "sha256:a3ba824b3ba96b35c7dca722e8fe666b292d380572f7afc323ee573f9e38f3b1"}, - {file = "porringer-0.2.1.dev20.tar.gz", hash = "sha256:7ea53736631ceb92657ee23eb6993bd377a48dba0c4bd53ea41e23956323b7c3"}, + {file = "porringer-0.2.1.dev21-py3-none-any.whl", hash = "sha256:4dcf4b653873b8322a49b0902aeef2a5edf20d4cf66c80ed92d1b206e4eb4970"}, + {file = "porringer-0.2.1.dev21.tar.gz", hash = "sha256:c6fc3f8a7a938bcc032b5ca4603957d68ab24f370691759c9718f813665860a7"}, ] [[package]] @@ -619,34 +619,34 @@ files = [ [[package]] name = "typer" -version = "0.23.1" -requires_python = ">=3.9" +version = "0.24.0" +requires_python = ">=3.10" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." groups = ["default"] dependencies = [ "annotated-doc>=0.0.2", - "click>=8.0.0", - "rich>=10.11.0", + "click>=8.2.1", + "rich>=12.3.0", "shellingham>=1.3.0", ] files = [ - {file = "typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e"}, - {file = "typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134"}, + {file = "typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8"}, + {file = "typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504"}, ] [[package]] name = "typer" -version = "0.23.1" +version = "0.24.0" extras = ["all"] -requires_python = ">=3.9" +requires_python = ">=3.10" summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." groups = ["default"] dependencies = [ - "typer==0.23.1", + "typer==0.24.0", ] files = [ - {file = "typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e"}, - {file = "typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134"}, + {file = "typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8"}, + {file = "typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 6e6ef30..114cd8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,9 +14,9 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev20", + "porringer>=0.2.1.dev21", "velopack>=0.0.1369.dev7516", - "typer>=0.23.1", + "typer>=0.24.0", ] [project.urls] diff --git a/synodic_client/application/icon.py b/synodic_client/application/icon.py index bef97ef..17a5ec4 100644 --- a/synodic_client/application/icon.py +++ b/synodic_client/application/icon.py @@ -4,8 +4,6 @@ so every caller shares the same instance. """ -from __future__ import annotations - from PySide6.QtGui import QIcon, QPixmap from synodic_client.client import Client diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 7bb2054..2f476f7 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -1,15 +1,12 @@ """Screen class for the Synodic Client application.""" -from __future__ import annotations - import logging from collections import OrderedDict from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING from porringer.api import API -from porringer.schema import PluginInfo, PluginKind +from porringer.schema import DirectoryValidationResult, ManifestDirectory, PluginInfo, PluginKind, SetupResults from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QStandardItem from PySide6.QtWidgets import ( @@ -46,9 +43,6 @@ ) from synodic_client.config import GlobalConfiguration, save_config -if TYPE_CHECKING: - from porringer.schema import ManifestDirectory - logger = logging.getLogger(__name__) # Plugin kinds that support auto-update and per-plugin upgrade. @@ -534,24 +528,23 @@ def refresh(self) -> None: current_text = self._combo.currentText() self._combo.clear() - directories = self._porringer.cache.list_directories() - for directory in directories: + results: list[DirectoryValidationResult] = self._porringer.cache.validate_directories(check_manifest=True) + for result in results: + directory = result.directory display = str(directory.path) tooltip = directory.name or '' - exists = Path(directory.path).is_dir() idx = self._combo.count() self._combo.addItem(display) self._combo.setItemData(idx, tooltip, Qt.ItemDataRole.ToolTipRole) self._combo.setItemData(idx, str(directory.path), Qt.ItemDataRole.UserRole) - if not exists: - # Grey out entries whose directory no longer exists on disk - model = self._combo.model() - item = model.item(idx) if hasattr(model, 'item') else None - if isinstance(item, QStandardItem): - item.setForeground(self.palette().placeholderText()) - item.setToolTip(f'{tooltip} \u2014 directory not found' if tooltip else 'Directory not found') + if not result.exists: + # Grey out entries whose path no longer exists on disk + self._grey_out_item(idx, tooltip, 'Path not found') + elif result.has_manifest is False: + # Dim entries where the path exists but no manifest is found + self._grey_out_item(idx, tooltip, 'No manifest found') # Restore previous selection if it still exists idx = self._combo.findText(current_text) @@ -567,6 +560,14 @@ def refresh(self) -> None: if self._combo.currentText(): self._load_preview() + def _grey_out_item(self, idx: int, tooltip: str, reason: str) -> None: + """Grey out a combo box item and append a reason to its tooltip.""" + model = self._combo.model() + item = model.item(idx) if hasattr(model, 'item') else None + if isinstance(item, QStandardItem): + item.setForeground(self.palette().placeholderText()) + item.setToolTip(f'{tooltip} \u2014 {reason}' if tooltip else reason) + # --- Event handlers --- def _on_selection_changed(self, _index: int) -> None: @@ -576,12 +577,14 @@ def _on_selection_changed(self, _index: int) -> None: self._load_preview() def _on_browse(self) -> None: - """Open a directory picker and set the combo text.""" - chosen = QFileDialog.getExistingDirectory( + """Open a file picker filtered to recognised manifest filenames.""" + filenames = self._porringer.sync.manifest_filenames() + filter_str = 'Manifests (' + ' '.join(filenames) + ');;All Files (*)' + chosen, _ = QFileDialog.getOpenFileName( self, - 'Select Project Directory', + 'Select Manifest File', self._combo.currentText() or '', - QFileDialog.Option.ShowDirsOnly, + filter_str, ) if chosen: self._combo.setEditText(chosen) @@ -624,23 +627,26 @@ def _load_preview(self) -> None: if not path_text: return - project_path = Path(path_text) - manifest_path = project_path / 'porringer.json' + selected_path = Path(path_text) self._preview.reset() - if not project_path.is_dir(): - self._preview.show_not_found(f'Directory not found: {project_path}') + if not selected_path.exists(): + self._preview.show_not_found(f'Path not found: {selected_path}') return - self._preview.set_project_directory(project_path) + if not self._porringer.sync.has_manifest(selected_path): + self._preview.show_not_found(f'No manifest found at: {selected_path}') + return + # Defer project directory assignment until the preview result + # provides root_directory — handles both file and directory inputs. preview_worker = PreviewWorker( self._porringer, - str(manifest_path), - project_directory=project_path, + str(selected_path), + project_directory=selected_path if selected_path.is_dir() else None, ) - preview_worker.preview_ready.connect(self._preview.on_preview_ready) + preview_worker.preview_ready.connect(self._on_preview_ready) preview_worker.action_checked.connect(self._preview.on_action_checked) preview_worker.finished.connect(self._preview.on_preview_finished) preview_worker.error.connect(self._on_preview_error) @@ -648,6 +654,12 @@ def _load_preview(self) -> None: self._runner = preview_worker self._runner.start() + def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: + """Set the project directory from the manifest result and forward.""" + if preview.root_directory: + self._preview.set_project_directory(preview.root_directory) + self._preview.on_preview_ready(preview, manifest_path, temp_dir_path) + def _on_preview_error(self, message: str) -> None: """Handle preview errors inline instead of showing a modal dialog.""" logger.warning('Preview error: %s', message) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index ac968bd..e910901 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -108,13 +108,13 @@ def run(self) -> None: directories = self._porringer.cache.list_directories() count = 0 for directory in directories: - manifest = Path(directory.path) / 'porringer.json' - if not manifest.exists(): - logger.debug('Skipping missing manifest: %s', manifest) + path = Path(directory.path) + if not self._porringer.sync.has_manifest(path): + logger.debug('Skipping path without manifest: %s', path) continue params = SetupParameters( - paths=[manifest], - project_directory=Path(directory.path), + paths=[path], + project_directory=path if path.is_dir() else None, strategy=SyncStrategy.LATEST, plugins=self._plugins, ) diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 824b09d..bf54907 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -4,8 +4,6 @@ code focused on layout and behaviour rather than pixel tweaking. """ -from __future__ import annotations - # --------------------------------------------------------------------------- # Window sizes (width, height) # --------------------------------------------------------------------------- From 9ea146ba1fdafdac13c3b73b08f6729111948c24 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 18:34:15 -0800 Subject: [PATCH 04/12] Cleanup --- pdm.lock | 8 +-- pyproject.toml | 2 +- synodic_client/application/screen/install.py | 52 ++++++++++++++------ 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/pdm.lock b/pdm.lock index 9fe4796..0080868 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:aa8731ea981ed6883f7863dd41759ff250b8f136de4263acbfd2661d09f7a3b3" +content_hash = "sha256:b0ed1d5bd0ef82beba1248745d132b8659f636cccf54c2fc92108bf9c7add883" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -272,7 +272,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev21" +version = "0.2.1.dev23" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -285,8 +285,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev21-py3-none-any.whl", hash = "sha256:4dcf4b653873b8322a49b0902aeef2a5edf20d4cf66c80ed92d1b206e4eb4970"}, - {file = "porringer-0.2.1.dev21.tar.gz", hash = "sha256:c6fc3f8a7a938bcc032b5ca4603957d68ab24f370691759c9718f813665860a7"}, + {file = "porringer-0.2.1.dev23-py3-none-any.whl", hash = "sha256:8c38892c95a63edca47843b015d7faac4416201586482d1c6888aa50a8a1e678"}, + {file = "porringer-0.2.1.dev23.tar.gz", hash = "sha256:b98cf5938f861d7400d60139b3dd64272ea158f83d2627700e6503f4e973d6c9"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 114cd8e..50980a1 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.dev21", + "porringer>=0.2.1.dev23", "velopack>=0.0.1369.dev7516", "typer>=0.24.0", ] diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 217c606..6e946b9 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -278,6 +278,7 @@ def __init__(self, porringer: API, parent: QWidget | None = None, *, show_close: self._cancellation_token: CancellationToken | None = None self._completed_count = 0 self._action_statuses: list[str] = [] + self._action_to_table_row: dict[int, int] = {} self._init_ui() @@ -383,6 +384,7 @@ def reset(self) -> None: self._cancellation_token = None self._completed_count = 0 self._action_statuses = [] + self._action_to_table_row = {} self._table.setRowCount(0) self._log_panel.clear() @@ -440,7 +442,12 @@ def on_action_checked(self, row: int, result: SetupActionResult) -> None: if 0 <= row < len(self._action_statuses): self._action_statuses[row] = label - item = self._table.item(row, 4) + # Command actions are not shown in the table. + table_row = self._action_to_table_row.get(row) + if table_row is None: + return + + item = self._table.item(table_row, 4) if item is None: return @@ -459,13 +466,17 @@ def on_preview_finished(self) -> None: for i, status in enumerate(self._action_statuses): if status == 'Checking…': self._action_statuses[i] = 'Needed' - item = self._table.item(i, 4) - if item is not None: - item.setText('Needed') - item.setForeground(self.palette().text()) - - total = len(self._action_statuses) - needed = sum(1 for s in self._action_statuses if s == 'Needed') + table_row = self._action_to_table_row.get(i) + if table_row is not None: + item = self._table.item(table_row, 4) + if item is not None: + item.setText('Needed') + item.setForeground(self.palette().text()) + + # Count only actions shown in the table (excludes bare commands). + table_statuses = [self._action_statuses[i] for i in self._action_to_table_row] + total = len(table_statuses) + needed = sum(1 for s in table_statuses if s == 'Needed') satisfied = total - needed if needed == 0: @@ -538,17 +549,26 @@ def _copy_table_selection(self) -> None: clipboard.setText('\n'.join(lines)) def _populate_table(self, actions: list[SetupAction]) -> None: - """Fill the actions table from a list of SetupAction objects.""" - self._table.setRowCount(len(actions)) - for row, action in enumerate(actions): - self._table.setItem(row, 0, QTableWidgetItem(ACTION_KIND_LABELS.get(action.kind, 'Action'))) - self._table.setItem(row, 1, QTableWidgetItem(action.installer or '')) - self._table.setItem(row, 2, QTableWidgetItem(str(action.package) if action.package else '')) - self._table.setItem(row, 3, QTableWidgetItem(action.package_description or action.description)) + """Fill the actions table from a list of SetupAction objects. + + Command actions (``kind is None``) are excluded from the table + because they cannot be dry-run checked — they always appear as + *Needed* which is misleading. They remain visible in the + command-list view and are still executed during install. + """ + self._action_to_table_row = {} + table_actions = [(i, a) for i, a in enumerate(actions) if a.kind is not None] + self._table.setRowCount(len(table_actions)) + for table_row, (action_idx, action) in enumerate(table_actions): + self._action_to_table_row[action_idx] = table_row + self._table.setItem(table_row, 0, QTableWidgetItem(ACTION_KIND_LABELS.get(action.kind, 'Action'))) + self._table.setItem(table_row, 1, QTableWidgetItem(action.installer or '')) + self._table.setItem(table_row, 2, QTableWidgetItem(str(action.package) if action.package else '')) + self._table.setItem(table_row, 3, QTableWidgetItem(action.package_description or action.description)) status_item = QTableWidgetItem('Checking…') status_item.setForeground(self.palette().placeholderText()) - self._table.setItem(row, 4, status_item) + self._table.setItem(table_row, 4, status_item) self._command_list.populate(actions) self._toggle_btn.setEnabled(True) From 623ffb5baa477be5e2f2d7fc89cce0b761da9da0 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 19:03:10 -0800 Subject: [PATCH 05/12] QAsync --- .vscode/settings.json | 3 + pdm.lock | 13 +- pyproject.toml | 1 + synodic_client/application/qt.py | 14 +- synodic_client/application/screen/install.py | 32 ++- synodic_client/application/screen/screen.py | 206 ++++++++++++------- synodic_client/application/screen/tray.py | 7 +- 7 files changed, 194 insertions(+), 82 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 31274ab..1fc1046 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,4 +8,7 @@ "editor.codeActionsOnSave": { "source.organizeImports": "explicit" }, + "cSpell.words": [ + "qasync" + ], } \ No newline at end of file diff --git a/pdm.lock b/pdm.lock index 0080868..8ab5639 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:b0ed1d5bd0ef82beba1248745d132b8659f636cccf54c2fc92108bf9c7add883" +content_hash = "sha256:8a071dba1c80d524a2e083f01f624d33bf0985e0904ba23f20b4261b596de3e9" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -539,6 +539,17 @@ files = [ {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, ] +[[package]] +name = "qasync" +version = "0.28.0" +requires_python = ">=3.8" +summary = "Python library for using asyncio in Qt-based applications" +groups = ["default"] +files = [ + {file = "qasync-0.28.0-py3-none-any.whl", hash = "sha256:21faba8d047c717008378f5ac29ea58c32a8128528629e4afd57c59b768dba0f"}, + {file = "qasync-0.28.0.tar.gz", hash = "sha256:6f7f1f18971f59cb259b107218269ba56e3ad475ec456e54714b426a6e30b71d"}, +] + [[package]] name = "rich" version = "14.3.2" diff --git a/pyproject.toml b/pyproject.toml index 50980a1..75eee13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", "porringer>=0.2.1.dev23", + "qasync>=0.27.1", "velopack>=0.0.1369.dev7516", "typer>=0.24.0", ] diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index df47b1e..b3f5ba1 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -1,5 +1,6 @@ """GUI entry point for the Synodic Client application.""" +import asyncio import ctypes import logging import signal @@ -7,6 +8,7 @@ import types from collections.abc import Callable +import qasync from porringer.api import API from porringer.schema import LocalConfiguration from PySide6.QtCore import Qt, QTimer @@ -48,8 +50,6 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfigura update_config.repo_url, ) - porringer.plugin.list() - return client, porringer, config @@ -142,6 +142,9 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None: app = _init_app() + loop = qasync.QEventLoop(app) + asyncio.set_event_loop(loop) + instance = SingleInstance(app) if instance.try_send_to_existing(uri or ''): logger.info('Another instance is already running, exiting') @@ -168,9 +171,10 @@ def _handle_install_uri(manifest_url: str) -> None: if uri: _process_uri(uri, _handle_install_uri) - # sys.exit ensures proper cleanup and exit code propagation - # Leading underscore indicates references kept alive intentionally until exec() returns - sys.exit(app.exec()) + # qasync integrates the asyncio event loop with Qt's event loop, + # enabling async/await usage in the GUI layer without dedicated threads. + with loop: + loop.run_forever() _PROTOCOL_SCHEME = 'synodic' diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 6e946b9..0403f03 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -349,8 +349,13 @@ def _init_button_bar(self) -> QHBoxLayout: self._toggle_btn.setEnabled(False) self._toggle_btn.clicked.connect(self._toggle_view) + self._log_btn = QPushButton('Show Log') + self._log_btn.setEnabled(False) + self._log_btn.clicked.connect(self._show_log) + button_bar = QHBoxLayout() button_bar.addWidget(self._toggle_btn) + button_bar.addWidget(self._log_btn) button_bar.addStretch() self._install_btn = QPushButton('Install') @@ -395,6 +400,7 @@ def reset(self) -> None: self._status_label.setStyleSheet('') self._install_btn.setEnabled(False) self._toggle_btn.setEnabled(False) + self._log_btn.setEnabled(False) self._view_stack.setCurrentIndex(0) def show_not_found(self, message: str) -> None: @@ -521,17 +527,25 @@ def _show_metadata(self, preview: SetupResults) -> None: # --- View toggle --- def _toggle_view(self) -> None: - """Cycle between overview table, command list, and execution log.""" + """Toggle between overview table and command list.""" current = self._view_stack.currentIndex() - if current == 0: + if current == 1: + self._view_stack.setCurrentIndex(0) + self._toggle_btn.setText('Show Commands') + else: self._view_stack.setCurrentIndex(1) - self._toggle_btn.setText('Show Log') - elif current == 1: - self._view_stack.setCurrentIndex(2) self._toggle_btn.setText('Show Overview') - else: + + def _show_log(self) -> None: + """Switch to the execution log view.""" + current = self._view_stack.currentIndex() + if current == 2: self._view_stack.setCurrentIndex(0) self._toggle_btn.setText('Show Commands') + self._log_btn.setText('Show Log') + else: + self._view_stack.setCurrentIndex(2) + self._log_btn.setText('Hide Log') # --- Table / command list --- @@ -572,6 +586,7 @@ def _populate_table(self, actions: list[SetupAction]) -> None: self._command_list.populate(actions) self._toggle_btn.setEnabled(True) + self._log_btn.setEnabled(True) # --- Install execution --- @@ -583,6 +598,7 @@ def _on_install(self) -> None: self._install_btn.setEnabled(False) self._close_btn.setEnabled(False) self._toggle_btn.setEnabled(False) + self._log_btn.setEnabled(False) self._completed_count = 0 self._cancellation_token = CancellationToken() @@ -671,6 +687,8 @@ def _on_install_finished(self, results: SetupResults) -> None: self._install_btn.setEnabled(False) self._close_btn.setEnabled(True) self._toggle_btn.setEnabled(True) + self._log_btn.setEnabled(True) + self._log_btn.setText('Hide Log') self.install_finished.emit(results) def _on_install_error(self, message: str) -> None: @@ -679,6 +697,8 @@ def _on_install_error(self, message: str) -> None: self._install_btn.setEnabled(True) self._close_btn.setEnabled(True) self._toggle_btn.setEnabled(True) + self._log_btn.setEnabled(True) + self._log_btn.setText('Hide Log') # --------------------------------------------------------------------------- diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 2f476f7..a38ac15 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -1,5 +1,6 @@ """Screen class for the Synodic Client application.""" +import asyncio import logging from collections import OrderedDict from dataclasses import dataclass, field @@ -322,6 +323,7 @@ def __init__( self._porringer = porringer self._config = config self._groups: list[PluginGroupSection] = [] + self._refresh_in_progress = False self._init_ui() def _init_ui(self) -> None: @@ -329,6 +331,12 @@ def _init_ui(self) -> None: outer = QVBoxLayout(self) outer.setContentsMargins(*COMPACT_MARGINS) + # Loading indicator (shown while data is fetched asynchronously) + self._loading_label = QLabel('Loading plugins\u2026') + self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._loading_label.hide() + outer.addWidget(self._loading_label) + # Toolbar toolbar = QHBoxLayout() toolbar.addStretch() @@ -355,45 +363,74 @@ def _init_ui(self) -> None: # --- Public API --- def refresh(self) -> None: - """Rebuild the plugin sections from porringer data, grouped by kind.""" - # Clear existing groups - for group in self._groups: - self._container_layout.removeWidget(group) - group.deleteLater() - self._groups.clear() + """Schedule an asynchronous rebuild of the plugin sections.""" + if self._refresh_in_progress: + return + asyncio.ensure_future(self._async_refresh()) + async def _async_refresh(self) -> None: + """Rebuild the plugin sections from porringer data, grouped by kind.""" + self._refresh_in_progress = True + self._loading_label.show() + + try: + loop = asyncio.get_running_loop() + plugins, packages_map = await loop.run_in_executor(None, self._fetch_plugin_data) + + # Clear existing groups + for group in self._groups: + self._container_layout.removeWidget(group) + group.deleteLater() + self._groups.clear() + + auto_update_map = self._config.plugin_auto_update or {} + + # Bucket plugins by kind, preserving discovery order within each bucket + kind_buckets: OrderedDict[PluginKind, list[PluginInfo]] = OrderedDict() + for plugin in plugins: + kind_buckets.setdefault(plugin.kind, []).append(plugin) + + for kind, bucket in kind_buckets.items(): + group = PluginGroupSection(kind, parent=self._container) + + for plugin in bucket: + packages = packages_map.get(plugin.name, []) + section = self._build_plugin_section( + plugin, + packages, + auto_update_map, + parent=group, + ) + section.auto_update_toggled.connect(self._on_auto_update_toggled) + section.update_requested.connect(self.plugin_update_requested.emit) + group.add_section(section) + + # Insert before the trailing stretch + idx = self._container_layout.count() - 1 + self._container_layout.insertWidget(idx, group) + self._groups.append(group) + except Exception: + logger.exception('Failed to refresh plugins') + finally: + self._loading_label.hide() + self._refresh_in_progress = False + + def _fetch_plugin_data( + self, + ) -> tuple[list[PluginInfo], dict[str, list[tuple[str, str]]]]: + """Fetch plugin data from porringer (runs in thread-pool executor).""" plugins = self._porringer.plugin.list() directories = self._porringer.cache.list_directories() - auto_update_map = self._config.plugin_auto_update or {} - - # Bucket plugins by kind, preserving discovery order within each bucket - kind_buckets: OrderedDict[PluginKind, list[PluginInfo]] = OrderedDict() + packages_map: dict[str, list[tuple[str, str]]] = {} for plugin in plugins: - kind_buckets.setdefault(plugin.kind, []).append(plugin) - - for kind, bucket in kind_buckets.items(): - group = PluginGroupSection(kind, parent=self._container) - - for plugin in bucket: - section = self._build_plugin_section( - plugin, - directories, - auto_update_map, - parent=group, - ) - section.auto_update_toggled.connect(self._on_auto_update_toggled) - section.update_requested.connect(self.plugin_update_requested.emit) - group.add_section(section) - - # Insert before the trailing stretch - idx = self._container_layout.count() - 1 - self._container_layout.insertWidget(idx, group) - self._groups.append(group) + if plugin.kind in _UPDATABLE_KINDS: + packages_map[plugin.name] = self._gather_packages(plugin.name, directories) + return plugins, packages_map def _build_plugin_section( self, plugin: PluginInfo, - directories: list[ManifestDirectory], + packages: list[tuple[str, str]], auto_update_map: dict[str, bool], *, parent: QWidget | None = None, @@ -404,8 +441,6 @@ def _build_plugin_section( show_controls = plugin.kind in _UPDATABLE_KINDS auto_update = auto_update_map.get(plugin.name, True) - packages = self._gather_packages(plugin.name, directories) if show_controls else [] - return PluginSection( PluginSectionData( name=plugin.name, @@ -484,6 +519,7 @@ def __init__(self, porringer: API, parent: QWidget | None = None) -> None: super().__init__(parent) self._porringer = porringer self._runner: QThread | None = None + self._refresh_in_progress = False self._init_ui() def _init_ui(self) -> None: @@ -491,6 +527,12 @@ def _init_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(*COMPACT_MARGINS) + # Loading indicator (shown while data is fetched asynchronously) + self._loading_label = QLabel('Loading projects\u2026') + self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._loading_label.hide() + layout.addWidget(self._loading_label) + # --- Project directory selector --- selector_row = QHBoxLayout() selector_row.setContentsMargins(0, 0, 0, 8) @@ -523,42 +565,68 @@ def _init_ui(self) -> None: # --- Public API --- def refresh(self) -> None: + """Schedule an asynchronous refresh of the cached directories.""" + if self._refresh_in_progress: + return + asyncio.ensure_future(self._async_refresh()) + + async def _async_refresh(self) -> None: """Refresh the cached directories combo box from porringer cache.""" - self._combo.blockSignals(True) - current_text = self._combo.currentText() - self._combo.clear() - - results: list[DirectoryValidationResult] = self._porringer.cache.validate_directories(check_manifest=True) - for result in results: - directory = result.directory - display = str(directory.path) - tooltip = directory.name or '' - - idx = self._combo.count() - self._combo.addItem(display) - self._combo.setItemData(idx, tooltip, Qt.ItemDataRole.ToolTipRole) - self._combo.setItemData(idx, str(directory.path), Qt.ItemDataRole.UserRole) - - if not result.exists: - # Grey out entries whose path no longer exists on disk - self._grey_out_item(idx, tooltip, 'Path not found') - elif result.has_manifest is False: - # Dim entries where the path exists but no manifest is found - self._grey_out_item(idx, tooltip, 'No manifest found') - - # Restore previous selection if it still exists - idx = self._combo.findText(current_text) - if idx >= 0: - self._combo.setCurrentIndex(idx) - elif self._combo.count() > 0: - self._combo.setCurrentIndex(0) + self._refresh_in_progress = True + self._loading_label.show() + self._combo.setEnabled(False) + self._browse_btn.setEnabled(False) + self._remove_btn.setEnabled(False) - self._combo.blockSignals(False) - self._update_remove_btn() + try: + loop = asyncio.get_running_loop() + results: list[DirectoryValidationResult] = await loop.run_in_executor( + None, + lambda: self._porringer.cache.validate_directories(check_manifest=True), + ) - # Trigger preview for the current selection - if self._combo.currentText(): - self._load_preview() + self._combo.blockSignals(True) + current_text = self._combo.currentText() + self._combo.clear() + + for result in results: + directory = result.directory + display = str(directory.path) + tooltip = directory.name or '' + + idx = self._combo.count() + self._combo.addItem(display) + self._combo.setItemData(idx, tooltip, Qt.ItemDataRole.ToolTipRole) + self._combo.setItemData(idx, str(directory.path), Qt.ItemDataRole.UserRole) + + if not result.exists: + # Grey out entries whose path no longer exists on disk + self._grey_out_item(idx, tooltip, 'Path not found') + elif result.has_manifest is False: + # Dim entries where the path exists but no manifest is found + self._grey_out_item(idx, tooltip, 'No manifest found') + + # Restore previous selection if it still exists + idx = self._combo.findText(current_text) + if idx >= 0: + self._combo.setCurrentIndex(idx) + elif self._combo.count() > 0: + self._combo.setCurrentIndex(0) + + self._combo.blockSignals(False) + self._update_remove_btn() + + # Trigger preview for the current selection + if self._combo.currentText(): + self._load_preview() + except Exception: + logger.exception('Failed to refresh projects') + finally: + self._loading_label.hide() + self._combo.setEnabled(True) + self._browse_btn.setEnabled(True) + self._update_remove_btn() + self._refresh_in_progress = False def _grey_out_item(self, idx: int, tooltip: str, reason: str) -> None: """Grey out a combo box item and append a reason to its tooltip.""" @@ -720,14 +788,14 @@ def show(self) -> None: self.setCentralWidget(self._tabs) - # Refresh both views + # Paint the window immediately, then refresh data asynchronously + super().show() + if self._plugins_view is not None: self._plugins_view.refresh() if self._projects_view is not None: self._projects_view.refresh() - super().show() - class Screen: """Screen class for the Synodic Client application.""" diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index e910901..cd454f1 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -507,9 +507,14 @@ def _on_tool_update(self) -> None: return logger.info('Starting periodic tool update check') + asyncio.ensure_future(self._do_tool_update(porringer)) + async def _do_tool_update(self, porringer: API) -> None: + """Resolve enabled plugins off-thread, then start the update worker.""" + loop = asyncio.get_running_loop() config = self._resolve_config() - all_names = [p.name for p in porringer.plugin.list() if p.installed] + all_plugins = await loop.run_in_executor(None, porringer.plugin.list) + all_names = [p.name for p in all_plugins if p.installed] enabled = resolve_enabled_plugins(config, all_names) worker = ToolUpdateWorker(porringer, plugins=enabled) From 4a13532df1d1f8594416b058ee4a7d8b6f8a2b66 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 19:11:46 -0800 Subject: [PATCH 06/12] Remove Stacked Log View --- synodic_client/application/screen/install.py | 39 +++++--------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 0403f03..e9aac0a 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -309,7 +309,7 @@ def _init_ui(self) -> None: self._status_label = QLabel() layout.addWidget(self._status_label) - # --- View stack (table / command list / execution log) --- + # --- View stack (table / command list) --- self._view_stack = QStackedWidget() self._table = self._init_actions_table() @@ -318,11 +318,13 @@ def _init_ui(self) -> None: self._command_list = CommandListWidget() self._view_stack.addWidget(self._command_list) # page 1 - self._log_panel = ExecutionLogPanel() - self._view_stack.addWidget(self._log_panel) # page 2 - layout.addWidget(self._view_stack) + # Execution log (always visible below the table once install starts) + self._log_panel = ExecutionLogPanel() + self._log_panel.hide() + layout.addWidget(self._log_panel) + # Button bar layout.addLayout(self._init_button_bar()) @@ -349,13 +351,8 @@ def _init_button_bar(self) -> QHBoxLayout: self._toggle_btn.setEnabled(False) self._toggle_btn.clicked.connect(self._toggle_view) - self._log_btn = QPushButton('Show Log') - self._log_btn.setEnabled(False) - self._log_btn.clicked.connect(self._show_log) - button_bar = QHBoxLayout() button_bar.addWidget(self._toggle_btn) - button_bar.addWidget(self._log_btn) button_bar.addStretch() self._install_btn = QPushButton('Install') @@ -393,6 +390,7 @@ def reset(self) -> None: self._table.setRowCount(0) self._log_panel.clear() + self._log_panel.hide() self._name_label.hide() self._description_label.hide() self._meta_label.hide() @@ -400,7 +398,6 @@ def reset(self) -> None: self._status_label.setStyleSheet('') self._install_btn.setEnabled(False) self._toggle_btn.setEnabled(False) - self._log_btn.setEnabled(False) self._view_stack.setCurrentIndex(0) def show_not_found(self, message: str) -> None: @@ -536,17 +533,6 @@ def _toggle_view(self) -> None: self._view_stack.setCurrentIndex(1) self._toggle_btn.setText('Show Overview') - def _show_log(self) -> None: - """Switch to the execution log view.""" - current = self._view_stack.currentIndex() - if current == 2: - self._view_stack.setCurrentIndex(0) - self._toggle_btn.setText('Show Commands') - self._log_btn.setText('Show Log') - else: - self._view_stack.setCurrentIndex(2) - self._log_btn.setText('Hide Log') - # --- Table / command list --- def _copy_table_selection(self) -> None: @@ -586,7 +572,6 @@ def _populate_table(self, actions: list[SetupAction]) -> None: self._command_list.populate(actions) self._toggle_btn.setEnabled(True) - self._log_btn.setEnabled(True) # --- Install execution --- @@ -598,14 +583,14 @@ def _on_install(self) -> None: self._install_btn.setEnabled(False) self._close_btn.setEnabled(False) self._toggle_btn.setEnabled(False) - self._log_btn.setEnabled(False) self._completed_count = 0 self._cancellation_token = CancellationToken() - # Switch to the execution log panel view + # Show the execution log panel below the table self._log_panel.clear() - self._view_stack.setCurrentIndex(2) + self._log_panel.show() + self._view_stack.setCurrentIndex(0) self._status_label.setText('Installing…') # Worker thread @@ -687,8 +672,6 @@ def _on_install_finished(self, results: SetupResults) -> None: self._install_btn.setEnabled(False) self._close_btn.setEnabled(True) self._toggle_btn.setEnabled(True) - self._log_btn.setEnabled(True) - self._log_btn.setText('Hide Log') self.install_finished.emit(results) def _on_install_error(self, message: str) -> None: @@ -697,8 +680,6 @@ def _on_install_error(self, message: str) -> None: self._install_btn.setEnabled(True) self._close_btn.setEnabled(True) self._toggle_btn.setEnabled(True) - self._log_btn.setEnabled(True) - self._log_btn.setText('Hide Log') # --------------------------------------------------------------------------- From 44a78c78bbb1806e1e6eec40706bc09f9054496f Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Feb 2026 15:21:56 -0800 Subject: [PATCH 07/12] Update Chore --- pdm.lock | 28 +++++++++---------- pyproject.toml | 19 +++++++++---- synodic_client/application/screen/__init__.py | 3 +- synodic_client/application/screen/install.py | 4 +-- synodic_client/application/screen/screen.py | 7 +++-- synodic_client/application/screen/tray.py | 2 +- tests/unit/qt/test_install_preview.py | 2 +- tests/unit/qt/test_log_panel.py | 2 +- tests/unit/test_install_preview.py | 2 +- 9 files changed, 40 insertions(+), 29 deletions(-) diff --git a/pdm.lock b/pdm.lock index 8ab5639..d40acd7 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:8a071dba1c80d524a2e083f01f624d33bf0985e0904ba23f20b4261b596de3e9" +content_hash = "sha256:4749e771203dc3d21f7e9a064323d012a7a04484a0588294b2e1ec4885cfbbfc" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -272,7 +272,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev23" +version = "0.2.1.dev29" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -285,8 +285,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev23-py3-none-any.whl", hash = "sha256:8c38892c95a63edca47843b015d7faac4416201586482d1c6888aa50a8a1e678"}, - {file = "porringer-0.2.1.dev23.tar.gz", hash = "sha256:b98cf5938f861d7400d60139b3dd64272ea158f83d2627700e6503f4e973d6c9"}, + {file = "porringer-0.2.1.dev29-py3-none-any.whl", hash = "sha256:ea48558552a8409c0dbc9048052c7d64cf6a6c157ed39cc889f89a07f41b3339"}, + {file = "porringer-0.2.1.dev29.tar.gz", hash = "sha256:a43362eec7696d30421ac2192afbaa384956175fd586a7200a39634bd43679b5"}, ] [[package]] @@ -407,20 +407,20 @@ files = [ [[package]] name = "pyrefly" -version = "0.52.0" +version = "0.53.0" requires_python = ">=3.8" summary = "A fast type checker and language server for Python with powerful IDE features" groups = ["lint"] files = [ - {file = "pyrefly-0.52.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:90d7bf2fb812ee3a920a962da2aa2387e2f4109c62604e5be1a736046a3260c7"}, - {file = "pyrefly-0.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:848764fdbc474fd36412d7ccf230d13a12ab3b2c28968124d9e9d51df79b7b8e"}, - {file = "pyrefly-0.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43b712830df1247798fb79f478a236b0ffbe5983bdde5eb2f5b99a9411e09f35"}, - {file = "pyrefly-0.52.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa4130c460ad7c8d7efcff9017b7bc74c71736c5959ebfc2b7e405c2ce07d5d"}, - {file = "pyrefly-0.52.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3297751b1b13ecb582af48c8798e0b652c41c33a7e4ed72676164b29561655f6"}, - {file = "pyrefly-0.52.0-py3-none-win32.whl", hash = "sha256:d24ed11ef5eab93625df0bb4e67f7f946208b2b0ed4359b78f69cabbc6f78e3d"}, - {file = "pyrefly-0.52.0-py3-none-win_amd64.whl", hash = "sha256:0e5bee368fbdce6430b7672304bc4e36f11bc3b72ad067cbfde934d380701a3b"}, - {file = "pyrefly-0.52.0-py3-none-win_arm64.whl", hash = "sha256:8cabc07740e90c0baea12a1e7c48d6422130a19331033e8d9a16dd63e7e90db0"}, - {file = "pyrefly-0.52.0.tar.gz", hash = "sha256:abe022b68e67a2fd9adad4f8fe2deced2a786df32601b0eecbb00b40ea1f3b93"}, + {file = "pyrefly-0.53.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79d7fb35dff0988b3943c26f74cc752fad54357a0bc33f7db665f02d1c9a5bcc"}, + {file = "pyrefly-0.53.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e1d98b1e86f3c38db44860695b7986e731238e1b19c3cad7a3050476a8f6f84d"}, + {file = "pyrefly-0.53.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb9f2440f7e0c70aa18400f44aed994c326a1ab00f2b01cf7253a63fc62d7c6b"}, + {file = "pyrefly-0.53.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4e826a5ff2aba2c41e02e6094580751c512db7916e60728cd8612dbcf178d7b"}, + {file = "pyrefly-0.53.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4c69410c7a96b417a390a0e3d340f4370fdab02f9d3eaa222c4bd42e3ce24a"}, + {file = "pyrefly-0.53.0-py3-none-win32.whl", hash = "sha256:00687bb6be6e366b8c0137a89625da40ced3b9212a65e561857ff888fe88e6e8"}, + {file = "pyrefly-0.53.0-py3-none-win_amd64.whl", hash = "sha256:e0512e6f7af44ae01cfddba096ff7740e15cbd1d0497a3d34a7afcb504e2b300"}, + {file = "pyrefly-0.53.0-py3-none-win_arm64.whl", hash = "sha256:5066e2102769683749102421b8b8667cae26abe1827617f04e8df4317e0a94af"}, + {file = "pyrefly-0.53.0.tar.gz", hash = "sha256:aef117e8abb9aa4cf17fc64fbf450d825d3c65fc9de3c02ed20129ebdd57aa74"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 75eee13..963c818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev23", - "qasync>=0.27.1", + "porringer>=0.2.1.dev29", + "qasync>=0.28.0", "velopack>=0.0.1369.dev7516", "typer>=0.24.0", ] @@ -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.1", "pyrefly>=0.52.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.1", + "pyrefly>=0.53.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/application/screen/__init__.py b/synodic_client/application/screen/__init__.py index 6d80e7b..3ef623e 100644 --- a/synodic_client/application/screen/__init__.py +++ b/synodic_client/application/screen/__init__.py @@ -6,7 +6,8 @@ from __future__ import annotations -from porringer.schema import PluginKind, SkipReason +from porringer.schema import SkipReason +from porringer.schema.plugin import PluginKind ACTION_KIND_LABELS: dict[PluginKind | None, str] = { PluginKind.PACKAGE: 'Package', diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index e9aac0a..689e153 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -23,7 +23,6 @@ from porringer.schema import ( CancellationToken, DownloadParameters, - PluginKind, ProgressEventKind, SetupAction, SetupActionResult, @@ -31,6 +30,7 @@ SetupResults, SubActionProgress, ) +from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtWidgets import ( @@ -862,7 +862,7 @@ def run(self) -> None: dest = Path(temp_dir) / 'porringer.json' params = DownloadParameters(url=self._url, destination=dest, timeout=3) - result = self._porringer.sync.download(params) + result = API.download(params) if not result.success: _safe_rmtree(temp_dir) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index a38ac15..79f4ede 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -7,7 +7,8 @@ from pathlib import Path from porringer.api import API -from porringer.schema import DirectoryValidationResult, ManifestDirectory, PluginInfo, PluginKind, SetupResults +from porringer.schema import DirectoryValidationResult, ManifestDirectory, PluginInfo, SetupResults +from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QStandardItem from PySide6.QtWidgets import ( @@ -395,7 +396,7 @@ async def _async_refresh(self) -> None: for plugin in bucket: packages = packages_map.get(plugin.name, []) - section = self._build_plugin_section( + section = PluginsView._build_plugin_section( plugin, packages, auto_update_map, @@ -427,8 +428,8 @@ def _fetch_plugin_data( packages_map[plugin.name] = self._gather_packages(plugin.name, directories) return plugins, packages_map + @staticmethod def _build_plugin_section( - self, plugin: PluginInfo, packages: list[tuple[str, str]], auto_update_map: dict[str, bool], diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index cd454f1..56f0406 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -513,7 +513,7 @@ async def _do_tool_update(self, porringer: API) -> None: """Resolve enabled plugins off-thread, then start the update worker.""" loop = asyncio.get_running_loop() config = self._resolve_config() - all_plugins = await loop.run_in_executor(None, porringer.plugin.list) + all_plugins = await loop.run_in_executor(None, lambda: porringer.plugin.list()) # noqa: PLW0108 all_names = [p.name for p in all_plugins if p.installed] enabled = resolve_enabled_plugins(config, all_names) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 7973ac4..95c01b6 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -9,13 +9,13 @@ from porringer.schema import ( CancellationToken, DownloadResult, - PluginKind, ProgressEvent, ProgressEventKind, SetupActionResult, SetupResults, SkipReason, ) +from porringer.schema.plugin import PluginKind from synodic_client.application.screen import ( ACTION_KIND_LABELS, diff --git a/tests/unit/qt/test_log_panel.py b/tests/unit/qt/test_log_panel.py index 31da817..001aea0 100644 --- a/tests/unit/qt/test_log_panel.py +++ b/tests/unit/qt/test_log_panel.py @@ -8,7 +8,6 @@ from porringer.schema import ( CancellationToken, - PluginKind, ProgressEvent, ProgressEventKind, SetupAction, @@ -17,6 +16,7 @@ SkipReason, SubActionProgress, ) +from porringer.schema.plugin import PluginKind # PySide6 widgets require a QApplication; create one once for the module. from PySide6.QtWidgets import QApplication diff --git a/tests/unit/test_install_preview.py b/tests/unit/test_install_preview.py index 0b0b95d..c91f2c0 100644 --- a/tests/unit/test_install_preview.py +++ b/tests/unit/test_install_preview.py @@ -15,13 +15,13 @@ from porringer.schema import ( CancellationToken, DownloadResult, - PluginKind, ProgressEvent, ProgressEventKind, SetupActionResult, SetupResults, SkipReason, ) +from porringer.schema.plugin import PluginKind from synodic_client.application.screen import ( ACTION_KIND_LABELS, From 699c80e2e514501e5dab0b528a856d5ee6647d22 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Feb 2026 15:23:03 -0800 Subject: [PATCH 08/12] Update pyproject.toml --- pyproject.toml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 963c818..68c9663 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,18 +25,9 @@ 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.1", - "pyrefly>=0.53.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.1", "pyrefly>=0.53.0"] +test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] [project.scripts] synodic-c = "synodic_client.cli:app" From e1d422fd8b42c48868c32e5ec5ddfca83a2dc8e1 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Feb 2026 10:08:58 -0800 Subject: [PATCH 09/12] Update Plugin API Usage --- pdm.lock | 8 +- pyproject.toml | 17 +++- synodic_client/application/screen/install.py | 70 ++++++++++++-- synodic_client/application/screen/screen.py | 25 +++-- tests/unit/qt/test_install_preview.py | 98 +++++++++++++++++--- tests/unit/test_install_preview.py | 97 ++++++++++++++++--- 6 files changed, 262 insertions(+), 53 deletions(-) diff --git a/pdm.lock b/pdm.lock index d40acd7..68c545a 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:4749e771203dc3d21f7e9a064323d012a7a04484a0588294b2e1ec4885cfbbfc" +content_hash = "sha256:20480c81a89571a4be78600a9497f9421eab4a51c003760a9ef02a62e5ad9cbd" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -272,7 +272,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev29" +version = "0.2.1.dev31" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -285,8 +285,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev29-py3-none-any.whl", hash = "sha256:ea48558552a8409c0dbc9048052c7d64cf6a6c157ed39cc889f89a07f41b3339"}, - {file = "porringer-0.2.1.dev29.tar.gz", hash = "sha256:a43362eec7696d30421ac2192afbaa384956175fd586a7200a39634bd43679b5"}, + {file = "porringer-0.2.1.dev31-py3-none-any.whl", hash = "sha256:54f7fa16b0f082ee213235299a1c1f0bc7bfd7949e6133ce47a88afb14287f6f"}, + {file = "porringer-0.2.1.dev31.tar.gz", hash = "sha256:135a1b90d72faa6b0a9c75b28574908821c22b82587410cfcfac3ce1fa86fff0"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 68c9663..99d89fc 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.dev29", + "porringer>=0.2.1.dev31", "qasync>=0.28.0", "velopack>=0.0.1369.dev7516", "typer>=0.24.0", @@ -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.1", "pyrefly>=0.53.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.1", + "pyrefly>=0.53.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/application/screen/install.py b/synodic_client/application/screen/install.py index 689e153..8f9dd7c 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -279,6 +279,7 @@ def __init__(self, porringer: API, parent: QWidget | None = None, *, show_close: self._completed_count = 0 self._action_statuses: list[str] = [] self._action_to_table_row: dict[int, int] = {} + self._plugin_installed: dict[str, bool] = {} self._init_ui() @@ -387,6 +388,7 @@ def reset(self) -> None: self._completed_count = 0 self._action_statuses = [] self._action_to_table_row = {} + self._plugin_installed = {} self._table.setRowCount(0) self._log_panel.clear() @@ -414,6 +416,18 @@ def show_not_found(self, message: str) -> None: # --- Preview callbacks (connect to PreviewWorker signals) --- + def on_plugins_queried(self, mapping: dict[str, bool]) -> None: + """Store plugin presence data for annotating the preview table. + + Called before :meth:`on_preview_ready` so that + :meth:`_populate_table` can flag actions whose installer plugin + is not installed. + + Args: + mapping: Plugin name → installed status. + """ + self._plugin_installed = mapping + def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None: """Handle a successful preview. @@ -465,7 +479,8 @@ def on_preview_finished(self) -> None: if not self._action_statuses: return - # Resolve any still-pending statuses as 'Needed' + # Resolve any still-pending statuses as 'Needed', but leave + # 'Not installed' entries untouched — they indicate a missing plugin. for i, status in enumerate(self._action_statuses): if status == 'Checking…': self._action_statuses[i] = 'Needed' @@ -480,15 +495,30 @@ def on_preview_finished(self) -> None: table_statuses = [self._action_statuses[i] for i in self._action_to_table_row] total = len(table_statuses) needed = sum(1 for s in table_statuses if s == 'Needed') - satisfied = total - needed - - if needed == 0: + unavailable = sum(1 for s in table_statuses if s == 'Not installed') + satisfied = total - needed - unavailable + + parts: list[str] = [] + if needed: + parts.append(f'{needed} needed') + if satisfied: + parts.append(f'{satisfied} already satisfied') + if unavailable: + parts.append(f'{unavailable} unavailable (plugin not installed)') + + if needed == 0 and unavailable == 0: self._status_label.setText(f'{total} action(s) — all already satisfied.') self._install_btn.setEnabled(False) else: - self._status_label.setText(f'{total} action(s): {needed} needed, {satisfied} already satisfied.') - - logger.info('Preview complete: %d total, %d needed, %d satisfied', total, needed, satisfied) + self._status_label.setText(f'{total} action(s): {", ".join(parts)}.') + + logger.info( + 'Preview complete: %d total, %d needed, %d satisfied, %d unavailable', + total, + needed, + satisfied, + unavailable, + ) def on_preview_error(self, message: str) -> None: """Handle a preview error.""" @@ -555,6 +585,10 @@ def _populate_table(self, actions: list[SetupAction]) -> None: because they cannot be dry-run checked — they always appear as *Needed* which is misleading. They remain visible in the command-list view and are still executed during install. + + Actions whose installer plugin is not installed are immediately + flagged as *Not installed* so the user knows the plugin must be + set up before the action can succeed. """ self._action_to_table_row = {} table_actions = [(i, a) for i, a in enumerate(actions) if a.kind is not None] @@ -566,7 +600,19 @@ def _populate_table(self, actions: list[SetupAction]) -> None: self._table.setItem(table_row, 2, QTableWidgetItem(str(action.package) if action.package else '')) self._table.setItem(table_row, 3, QTableWidgetItem(action.package_description or action.description)) - status_item = QTableWidgetItem('Checking…') + # Check whether the installer plugin is present on the system. + installer_missing = ( + action.installer is not None + and action.installer in self._plugin_installed + and not self._plugin_installed[action.installer] + ) + + if installer_missing: + status_item = QTableWidgetItem('Not installed') + self._action_statuses[action_idx] = 'Not installed' + else: + status_item = QTableWidgetItem('Checking…') + status_item.setForeground(self.palette().placeholderText()) self._table.setItem(table_row, 4, status_item) @@ -803,6 +849,7 @@ def start(self) -> None: preview_worker = PreviewWorker(self._porringer, self._manifest_url, project_directory=self._project_directory) + preview_worker.plugins_queried.connect(self._preview_widget.on_plugins_queried) preview_worker.preview_ready.connect(self._on_preview_ready) preview_worker.action_checked.connect(self._preview_widget.on_action_checked) preview_worker.finished.connect(self._preview_widget.on_preview_finished) @@ -835,6 +882,7 @@ class PreviewWorker(QThread): preview_ready = Signal(object, str, str) # (SetupResults, manifest_path, temp_dir_path) action_checked = Signal(int, object) # (row_index, SetupActionResult) + plugins_queried = Signal(object) # dict[str, bool] — plugin name → installed finished = Signal() error = Signal(str) @@ -884,6 +932,12 @@ def run(self) -> None: async def _dry_run(self, manifest_path: Path, temp_dir: str) -> None: """Stream dry-run events, emitting preview_ready and action_checked signals.""" + # Query plugin presence before the dry-run so the widget can + # annotate actions whose installer is not available. + plugins = self._porringer.plugin.list() + plugin_installed = {p.name: p.installed for p in plugins} + self.plugins_queried.emit(plugin_installed) + params = SetupParameters( paths=[manifest_path], dry_run=True, diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 79f4ede..63c61cd 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -64,7 +64,7 @@ class PluginSectionData: packages: list[tuple[str, str]] = field(default_factory=list) auto_update: bool = True show_controls: bool = False - found: bool = True + installed: bool = True class PluginSection(QWidget): @@ -96,7 +96,7 @@ def __init__(self, data: PluginSectionData, parent: QWidget | None = None) -> No data.version, data.auto_update, data.show_controls, - found=data.found, + installed=data.installed, ) layout.addWidget(self._header) @@ -113,7 +113,7 @@ def _build_header( auto_update: bool, show_controls: bool, *, - found: bool = True, + installed: bool = True, ) -> QWidget: """Construct the clickable header row.""" header = QWidget() @@ -158,12 +158,12 @@ def _build_header( ) header_layout.addWidget(update_btn) - if not found: + if not installed: self._toggle_btn.setEnabled(False) self._toggle_btn.setChecked(False) - self._toggle_btn.setToolTip('Plugin not found \u2014 cannot auto-update') + self._toggle_btn.setToolTip('Not installed \u2014 cannot auto-update') update_btn.setEnabled(False) - update_btn.setToolTip('Plugin not found \u2014 cannot update') + update_btn.setToolTip('Not installed \u2014 cannot update') return header @@ -437,8 +437,14 @@ def _build_plugin_section( parent: QWidget | None = None, ) -> PluginSection: """Create a :class:`PluginSection` for a single plugin.""" - found = plugin.installed - version = str(plugin.tool_version) if plugin.tool_version is not None else 'Installed' if found else 'Not found' + installed = plugin.installed + version = ( + str(plugin.tool_version) + if plugin.tool_version is not None + else 'Installed' + if installed + else 'Not installed' + ) show_controls = plugin.kind in _UPDATABLE_KINDS auto_update = auto_update_map.get(plugin.name, True) @@ -449,7 +455,7 @@ def _build_plugin_section( packages=packages, auto_update=auto_update, show_controls=show_controls, - found=found, + installed=installed, ), parent=parent, ) @@ -717,6 +723,7 @@ def _load_preview(self) -> None: ) preview_worker.preview_ready.connect(self._on_preview_ready) preview_worker.action_checked.connect(self._preview.on_action_checked) + preview_worker.plugins_queried.connect(self._preview.on_plugins_queried) preview_worker.finished.connect(self._preview.on_preview_finished) preview_worker.error.connect(self._on_preview_error) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 95c01b6..c0c170a 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -6,9 +6,11 @@ from typing import Any from unittest.mock import MagicMock +import pytest from porringer.schema import ( CancellationToken, DownloadResult, + PluginInfo, ProgressEvent, ProgressEventKind, SetupActionResult, @@ -30,6 +32,8 @@ ) from synodic_client.application.uri import parse_uri +_DOWNLOAD_PATCH = 'synodic_client.application.screen.install.API.download' + class TestParseUriInstall: """Tests for parsing install URIs.""" @@ -302,15 +306,18 @@ class TestPreviewWorker: """Tests for PreviewWorker download and preview flow.""" @staticmethod - def test_emits_error_on_download_failure() -> None: + def test_emits_error_on_download_failure(monkeypatch: pytest.MonkeyPatch) -> None: """Verify PreviewWorker emits error when download fails.""" porringer = MagicMock() - porringer.sync.download.return_value = DownloadResult( - success=False, - path=None, - verified=False, - size=0, - message='Network error', + monkeypatch.setattr( + _DOWNLOAD_PATCH, + lambda params, progress_callback=None: DownloadResult( + success=False, + path=None, + verified=False, + size=0, + message='Network error', + ), ) worker = PreviewWorker(porringer, 'https://example.com/bad.json') @@ -323,16 +330,24 @@ def test_emits_error_on_download_failure() -> None: assert 'Network error' in errors[0] @staticmethod - def test_emits_preview_ready_on_success() -> None: + def test_emits_preview_ready_on_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Verify PreviewWorker emits preview_ready with SetupResults.""" porringer = MagicMock() - porringer.sync.download.return_value = DownloadResult( - success=True, - path=Path('/tmp/test/porringer.json'), - verified=True, - size=100, - message='OK', + + dest = tmp_path / 'porringer.json' + dest.write_text('{}') + + monkeypatch.setattr( + _DOWNLOAD_PATCH, + lambda params, progress_callback=None: DownloadResult( + success=True, + path=dest, + verified=True, + size=100, + message='OK', + ), ) + expected = SetupResults(actions=[]) manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=expected) @@ -483,3 +498,58 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: assert len(errors) == 1 assert 'dry-run boom' in errors[0] assert len(finished_count) == 0 + + @staticmethod + def test_emits_plugins_queried(tmp_path: Path) -> None: + """Verify plugins_queried is emitted with plugin presence mapping.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + porringer.plugin.list.return_value = [ + PluginInfo(name='pip', kind=PluginKind.PACKAGE, version=MagicMock(), installed=True, tool_version=None), + PluginInfo(name='uv', kind=PluginKind.PACKAGE, version=MagicMock(), installed=False, tool_version=None), + ] + + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest)) + + plugin_data: list[dict[str, bool]] = [] + worker.plugins_queried.connect(plugin_data.append) + worker.run() + + assert len(plugin_data) == 1 + assert plugin_data[0] == {'pip': True, 'uv': False} + + @staticmethod + def test_plugins_queried_emitted_before_preview_ready(tmp_path: Path) -> None: + """Verify plugins_queried fires before preview_ready.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + porringer.plugin.list.return_value = [] + + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest)) + + order: list[str] = [] + worker.plugins_queried.connect(lambda _: order.append('plugins')) + worker.preview_ready.connect(lambda *_: order.append('preview')) + worker.run() + + assert order == ['plugins', 'preview'] diff --git a/tests/unit/test_install_preview.py b/tests/unit/test_install_preview.py index c91f2c0..b380a9f 100644 --- a/tests/unit/test_install_preview.py +++ b/tests/unit/test_install_preview.py @@ -15,6 +15,7 @@ from porringer.schema import ( CancellationToken, DownloadResult, + PluginInfo, ProgressEvent, ProgressEventKind, SetupActionResult, @@ -36,6 +37,8 @@ ) from synodic_client.application.uri import parse_uri +_DOWNLOAD_PATCH = 'synodic_client.application.screen.install.API.download' + class TestParseUriInstall: """Tests for parsing install URIs.""" @@ -308,15 +311,18 @@ class TestPreviewWorker: """Tests for PreviewWorker download and preview flow.""" @staticmethod - def test_emits_error_on_download_failure() -> None: + def test_emits_error_on_download_failure(monkeypatch: pytest.MonkeyPatch) -> None: """Verify PreviewWorker emits error when download fails.""" porringer = MagicMock() - porringer.sync.download.return_value = DownloadResult( - success=False, - path=None, - verified=False, - size=0, - message='Network error', + monkeypatch.setattr( + _DOWNLOAD_PATCH, + lambda params, progress_callback=None: DownloadResult( + success=False, + path=None, + verified=False, + size=0, + message='Network error', + ), ) worker = PreviewWorker(porringer, 'https://example.com/bad.json') @@ -329,16 +335,24 @@ def test_emits_error_on_download_failure() -> None: assert 'Network error' in errors[0] @staticmethod - def test_emits_preview_ready_on_success() -> None: + def test_emits_preview_ready_on_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Verify PreviewWorker emits preview_ready with SetupResults.""" porringer = MagicMock() - porringer.sync.download.return_value = DownloadResult( - success=True, - path=Path('/tmp/test/porringer.json'), - verified=True, - size=100, - message='OK', + + dest = tmp_path / 'porringer.json' + dest.write_text('{}') + + monkeypatch.setattr( + _DOWNLOAD_PATCH, + lambda params, progress_callback=None: DownloadResult( + success=True, + path=dest, + verified=True, + size=100, + message='OK', + ), ) + expected = SetupResults(actions=[]) manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=expected) @@ -489,3 +503,58 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: assert len(errors) == 1 assert 'dry-run boom' in errors[0] assert len(finished_count) == 0 + + @staticmethod + def test_emits_plugins_queried(tmp_path: Path) -> None: + """Verify plugins_queried is emitted with plugin presence mapping.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + porringer.plugin.list.return_value = [ + PluginInfo(name='pip', kind=PluginKind.PACKAGE, version=MagicMock(), installed=True, tool_version=None), + PluginInfo(name='uv', kind=PluginKind.PACKAGE, version=MagicMock(), installed=False, tool_version=None), + ] + + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest)) + + plugin_data: list[dict[str, bool]] = [] + worker.plugins_queried.connect(plugin_data.append) + worker.run() + + assert len(plugin_data) == 1 + assert plugin_data[0] == {'pip': True, 'uv': False} + + @staticmethod + def test_plugins_queried_emitted_before_preview_ready(tmp_path: Path) -> None: + """Verify plugins_queried fires before preview_ready.""" + manifest = tmp_path / 'porringer.json' + manifest.write_text('{}') + + porringer = MagicMock() + porringer.plugin.list.return_value = [] + + preview = SetupResults(actions=[]) + manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) + + async def mock_stream(*args: Any, **kwargs: Any) -> Any: + yield manifest_event + + porringer.sync.execute_stream = mock_stream + + worker = PreviewWorker(porringer, str(manifest)) + + order: list[str] = [] + worker.plugins_queried.connect(lambda _: order.append('plugins')) + worker.preview_ready.connect(lambda *_: order.append('preview')) + worker.run() + + assert order == ['plugins', 'preview'] From 5d0f7a2d03d22860977915638b04925f37530da4 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Feb 2026 10:10:49 -0800 Subject: [PATCH 10/12] Test Removal/Cleanup --- tests/unit/qt/test_install_preview.py | 33 -- tests/unit/test_install_preview.py | 560 -------------------------- tests/unit/test_updater.py | 103 ----- 3 files changed, 696 deletions(-) delete mode 100644 tests/unit/test_install_preview.py diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index c0c170a..ec2de62 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -30,43 +30,10 @@ format_cli_command, resolve_local_path, ) -from synodic_client.application.uri import parse_uri _DOWNLOAD_PATCH = 'synodic_client.application.screen.install.API.download' -class TestParseUriInstall: - """Tests for parsing install URIs.""" - - @staticmethod - def test_install_action_parsed() -> None: - """Verify the action is 'install' for an install URI.""" - result = parse_uri('synodic://install?manifest=https://example.com/porringer.json') - assert result['action'] == 'install' - - @staticmethod - def test_manifest_key_present() -> None: - """Verify the manifest query parameter is extracted.""" - result = parse_uri('synodic://install?manifest=https://example.com/porringer.json') - assert 'manifest' in result - assert isinstance(result['manifest'], list) - assert result['manifest'][0] == 'https://example.com/porringer.json' - - @staticmethod - def test_multiple_manifests() -> None: - """Verify multiple manifest values are captured.""" - result = parse_uri('synodic://install?manifest=https://a.com/a.json&manifest=https://b.com/b.json') - manifests = result['manifest'] - assert isinstance(manifests, list) - assert len(manifests) == 2 # noqa: PLR2004 - - @staticmethod - def test_unknown_action() -> None: - """Verify unknown actions are still parsed without error.""" - result = parse_uri('synodic://unknown?foo=bar') - assert result['action'] == 'unknown' - - class TestInstallPreviewWindow: """Tests for InstallPreviewWindow table population logic.""" diff --git a/tests/unit/test_install_preview.py b/tests/unit/test_install_preview.py deleted file mode 100644 index b380a9f..0000000 --- a/tests/unit/test_install_preview.py +++ /dev/null @@ -1,560 +0,0 @@ -"""Tests for the install preview window and URI-based install flow.""" - -from __future__ import annotations - -import sys - -import pytest - -pytest.importorskip('PySide6.QtWidgets', reason='PySide6 requires system Qt libraries') - -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock - -from porringer.schema import ( - CancellationToken, - DownloadResult, - PluginInfo, - ProgressEvent, - ProgressEventKind, - SetupActionResult, - SetupResults, - SkipReason, -) -from porringer.schema.plugin import PluginKind - -from synodic_client.application.screen import ( - ACTION_KIND_LABELS, - SKIP_REASON_LABELS, - skip_reason_label, -) -from synodic_client.application.screen.install import ( - InstallWorker, - PreviewWorker, - format_cli_command, - resolve_local_path, -) -from synodic_client.application.uri import parse_uri - -_DOWNLOAD_PATCH = 'synodic_client.application.screen.install.API.download' - - -class TestParseUriInstall: - """Tests for parsing install URIs.""" - - @staticmethod - def test_install_action_parsed() -> None: - """Verify the action is 'install' for an install URI.""" - result = parse_uri('synodic://install?manifest=https://example.com/porringer.json') - assert result['action'] == 'install' - - @staticmethod - def test_manifest_key_present() -> None: - """Verify the manifest query parameter is extracted.""" - result = parse_uri('synodic://install?manifest=https://example.com/porringer.json') - assert 'manifest' in result - assert isinstance(result['manifest'], list) - assert result['manifest'][0] == 'https://example.com/porringer.json' - - @staticmethod - def test_multiple_manifests() -> None: - """Verify multiple manifest values are captured.""" - result = parse_uri('synodic://install?manifest=https://a.com/a.json&manifest=https://b.com/b.json') - manifests = result['manifest'] - assert isinstance(manifests, list) - assert len(manifests) == 2 # noqa: PLR2004 - - @staticmethod - def test_unknown_action() -> None: - """Verify unknown actions are still parsed without error.""" - result = parse_uri('synodic://unknown?foo=bar') - assert result['action'] == 'unknown' - - -class TestInstallPreviewWindow: - """Tests for InstallPreviewWindow table population logic.""" - - @staticmethod - def _make_action( - kind: str = 'PACKAGE', - description: str = 'Install test', - installer: str = 'pip', - package: str = 'requests', - ) -> MagicMock: - """Create a mock SetupAction.""" - action = MagicMock() - action.kind = getattr(PluginKind, kind) - action.description = description - action.installer = installer - action.package = package - action.command = None - action.cli_command = None - return action - - @staticmethod - def test_action_kind_labels() -> None: - """Verify action kind label mapping covers all kinds plus None.""" - for plugin_kind in PluginKind: - assert plugin_kind in ACTION_KIND_LABELS - assert None in ACTION_KIND_LABELS - - @staticmethod - def test_skip_reason_labels() -> None: - """Verify skip reason label mapping covers all reasons.""" - for reason in SkipReason: - assert reason in SKIP_REASON_LABELS - - @staticmethod - def test_skip_reason_label_human_readable() -> None: - """Verify skip reason labels are human-readable, not raw enum names.""" - assert skip_reason_label(SkipReason.ALREADY_INSTALLED) == 'Already installed' - assert skip_reason_label(None) == 'Skipped' - - -class TestFormatCliCommand: - """Tests for format_cli_command helper.""" - - @staticmethod - def _make_action(**overrides: Any) -> MagicMock: - """Create a mock SetupAction with optional attribute overrides.""" - defaults: dict[str, Any] = { - 'kind': 'PACKAGE', - 'description': 'Install test', - 'installer': 'pip', - 'package': 'requests', - 'cli_command': None, - 'command': None, - } - defaults.update(overrides) - action = MagicMock() - kind = defaults['kind'] - action.kind = getattr(PluginKind, kind) if isinstance(kind, str) else kind - action.description = defaults['description'] - action.installer = defaults['installer'] - action.package = defaults['package'] - action.command = defaults['command'] - action.cli_command = defaults['cli_command'] - return action - - def test_prefers_cli_command(self) -> None: - """Verify cli_command takes precedence over command and fallback.""" - action = self._make_action(cli_command=['uv', 'pip', 'install', 'requests']) - assert format_cli_command(action) == 'uv pip install requests' - - def test_falls_back_to_command(self) -> None: - """Verify command is used when cli_command is absent.""" - action = self._make_action( - kind='TOOL', - command=['echo', 'hello'], - ) - assert format_cli_command(action) == 'echo hello' - - def test_synthesises_package_command(self) -> None: - """Verify package actions synthesise installer + package.""" - action = self._make_action(installer='pip', package='ruff') - assert format_cli_command(action) == 'pip install ruff' - - def test_synthesises_default_installer(self) -> None: - """Verify pip is used as default installer for package actions.""" - action = self._make_action(installer=None, package='ruff') - assert format_cli_command(action) == 'pip install ruff' - - def test_description_fallback(self) -> None: - """Verify description is returned when nothing else is available.""" - action = self._make_action( - kind='TOOL', - description='Custom step', - package=None, - ) - assert format_cli_command(action) == 'Custom step' - - -class TestInstallWorker: - """Tests for InstallWorker signal emission.""" - - @staticmethod - def test_worker_emits_finished_on_success() -> None: - """Verify worker emits finished signal with results.""" - porringer = MagicMock() - manifest_path = Path('/tmp/test/porringer.json') - - action = MagicMock() - manifest = SetupResults(actions=[action]) - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=manifest) - - result = MagicMock(spec=SetupActionResult) - completed_event = ProgressEvent( - kind=ProgressEventKind.ACTION_COMPLETED, - action=action, - result=result, - ) - - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 - yield manifest_event - yield completed_event - - porringer.sync.execute_stream = mock_stream - - token = CancellationToken() - worker = InstallWorker(porringer, manifest_path, token) - - received: list[SetupResults] = [] - worker.finished.connect(received.append) - worker.run() - - assert len(received) == 1 - assert received[0].actions == manifest.actions - - @staticmethod - def test_worker_emits_error_on_failure() -> None: - """Verify worker emits error signal on exception.""" - porringer = MagicMock() - manifest_path = Path('/tmp/test/porringer.json') - - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 - if False: - yield # pragma: no cover — establishes async generator protocol - msg = 'boom' - raise RuntimeError(msg) - - porringer.sync.execute_stream = mock_stream - - token = CancellationToken() - worker = InstallWorker(porringer, manifest_path, token) - - errors: list[str] = [] - worker.error.connect(errors.append) - worker.run() - - assert len(errors) == 1 - assert 'boom' in errors[0] - - -class TestResolveLocalPath: - """Tests for _resolve_local_path helper.""" - - @staticmethod - def test_http_url_returns_none() -> None: - """HTTP URLs should not resolve to a local path.""" - assert resolve_local_path('https://example.com/porringer.json') is None - - @staticmethod - def test_absolute_path_returns_path() -> None: - """Absolute OS paths should resolve.""" - path = 'C:\\Users\\test\\porringer.json' if sys.platform == 'win32' else '/Users/test/porringer.json' - result = resolve_local_path(path) - assert result is not None - assert result == Path(path) - - @staticmethod - def test_file_uri_returns_path() -> None: - """file:// URIs should resolve to a local path.""" - result = resolve_local_path('file:///C:/Users/test/porringer.json') - assert result is not None - assert 'porringer.json' in str(result) - - @staticmethod - def test_existing_relative_path(tmp_path: Path) -> None: - """Relative paths that exist on disk should resolve.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - result = resolve_local_path(str(manifest)) - assert result is not None - - -class TestPreviewWorkerLocal: - """Tests for PreviewWorker with local manifest files.""" - - @staticmethod - def test_local_manifest_skips_download(tmp_path: Path) -> None: - """Verify PreviewWorker skips download for local files.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - - porringer = MagicMock() - expected = SetupResults(actions=[]) - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=expected) - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - yield manifest_event - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, str(manifest)) - - results: list[tuple[object, str, str]] = [] - worker.preview_ready.connect(lambda r, p, t: results.append((r, p, t))) - worker.run() - - assert len(results) == 1 - assert results[0][0] is expected - # download should NOT have been called - porringer.sync.download.assert_not_called() - - @staticmethod - def test_local_manifest_not_found() -> None: - """Verify PreviewWorker emits error for missing local file.""" - path = 'C:\\nonexistent\\porringer.json' if sys.platform == 'win32' else '/nonexistent/porringer.json' - porringer = MagicMock() - worker = PreviewWorker(porringer, path) - - errors: list[str] = [] - worker.error.connect(errors.append) - worker.run() - - assert len(errors) == 1 - assert 'not found' in errors[0].lower() - - -class TestPreviewWorker: - """Tests for PreviewWorker download and preview flow.""" - - @staticmethod - def test_emits_error_on_download_failure(monkeypatch: pytest.MonkeyPatch) -> None: - """Verify PreviewWorker emits error when download fails.""" - porringer = MagicMock() - monkeypatch.setattr( - _DOWNLOAD_PATCH, - lambda params, progress_callback=None: DownloadResult( - success=False, - path=None, - verified=False, - size=0, - message='Network error', - ), - ) - - worker = PreviewWorker(porringer, 'https://example.com/bad.json') - - errors: list[str] = [] - worker.error.connect(errors.append) - worker.run() - - assert len(errors) == 1 - assert 'Network error' in errors[0] - - @staticmethod - def test_emits_preview_ready_on_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Verify PreviewWorker emits preview_ready with SetupResults.""" - porringer = MagicMock() - - dest = tmp_path / 'porringer.json' - dest.write_text('{}') - - monkeypatch.setattr( - _DOWNLOAD_PATCH, - lambda params, progress_callback=None: DownloadResult( - success=True, - path=dest, - verified=True, - size=100, - message='OK', - ), - ) - - expected = SetupResults(actions=[]) - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=expected) - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - yield manifest_event - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, 'https://example.com/good.json') - - results: list[tuple[object, str, str]] = [] - worker.preview_ready.connect(lambda r, p, t: results.append((r, p, t))) - worker.run() - - assert len(results) == 1 - assert results[0][0] is expected - - -class TestPreviewWorkerSignals: - """Tests for PreviewWorker signal emission and dry-run status check.""" - - @staticmethod - def test_emits_preview_ready_and_finished(tmp_path: Path) -> None: - """Verify worker emits preview_ready then finished for a local manifest.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - - porringer = MagicMock() - action = MagicMock() - action.kind = PluginKind.PACKAGE - preview = SetupResults(actions=[action]) - - # Dry-run stream yields manifest loaded then one completed event - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) - result = SetupActionResult(action=action, success=True, skipped=False, skip_reason=None) - completed_event = ProgressEvent(kind=ProgressEventKind.ACTION_COMPLETED, action=action, result=result) - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - yield manifest_event - yield completed_event - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, str(manifest)) - - ready_calls: list[tuple[object, str, str]] = [] - checked: list[tuple[int, SetupActionResult]] = [] - finished_count: list[int] = [] - worker.preview_ready.connect(lambda p, m, t: ready_calls.append((p, m, t))) - worker.action_checked.connect(lambda row, r: checked.append((row, r))) - worker.finished.connect(lambda: finished_count.append(1)) - worker.run() - - assert len(ready_calls) == 1 - assert ready_calls[0][0] is preview - assert len(checked) == 1 - assert checked[0] == (0, result) - assert len(finished_count) == 1 - - @staticmethod - def test_emits_finished_for_empty_actions(tmp_path: Path) -> None: - """Verify worker emits finished signal even with no actions.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - - porringer = MagicMock() - preview = SetupResults(actions=[]) - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - yield manifest_event - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, str(manifest)) - - finished_count: list[int] = [] - worker.finished.connect(lambda: finished_count.append(1)) - worker.run() - - assert len(finished_count) == 1 - - @staticmethod - def test_action_checked_maps_correct_rows(tmp_path: Path) -> None: - """Verify action_checked emits correct row indices via identity matching.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - - porringer = MagicMock() - action_a = MagicMock() - action_a.kind = PluginKind.RUNTIME - action_b = MagicMock() - action_b.kind = PluginKind.PACKAGE - preview = SetupResults(actions=[action_a, action_b]) - - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) - result_b = SetupActionResult( - action=action_b, success=True, skipped=True, skip_reason=SkipReason.ALREADY_INSTALLED - ) - result_a = SetupActionResult(action=action_a, success=True, skipped=False, skip_reason=None) - - # Stream returns in execution order (b before a), not preview order - event_b = ProgressEvent(kind=ProgressEventKind.ACTION_COMPLETED, action=action_b, result=result_b) - event_a = ProgressEvent(kind=ProgressEventKind.ACTION_COMPLETED, action=action_a, result=result_a) - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - yield manifest_event - yield event_b - yield event_a - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, str(manifest)) - - checked: list[tuple[int, SetupActionResult]] = [] - worker.action_checked.connect(lambda row, r: checked.append((row, r))) - worker.run() - - assert len(checked) == 2 # noqa: PLR2004 - # action_b is at index 1 in preview, action_a at index 0 - assert checked[0] == (1, result_b) - assert checked[1] == (0, result_a) - - @staticmethod - def test_emits_error_when_dry_run_fails(tmp_path: Path) -> None: - """Verify worker emits error when dry-run raises.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - - porringer = MagicMock() - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - if False: - yield # pragma: no cover — establishes async generator protocol - msg = 'dry-run boom' - raise RuntimeError(msg) - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, str(manifest)) - - errors: list[str] = [] - finished_count: list[int] = [] - worker.error.connect(errors.append) - worker.finished.connect(lambda: finished_count.append(1)) - worker.run() - - assert len(errors) == 1 - assert 'dry-run boom' in errors[0] - assert len(finished_count) == 0 - - @staticmethod - def test_emits_plugins_queried(tmp_path: Path) -> None: - """Verify plugins_queried is emitted with plugin presence mapping.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - - porringer = MagicMock() - porringer.plugin.list.return_value = [ - PluginInfo(name='pip', kind=PluginKind.PACKAGE, version=MagicMock(), installed=True, tool_version=None), - PluginInfo(name='uv', kind=PluginKind.PACKAGE, version=MagicMock(), installed=False, tool_version=None), - ] - - preview = SetupResults(actions=[]) - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - yield manifest_event - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, str(manifest)) - - plugin_data: list[dict[str, bool]] = [] - worker.plugins_queried.connect(plugin_data.append) - worker.run() - - assert len(plugin_data) == 1 - assert plugin_data[0] == {'pip': True, 'uv': False} - - @staticmethod - def test_plugins_queried_emitted_before_preview_ready(tmp_path: Path) -> None: - """Verify plugins_queried fires before preview_ready.""" - manifest = tmp_path / 'porringer.json' - manifest.write_text('{}') - - porringer = MagicMock() - porringer.plugin.list.return_value = [] - - preview = SetupResults(actions=[]) - manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview) - - async def mock_stream(*args: Any, **kwargs: Any) -> Any: - yield manifest_event - - porringer.sync.execute_stream = mock_stream - - worker = PreviewWorker(porringer, str(manifest)) - - order: list[str] = [] - worker.plugins_queried.connect(lambda _: order.append('plugins')) - worker.preview_ready.connect(lambda *_: order.append('preview')) - worker.run() - - assert order == ['plugins', 'preview'] diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index d50555e..2d8f231 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -6,8 +6,6 @@ from packaging.version import Version from synodic_client.updater import ( - DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, - DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, GITHUB_REPO_URL, UpdateChannel, UpdateConfig, @@ -19,110 +17,9 @@ ) -class TestUpdateChannel: - """Tests for UpdateChannel enum.""" - - @staticmethod - def test_stable_channel_exists() -> None: - """Verify STABLE channel is defined.""" - assert hasattr(UpdateChannel, 'STABLE') - - @staticmethod - def test_development_channel_exists() -> None: - """Verify DEVELOPMENT channel is defined.""" - assert hasattr(UpdateChannel, 'DEVELOPMENT') - - -class TestUpdateState: - """Tests for UpdateState enum.""" - - @staticmethod - def test_all_states_exist() -> None: - """Verify all expected states are defined.""" - expected_states = [ - 'NO_UPDATE', - 'UPDATE_AVAILABLE', - 'DOWNLOADING', - 'DOWNLOADED', - 'APPLYING', - 'APPLIED', - 'FAILED', - ] - for state_name in expected_states: - assert hasattr(UpdateState, state_name) - - -class TestUpdateInfo: - """Tests for UpdateInfo dataclass.""" - - @staticmethod - def test_minimal_creation() -> None: - """Verify UpdateInfo can be created with minimal required fields.""" - info = UpdateInfo( - available=False, - current_version=Version('1.0.0'), - ) - assert info.available is False - assert info.current_version == Version('1.0.0') - assert info.latest_version is None - assert info.error is None - assert info._velopack_info is None - - @staticmethod - def test_full_creation() -> None: - """Verify UpdateInfo can be created with all fields.""" - mock_velopack_info = MagicMock() - info = UpdateInfo( - available=True, - current_version=Version('1.0.0'), - latest_version=Version('2.0.0'), - error=None, - _velopack_info=mock_velopack_info, - ) - assert info.available is True - assert info.latest_version == Version('2.0.0') - assert info._velopack_info is mock_velopack_info - - @staticmethod - def test_with_error() -> None: - """Verify UpdateInfo can be created with error.""" - info = UpdateInfo( - available=False, - current_version=Version('1.0.0'), - error='Network error', - ) - assert info.available is False - assert info.error == 'Network error' - - class TestUpdateConfig: """Tests for UpdateConfig dataclass.""" - @staticmethod - def test_default_values() -> None: - """Verify default configuration values.""" - config = UpdateConfig() - assert config.repo_url == GITHUB_REPO_URL - assert config.channel == UpdateChannel.STABLE - assert config.auto_update_interval_minutes == DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES - assert config.tool_update_interval_minutes == DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES - - @staticmethod - def test_custom_values() -> None: - """Verify custom configuration values are applied.""" - auto = DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES * 2 - tool = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES // 2 - config = UpdateConfig( - repo_url='https://github.com/custom/repo', - channel=UpdateChannel.DEVELOPMENT, - auto_update_interval_minutes=auto, - tool_update_interval_minutes=tool, - ) - assert config.repo_url == 'https://github.com/custom/repo' - assert config.channel == UpdateChannel.DEVELOPMENT - assert config.auto_update_interval_minutes == auto - assert config.tool_update_interval_minutes == tool - @staticmethod def test_channel_name_stable() -> None: """Verify STABLE channel returns platform-specific 'stable' name.""" From c0d793a127f326e19b5a4c9dd57e0fd764f80a8d Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Feb 2026 10:24:42 -0800 Subject: [PATCH 11/12] Spinner Loading --- synodic_client/application/screen/install.py | 17 ++++ synodic_client/application/screen/screen.py | 23 +++-- synodic_client/application/screen/spinner.py | 94 ++++++++++++++++++++ 3 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 synodic_client/application/screen/spinner.py diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 8f9dd7c..fc624e4 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -54,6 +54,7 @@ from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label from synodic_client.application.screen.log_panel import ExecutionLogPanel +from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.theme import ( COMMAND_HEADER_STYLE, COMPACT_MARGINS, @@ -310,6 +311,10 @@ def _init_ui(self) -> None: self._status_label = QLabel() layout.addWidget(self._status_label) + # Centered spinner (fills empty space while loading manifest) + self._spinner = SpinnerWidget('Loading\u2026') + layout.addWidget(self._spinner, 1) + # --- View stack (table / command list) --- self._view_stack = QStackedWidget() @@ -398,10 +403,19 @@ def reset(self) -> None: self._meta_label.hide() self._status_label.setText('') self._status_label.setStyleSheet('') + self._spinner.stop() self._install_btn.setEnabled(False) self._toggle_btn.setEnabled(False) self._view_stack.setCurrentIndex(0) + def start_loading(self) -> None: + """Show the centered loading spinner. + + Call this before starting a :class:`PreviewWorker` so the user + sees an animated indicator in the otherwise-empty preview area. + """ + self._spinner.start() + def show_not_found(self, message: str) -> None: """Display a muted 'not found' message in the status label. @@ -440,6 +454,7 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p self._preview = preview self._manifest_path = Path(manifest_path) self._status_label.setStyleSheet('') + self._spinner.stop() self._show_metadata(preview) @@ -523,6 +538,7 @@ def on_preview_finished(self) -> None: def on_preview_error(self, message: str) -> None: """Handle a preview error.""" logger.error('Preview failed: %s', message) + self._spinner.stop() self._status_label.setText('') QMessageBox.critical(self, 'Preview Failed', message) self.close_requested.emit() @@ -846,6 +862,7 @@ def start(self) -> None: """ logger.info('Starting install preview for: %s', self._manifest_url) self._url_label.setText(f'Manifest: {self._manifest_url}') + self._preview_widget.start_loading() preview_worker = PreviewWorker(self._porringer, self._manifest_url, project_directory=self._project_directory) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 63c61cd..9386157 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -30,6 +30,7 @@ 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 PreviewWorker, SetupPreviewWidget +from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.theme import ( COMPACT_MARGINS, LOG_CHEVRON_STYLE, @@ -333,10 +334,8 @@ def _init_ui(self) -> None: outer.setContentsMargins(*COMPACT_MARGINS) # Loading indicator (shown while data is fetched asynchronously) - self._loading_label = QLabel('Loading plugins\u2026') - self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._loading_label.hide() - outer.addWidget(self._loading_label) + self._loading_spinner = SpinnerWidget('Loading plugins\u2026') + outer.addWidget(self._loading_spinner) # Toolbar toolbar = QHBoxLayout() @@ -372,7 +371,7 @@ def refresh(self) -> None: async def _async_refresh(self) -> None: """Rebuild the plugin sections from porringer data, grouped by kind.""" self._refresh_in_progress = True - self._loading_label.show() + self._loading_spinner.start() try: loop = asyncio.get_running_loop() @@ -413,7 +412,7 @@ async def _async_refresh(self) -> None: except Exception: logger.exception('Failed to refresh plugins') finally: - self._loading_label.hide() + self._loading_spinner.stop() self._refresh_in_progress = False def _fetch_plugin_data( @@ -535,10 +534,8 @@ def _init_ui(self) -> None: layout.setContentsMargins(*COMPACT_MARGINS) # Loading indicator (shown while data is fetched asynchronously) - self._loading_label = QLabel('Loading projects\u2026') - self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._loading_label.hide() - layout.addWidget(self._loading_label) + self._loading_spinner = SpinnerWidget('Loading projects\u2026') + layout.addWidget(self._loading_spinner) # --- Project directory selector --- selector_row = QHBoxLayout() @@ -580,7 +577,7 @@ def refresh(self) -> None: async def _async_refresh(self) -> None: """Refresh the cached directories combo box from porringer cache.""" self._refresh_in_progress = True - self._loading_label.show() + self._loading_spinner.start() self._combo.setEnabled(False) self._browse_btn.setEnabled(False) self._remove_btn.setEnabled(False) @@ -629,7 +626,7 @@ async def _async_refresh(self) -> None: except Exception: logger.exception('Failed to refresh projects') finally: - self._loading_label.hide() + self._loading_spinner.stop() self._combo.setEnabled(True) self._browse_btn.setEnabled(True) self._update_remove_btn() @@ -714,6 +711,8 @@ def _load_preview(self) -> None: self._preview.show_not_found(f'No manifest found at: {selected_path}') return + self._preview.start_loading() + # Defer project directory assignment until the preview result # provides root_directory — handles both file and directory inputs. preview_worker = PreviewWorker( diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py new file mode 100644 index 0000000..944a6ce --- /dev/null +++ b/synodic_client/application/screen/spinner.py @@ -0,0 +1,94 @@ +"""Animated loading spinner widget. + +Provides :class:`SpinnerWidget` — a palette-aware spinning arc with an +optional text label. Call ``start()`` to show and ``stop()`` to hide. +""" + +from __future__ import annotations + +from PySide6.QtCore import QRect, Qt, QTimer +from PySide6.QtGui import QPainter, QPen +from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget + +_SIZE = 24 +_PEN = 3 +_INTERVAL = 50 +_ARC = 90 + + +class _Canvas(QWidget): + """Fixed-size widget that paints the spinning arc.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._angle = 0 + self.setFixedSize(_SIZE, _SIZE) + + def paintEvent(self, _event: object) -> None: # noqa: N802 + """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) + + for colour, span in ((self.palette().mid(), 360), (self.palette().highlight(), _ARC)): + pen = QPen(colour, _PEN) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + if span == 360: + painter.drawEllipse(rect) + else: + painter.drawArc(rect, self._angle * 16, span * 16) + + painter.end() + + def tick(self) -> None: + """Advance the arc and repaint.""" + self._angle = (self._angle - 10) % 360 + self.update() + + +class SpinnerWidget(QWidget): + """Animated spinner circle with optional text label. + + The widget centres itself in whatever space the parent layout + provides — callers just need ``layout.addWidget(spinner)`` (with an + optional stretch factor for vertical centering in empty areas). + """ + + def __init__(self, text: str = '', parent: QWidget | None = None) -> None: + super().__init__(parent) + self.hide() + + self._canvas = _Canvas(self) + self._timer = QTimer(self) + self._timer.setInterval(_INTERVAL) + self._timer.timeout.connect(self._canvas.tick) + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addStretch() + + row = QHBoxLayout() + row.setSpacing(8) + row.addStretch() + row.addWidget(self._canvas) + self._label = QLabel(text) + if text: + row.addWidget(self._label) + row.addStretch() + + outer.addLayout(row) + outer.addStretch() + + def start(self) -> None: + """Show the widget and start the animation.""" + self.show() + self._canvas._angle = 0 + self._timer.start() + + def stop(self) -> None: + """Stop the animation and hide the widget.""" + self._timer.stop() + self.hide() From 779dfb421f8d29646153f4bb8459e7830c946e87 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 18 Feb 2026 11:01:47 -0800 Subject: [PATCH 12/12] Project Layout Update --- synodic_client/application/screen/card.py | 148 +++++++++++ synodic_client/application/screen/install.py | 243 +++++++++++------- .../application/screen/log_panel.py | 20 +- synodic_client/application/screen/screen.py | 90 +++---- synodic_client/application/theme.py | 14 + 5 files changed, 367 insertions(+), 148 deletions(-) create mode 100644 synodic_client/application/screen/card.py diff --git a/synodic_client/application/screen/card.py b/synodic_client/application/screen/card.py new file mode 100644 index 0000000..32de31a --- /dev/null +++ b/synodic_client/application/screen/card.py @@ -0,0 +1,148 @@ +"""Reusable card frame and clickable header widgets. + +:class:`CardFrame` provides a styled, optionally collapsible container +for grouping related UI elements. :class:`ClickableHeader` extracts the +ad-hoc clickable-header pattern used in log panels and plugin sections +into a proper widget with a ``clicked`` signal. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from synodic_client.application.theme import ( + CARD_FRAME_STYLE, + CARD_HEADER_STYLE, + LOG_CHEVRON_STYLE, + NO_MARGINS, +) + +# Unicode chevrons +CHEVRON_DOWN = '\u25bc' +CHEVRON_RIGHT = '\u25b6' + + +class ClickableHeader(QWidget): + """A clickable header widget with an optional chevron for collapse/expand. + + Emits :attr:`clicked` on mouse press and sets a pointing-hand cursor. + Callers supply *object_name* and *stylesheet* to control appearance. + """ + + clicked = Signal() + + def __init__( + self, + object_name: str, + stylesheet: str, + *, + parent: QWidget | None = None, + ) -> None: + """Initialise the header. + + Args: + object_name: ``QObject`` name (for CSS selectors). + stylesheet: Stylesheet applied to this widget. + parent: Optional parent widget. + """ + super().__init__(parent) + self.setObjectName(object_name) + self.setStyleSheet(stylesheet) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(6) + + # --- Layout access --------------------------------------------------- + + @property + def header_layout(self) -> QHBoxLayout: + """Return the header's horizontal layout for adding child widgets.""" + return self._layout + + # --- Event handling --------------------------------------------------- + + def mousePressEvent(self, _event: object) -> None: # noqa: N802 + """Emit :attr:`clicked` on any mouse press.""" + self.clicked.emit() + + +class CardFrame(QFrame): + """A rounded-border card container with an optional title and collapse. + + When *collapsible* is ``True`` the title becomes a + :class:`ClickableHeader` and clicking it toggles the content area. + Use :meth:`content_layout` to add child widgets. + """ + + def __init__( + self, + title: str = '', + *, + collapsible: bool = False, + parent: QWidget | None = None, + ) -> None: + """Initialise the card. + + Args: + title: Optional heading text shown at the top of the card. + collapsible: When ``True``, the title row toggles content + visibility on click. + parent: Optional parent widget. + """ + super().__init__(parent) + self.setObjectName('card') + self.setStyleSheet(CARD_FRAME_STYLE) + self._expanded = True + self._collapsible = collapsible + + outer = QVBoxLayout(self) + outer.setContentsMargins(*NO_MARGINS) + outer.setSpacing(4) + + # --- Optional title / header ----------------------------------------- + self._chevron: QLabel | None = None + if title: + if collapsible: + header = ClickableHeader('cardHeader', '', parent=self) + header.clicked.connect(self._toggle) + + self._chevron = QLabel(CHEVRON_DOWN) + self._chevron.setStyleSheet(LOG_CHEVRON_STYLE) + self._chevron.setFixedWidth(14) + header.header_layout.addWidget(self._chevron) + + label = QLabel(title) + label.setStyleSheet(CARD_HEADER_STYLE) + header.header_layout.addWidget(label) + header.header_layout.addStretch() + outer.addWidget(header) + else: + label = QLabel(title) + label.setStyleSheet(CARD_HEADER_STYLE) + outer.addWidget(label) + + # --- Content area ----------------------------------------------------- + self._content = QWidget() + self._content_layout = QVBoxLayout(self._content) + self._content_layout.setContentsMargins(*NO_MARGINS) + self._content_layout.setSpacing(4) + outer.addWidget(self._content) + + # --- Public API ----------------------------------------------------------- + + @property + def content_layout(self) -> QVBoxLayout: + """Return the inner layout for adding child widgets to the card.""" + return self._content_layout + + # --- Collapse / expand ---------------------------------------------------- + + def _toggle(self) -> None: + """Toggle the content area visibility.""" + self._expanded = not self._expanded + self._content.setVisible(self._expanded) + if self._chevron is not None: + self._chevron.setText(CHEVRON_DOWN if self._expanded else CHEVRON_RIGHT) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index fc624e4..0f494c6 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -36,6 +36,7 @@ from PySide6.QtWidgets import ( QApplication, QFileDialog, + QGridLayout, QHBoxLayout, QHeaderView, QLabel, @@ -43,7 +44,7 @@ QMainWindow, QMessageBox, QPushButton, - QScrollArea, + QSizePolicy, QStackedWidget, QTableWidget, QTableWidgetItem, @@ -53,9 +54,11 @@ ) from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label +from synodic_client.application.screen.card import CardFrame from synodic_client.application.screen.log_panel import ExecutionLogPanel from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.theme import ( + CARD_SPACING, COMMAND_HEADER_STYLE, COMPACT_MARGINS, CONTENT_MARGINS, @@ -165,27 +168,59 @@ async def _execute(self) -> SetupResults: ) -class CommandListWidget(QScrollArea): - """Scrollable list of per-action CLI commands with copy buttons.""" +class PostInstallSection(QWidget): + """Always-visible section showing bare-command (post-install) actions. + + Bare-command actions (``kind is None``) cannot be dry-run checked, so + they are excluded from the main actions table. This widget gives + them a dedicated, always-visible home with copyable CLI text. + """ def __init__(self, parent: QWidget | None = None) -> None: - """Initialise the command list widget.""" + """Initialise the section (hidden until :meth:`populate` is called).""" super().__init__(parent) - self.setWidgetResizable(True) + self.hide() + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(*NO_MARGINS) + self._layout.setSpacing(4) + + header = QLabel('Post-Install Commands') + header.setStyleSheet(COMMAND_HEADER_STYLE) + self._layout.addWidget(header) + + self._content = QWidget() + self._content_layout = QVBoxLayout(self._content) + self._content_layout.setContentsMargins(*NO_MARGINS) + self._content_layout.setSpacing(4) + self._layout.addWidget(self._content) def populate(self, actions: list[SetupAction]) -> None: - """Build per-action command fields with descriptive labels above each.""" - container = QWidget() - layout = QVBoxLayout(container) - layout.setContentsMargins(*NO_MARGINS) + """Show command actions from *actions*. + + Only actions whose ``kind`` is ``None`` are shown. If there are + none the widget stays hidden. + + Args: + actions: The full list of setup actions. + """ + # Clear previous content + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + commands = [(i, a) for i, a in enumerate(actions, 1) if a.kind is None] + if not commands: + self.hide() + return mono = QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE) - for i, action in enumerate(actions, 1): - label_text = ACTION_KIND_LABELS.get(action.kind, 'Action') + for idx, action in commands: desc = action.package_description or action.description - header = QLabel(f'{i}. [{label_text}] {desc}') - header.setStyleSheet(COMMAND_HEADER_STYLE) - layout.addWidget(header) + label = QLabel(f'{idx}. {desc}') + label.setStyleSheet(COMMAND_HEADER_STYLE) + self._content_layout.addWidget(label) field = QLineEdit(format_cli_command(action)) field.setReadOnly(True) @@ -199,10 +234,9 @@ def populate(self, actions: list[SetupAction]) -> None: row_widget = QWidget() row_widget.setLayout(row_layout) - layout.addWidget(row_widget) + self._content_layout.addWidget(row_widget) - layout.addStretch() - self.setWidget(container) + self.show() def _make_copy_button(field: QLineEdit) -> QToolButton: @@ -286,53 +320,85 @@ def __init__(self, porringer: API, parent: QWidget | None = None, *, show_close: # --- UI construction --- + _SPINNER_PAGE = 0 + _CONTENT_PAGE = 1 + def _init_ui(self) -> None: - """Build the widget layout.""" - layout = QVBoxLayout(self) - layout.setContentsMargins(*NO_MARGINS) + """Build the card-based grid layout. + + The spinner and the actions card share a :class:`QStackedWidget` + so that switching between loading and content states never + changes the grid geometry — eliminating layout shifts. + """ + grid = QGridLayout(self) + grid.setContentsMargins(*NO_MARGINS) + grid.setVerticalSpacing(CARD_SPACING) + grid.setHorizontalSpacing(CARD_SPACING) + + row = 0 + + # Row 0 — Metadata card (hidden until preview metadata arrives) + self._metadata_card = CardFrame('Project', collapsible=True) + self._metadata_card.hide() - # Metadata labels (populated after preview completes) self._name_label = QLabel() self._name_label.setStyleSheet(HEADER_STYLE) self._name_label.hide() - layout.addWidget(self._name_label) + self._metadata_card.content_layout.addWidget(self._name_label) self._description_label = QLabel() self._description_label.setWordWrap(True) self._description_label.hide() - layout.addWidget(self._description_label) + self._metadata_card.content_layout.addWidget(self._description_label) self._meta_label = QLabel() self._meta_label.setStyleSheet(MUTED_STYLE) self._meta_label.hide() - layout.addWidget(self._meta_label) + self._metadata_card.content_layout.addWidget(self._meta_label) + + grid.addWidget(self._metadata_card, row, 0, 1, 2) + row += 1 - # Status label + # Row 1 — Status label self._status_label = QLabel() - layout.addWidget(self._status_label) + grid.addWidget(self._status_label, row, 0, 1, 2) + row += 1 - # Centered spinner (fills empty space while loading manifest) - self._spinner = SpinnerWidget('Loading\u2026') - layout.addWidget(self._spinner, 1) + # Row 2 — Content stack: spinner (page 0) / actions card (page 1) + # + # Both pages live in the same grid cell with the same stretch, + # so toggling the current page causes *no* layout shift. + self._content_stack = QStackedWidget() - # --- View stack (table / command list) --- - self._view_stack = QStackedWidget() + self._spinner = SpinnerWidget('Loading\u2026') + self._spinner.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._content_stack.addWidget(self._spinner) # page 0 + self._actions_card = CardFrame() self._table = self._init_actions_table() - self._view_stack.addWidget(self._table) # page 0 + self._actions_card.content_layout.addWidget(self._table) + self._post_install_section = PostInstallSection() + self._actions_card.content_layout.addWidget(self._post_install_section) + self._content_stack.addWidget(self._actions_card) # page 1 - self._command_list = CommandListWidget() - self._view_stack.addWidget(self._command_list) # page 1 + self._content_stack.setCurrentIndex(self._CONTENT_PAGE) - layout.addWidget(self._view_stack) + grid.addWidget(self._content_stack, row, 0, 1, 2) + grid.setRowStretch(row, 2) + row += 1 - # Execution log (always visible below the table once install starts) + # Row 3 — Execution log card (hidden until install starts) + self._log_card = CardFrame('Execution Log', collapsible=True) self._log_panel = ExecutionLogPanel() - self._log_panel.hide() - layout.addWidget(self._log_panel) + self._log_card.content_layout.addWidget(self._log_panel) + self._log_card.hide() + grid.addWidget(self._log_card, row, 0, 1, 2) + grid.setRowStretch(row, 1) + row += 1 - # Button bar - layout.addLayout(self._init_button_bar()) + # Row 4 — Button bar + button_bar = self._init_button_bar() + grid.addLayout(button_bar, row, 0, 1, 2) def _init_actions_table(self) -> QTableWidget: """Create and configure the actions table widget.""" @@ -353,12 +419,7 @@ def _init_actions_table(self) -> QTableWidget: def _init_button_bar(self) -> QHBoxLayout: """Create the bottom button bar.""" - self._toggle_btn = QPushButton('Show Commands') - self._toggle_btn.setEnabled(False) - self._toggle_btn.clicked.connect(self._toggle_view) - button_bar = QHBoxLayout() - button_bar.addWidget(self._toggle_btn) button_bar.addStretch() self._install_btn = QPushButton('Install') @@ -396,25 +457,29 @@ def reset(self) -> None: self._plugin_installed = {} self._table.setRowCount(0) + self._post_install_section.hide() self._log_panel.clear() - self._log_panel.hide() + self._log_card.hide() self._name_label.hide() self._description_label.hide() self._meta_label.hide() + self._metadata_card.hide() self._status_label.setText('') self._status_label.setStyleSheet('') - self._spinner.stop() + self._spinner._timer.stop() + self._content_stack.setCurrentIndex(self._CONTENT_PAGE) self._install_btn.setEnabled(False) - self._toggle_btn.setEnabled(False) - self._view_stack.setCurrentIndex(0) def start_loading(self) -> None: """Show the centered loading spinner. - Call this before starting a :class:`PreviewWorker` so the user - sees an animated indicator in the otherwise-empty preview area. + Switches the content stack to the spinner page and starts the + animation. The grid geometry stays constant because the spinner + and the actions card share the same :class:`QStackedWidget` cell. """ - self._spinner.start() + self._content_stack.setCurrentIndex(self._SPINNER_PAGE) + self._spinner._canvas._angle = 0 + self._spinner._timer.start() def show_not_found(self, message: str) -> None: """Display a muted 'not found' message in the status label. @@ -454,7 +519,8 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p self._preview = preview self._manifest_path = Path(manifest_path) self._status_label.setStyleSheet('') - self._spinner.stop() + self._spinner._timer.stop() + self._content_stack.setCurrentIndex(self._CONTENT_PAGE) self._show_metadata(preview) @@ -506,11 +572,10 @@ def on_preview_finished(self) -> None: item.setText('Needed') item.setForeground(self.palette().text()) - # Count only actions shown in the table (excludes bare commands). - table_statuses = [self._action_statuses[i] for i in self._action_to_table_row] - total = len(table_statuses) - needed = sum(1 for s in table_statuses if s == 'Needed') - unavailable = sum(1 for s in table_statuses if s == 'Not installed') + # Count ALL actions (including bare commands) for enablement. + total = len(self._action_statuses) + needed = sum(1 for s in self._action_statuses if s == 'Needed') + unavailable = sum(1 for s in self._action_statuses if s == 'Not installed') satisfied = total - needed - unavailable parts: list[str] = [] @@ -538,7 +603,8 @@ def on_preview_finished(self) -> None: def on_preview_error(self, message: str) -> None: """Handle a preview error.""" logger.error('Preview failed: %s', message) - self._spinner.stop() + self._spinner._timer.stop() + self._content_stack.setCurrentIndex(self._CONTENT_PAGE) self._status_label.setText('') QMessageBox.critical(self, 'Preview Failed', message) self.close_requested.emit() @@ -551,12 +617,15 @@ def _show_metadata(self, preview: SetupResults) -> None: if not metadata: return + has_content = False if metadata.name: self._name_label.setText(metadata.name) self._name_label.show() + has_content = True if metadata.description: self._description_label.setText(metadata.description) self._description_label.show() + has_content = True meta_parts: list[str] = [] if metadata.author: @@ -566,20 +635,12 @@ def _show_metadata(self, preview: SetupResults) -> None: if meta_parts: self._meta_label.setText(' | '.join(meta_parts)) self._meta_label.show() + has_content = True - # --- View toggle --- + if has_content: + self._metadata_card.show() - def _toggle_view(self) -> None: - """Toggle between overview table and command list.""" - current = self._view_stack.currentIndex() - if current == 1: - self._view_stack.setCurrentIndex(0) - self._toggle_btn.setText('Show Commands') - else: - self._view_stack.setCurrentIndex(1) - self._toggle_btn.setText('Show Overview') - - # --- Table / command list --- + # --- Table --- def _copy_table_selection(self) -> None: """Copy selected table rows to the clipboard as tab-separated text.""" @@ -595,12 +656,12 @@ def _copy_table_selection(self) -> None: clipboard.setText('\n'.join(lines)) def _populate_table(self, actions: list[SetupAction]) -> None: - """Fill the actions table from a list of SetupAction objects. + """Fill the actions table and post-install section from *actions*. Command actions (``kind is None``) are excluded from the table because they cannot be dry-run checked — they always appear as - *Needed* which is misleading. They remain visible in the - command-list view and are still executed during install. + *Needed* which is misleading. They are shown in the dedicated + :class:`PostInstallSection` instead. Actions whose installer plugin is not installed are immediately flagged as *Not installed* so the user knows the plugin must be @@ -632,8 +693,8 @@ def _populate_table(self, actions: list[SetupAction]) -> None: status_item.setForeground(self.palette().placeholderText()) self._table.setItem(table_row, 4, status_item) - self._command_list.populate(actions) - self._toggle_btn.setEnabled(True) + # Populate the always-visible post-install commands section + self._post_install_section.populate(actions) # --- Install execution --- @@ -644,15 +705,13 @@ def _on_install(self) -> None: self._install_btn.setEnabled(False) self._close_btn.setEnabled(False) - self._toggle_btn.setEnabled(False) self._completed_count = 0 self._cancellation_token = CancellationToken() - # Show the execution log panel below the table + # Show the execution log card below the actions card self._log_panel.clear() - self._log_panel.show() - self._view_stack.setCurrentIndex(0) + self._log_card.show() self._status_label.setText('Installing…') # Worker thread @@ -681,14 +740,19 @@ def _on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> def _on_action_progress(self, action: SetupAction, result: SetupActionResult) -> None: """Handle a single action completion from the worker.""" - row = self._completed_count self._completed_count += 1 # Update the execution log panel self._log_panel.on_action_completed(action, result) - # Update the table status too (for when user switches back to table view) - self._update_table_status(row, result) + # Map the action back to its table row (commands have no table row) + if self._preview: + for idx, a in enumerate(self._preview.actions): + if a is action: + table_row = self._action_to_table_row.get(idx) + if table_row is not None: + self._update_table_status(table_row, result) + break # Update status label total = len(self._preview.actions) if self._preview else 0 @@ -733,7 +797,6 @@ def _on_install_finished(self, results: SetupResults) -> None: self._status_label.setText(f'Done — {summary}') self._install_btn.setEnabled(False) self._close_btn.setEnabled(True) - self._toggle_btn.setEnabled(True) self.install_finished.emit(results) def _on_install_error(self, message: str) -> None: @@ -741,7 +804,6 @@ def _on_install_error(self, message: str) -> None: self._status_label.setText(f'Install failed: {message}') self._install_btn.setEnabled(True) self._close_btn.setEnabled(True) - self._toggle_btn.setEnabled(True) # --------------------------------------------------------------------------- @@ -784,14 +846,17 @@ def _init_ui(self) -> None: self.setCentralWidget(central) layout = QVBoxLayout(central) layout.setContentsMargins(*CONTENT_MARGINS) + layout.setSpacing(CARD_SPACING) + + # Source card — manifest URL + project directory + source_card = CardFrame('Source') - # URL header self._url_label = QLabel() self._url_label.setWordWrap(True) - layout.addWidget(self._url_label) + source_card.content_layout.addWidget(self._url_label) + source_card.content_layout.addLayout(self._init_project_dir_row()) - # Project directory input - layout.addLayout(self._init_project_dir_row()) + layout.addWidget(source_card) # Shared preview widget self._preview_widget = SetupPreviewWidget(self._porringer, self) diff --git a/synodic_client/application/screen/log_panel.py b/synodic_client/application/screen/log_panel.py index 3aed548..7b6e9f5 100644 --- a/synodic_client/application/screen/log_panel.py +++ b/synodic_client/application/screen/log_panel.py @@ -12,10 +12,8 @@ import logging from porringer.schema import SetupAction, SetupActionResult, SubActionProgress -from PySide6.QtCore import Qt from PySide6.QtGui import QFont, QTextCursor from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QScrollArea, QSizePolicy, @@ -25,6 +23,7 @@ ) from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label +from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader from synodic_client.application.theme import ( LOG_CHEVRON_STYLE, LOG_COLOR_ERROR, @@ -46,10 +45,6 @@ logger = logging.getLogger(__name__) -# Unicode chevrons -CHEVRON_DOWN = '\u25bc' -CHEVRON_RIGHT = '\u25b6' - class ActionLogSection(QWidget): """A single collapsible action log section. @@ -75,15 +70,10 @@ def __init__(self, action: SetupAction, index: int, parent: QWidget | None = Non layout.setSpacing(0) # --- Header --- - self._header = QWidget() - self._header.setObjectName('sectionHeader') - self._header.setStyleSheet(LOG_SECTION_HEADER_STYLE) - self._header.setCursor(Qt.CursorShape.PointingHandCursor) - self._header.mousePressEvent = lambda _event: self._toggle() - - header_layout = QHBoxLayout(self._header) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(6) + self._header = ClickableHeader('sectionHeader', LOG_SECTION_HEADER_STYLE, parent=self) + self._header.clicked.connect(self._toggle) + + header_layout = self._header.header_layout self._chevron = QLabel(CHEVRON_DOWN) self._chevron.setStyleSheet(LOG_CHEVRON_STYLE) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 9386157..3d28027 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -10,16 +10,18 @@ from porringer.schema import DirectoryValidationResult, ManifestDirectory, PluginInfo, SetupResults from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QThread, Signal -from PySide6.QtGui import QStandardItem +from PySide6.QtGui import QResizeEvent, QStandardItem from PySide6.QtWidgets import ( QComboBox, QFileDialog, + QGridLayout, QHBoxLayout, QHeaderView, QLabel, QMainWindow, QPushButton, QScrollArea, + QSizePolicy, QTableWidget, QTableWidgetItem, QTabWidget, @@ -29,9 +31,11 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen import plugin_kind_group_label +from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader from synodic_client.application.screen.install import PreviewWorker, SetupPreviewWidget from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.theme import ( + CARD_SPACING, COMPACT_MARGINS, LOG_CHEVRON_STYLE, LOG_SECTION_TITLE_STYLE, @@ -51,10 +55,6 @@ # Plugin kinds that support auto-update and per-plugin upgrade. _UPDATABLE_KINDS = frozenset({PluginKind.TOOL, PluginKind.PACKAGE}) -# Unicode chevrons -_CHEVRON_DOWN = '\u25bc' -_CHEVRON_RIGHT = '\u25b6' - @dataclass class PluginSectionData: @@ -115,19 +115,14 @@ def _build_header( show_controls: bool, *, installed: bool = True, - ) -> QWidget: + ) -> ClickableHeader: """Construct the clickable header row.""" - header = QWidget() - header.setObjectName('pluginHeader') - header.setStyleSheet(PLUGIN_SECTION_HEADER_STYLE) - header.setCursor(Qt.CursorShape.PointingHandCursor) - header.mousePressEvent = lambda _event: self._toggle() + header = ClickableHeader('pluginHeader', PLUGIN_SECTION_HEADER_STYLE) + header.clicked.connect(self._toggle) - header_layout = QHBoxLayout(header) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(6) + header_layout = header.header_layout - self._chevron = QLabel(_CHEVRON_RIGHT) + self._chevron = QLabel(CHEVRON_RIGHT) self._chevron.setStyleSheet(LOG_CHEVRON_STYLE) self._chevron.setFixedWidth(14) header_layout.addWidget(self._chevron) @@ -201,7 +196,7 @@ def _toggle(self) -> None: """Toggle the body visibility.""" self._expanded = not self._expanded self._body.setVisible(self._expanded) - self._chevron.setText(_CHEVRON_DOWN if self._expanded else _CHEVRON_RIGHT) + self._chevron.setText(CHEVRON_DOWN if self._expanded else CHEVRON_RIGHT) # --- Callbacks --- @@ -249,19 +244,14 @@ def __init__( # --- Header builder --- - def _build_header(self, kind: PluginKind) -> QWidget: + def _build_header(self, kind: PluginKind) -> ClickableHeader: """Construct the clickable group header row.""" - header = QWidget() - header.setObjectName('pluginGroupHeader') - header.setStyleSheet(PLUGIN_GROUP_HEADER_STYLE) - header.setCursor(Qt.CursorShape.PointingHandCursor) - header.mousePressEvent = lambda _event: self._toggle() + header = ClickableHeader('pluginGroupHeader', PLUGIN_GROUP_HEADER_STYLE) + header.clicked.connect(self._toggle) - header_layout = QHBoxLayout(header) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(6) + header_layout = header.header_layout - self._chevron = QLabel(_CHEVRON_DOWN) + self._chevron = QLabel(CHEVRON_DOWN) self._chevron.setStyleSheet(LOG_CHEVRON_STYLE) self._chevron.setFixedWidth(14) header_layout.addWidget(self._chevron) @@ -296,7 +286,7 @@ def _toggle(self) -> None: """Toggle the body visibility.""" self._expanded = not self._expanded self._body.setVisible(self._expanded) - self._chevron.setText(_CHEVRON_DOWN if self._expanded else _CHEVRON_RIGHT) + self._chevron.setText(CHEVRON_DOWN if self._expanded else CHEVRON_RIGHT) class PluginsView(QWidget): @@ -529,42 +519,53 @@ def __init__(self, porringer: API, parent: QWidget | None = None) -> None: self._init_ui() def _init_ui(self) -> None: - """Initialize the UI components.""" - layout = QVBoxLayout(self) - layout.setContentsMargins(*COMPACT_MARGINS) - - # Loading indicator (shown while data is fetched asynchronously) - self._loading_spinner = SpinnerWidget('Loading projects\u2026') - layout.addWidget(self._loading_spinner) + """Initialize the UI components with a grid layout. - # --- Project directory selector --- - selector_row = QHBoxLayout() - selector_row.setContentsMargins(0, 0, 0, 8) + The loading spinner is a floating overlay parented to ``self`` + but **not** part of the grid, so showing/hiding it never + changes the geometry of the rows beneath. + """ + grid = QGridLayout(self) + grid.setContentsMargins(*COMPACT_MARGINS) + grid.setVerticalSpacing(CARD_SPACING) + # Row 0 — Project directory selector self._combo = QComboBox() self._combo.setEditable(True) self._combo.setToolTip('Select a cached project directory or enter a new path') self._combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) self._combo.setMinimumContentsLength(40) self._combo.currentIndexChanged.connect(self._on_selection_changed) - selector_row.addWidget(self._combo, 1) + grid.addWidget(self._combo, 0, 0) + grid.setColumnStretch(0, 1) self._browse_btn = QPushButton('Browse…') self._browse_btn.clicked.connect(self._on_browse) - selector_row.addWidget(self._browse_btn) + grid.addWidget(self._browse_btn, 0, 1) self._remove_btn = QPushButton('Remove') self._remove_btn.setToolTip('Remove the selected directory from the cache') self._remove_btn.clicked.connect(self._on_remove) self._remove_btn.setEnabled(False) - selector_row.addWidget(self._remove_btn) - - layout.addLayout(selector_row) + grid.addWidget(self._remove_btn, 0, 2) - # --- Shared preview widget --- + # Row 1 — Shared preview widget (takes majority of space) self._preview = SetupPreviewWidget(self._porringer, self, show_close=False) self._preview.install_finished.connect(self._on_install_finished) - layout.addWidget(self._preview) + grid.addWidget(self._preview, 1, 0, 1, 3) + grid.setRowStretch(1, 1) + + # Floating overlay spinner — not in the grid layout. + # Positioned in resizeEvent to cover the full widget area. + self._loading_spinner = SpinnerWidget('Loading projects\u2026', parent=self) + self._loading_spinner.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._loading_spinner.raise_() + + # ------------------------------------------------------------------ + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + """Keep the overlay spinner filling the entire view.""" + super().resizeEvent(event) + self._loading_spinner.setGeometry(self.rect()) # --- Public API --- @@ -627,6 +628,7 @@ async def _async_refresh(self) -> None: logger.exception('Failed to refresh projects') finally: self._loading_spinner.stop() + self._loading_spinner.lower() # put behind content after loading self._combo.setEnabled(True) self._browse_btn.setEnabled(True) self._update_remove_btn() diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index bf54907..a02e3dc 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -142,3 +142,17 @@ ) PLUGIN_SECTION_SPACING = 4 """Pixels between plugin sections in the scroll area.""" + +# --------------------------------------------------------------------------- +# Card-based layout +# --------------------------------------------------------------------------- +CARD_FRAME_STYLE = ( + 'QFrame#card { border: 1px solid palette(mid); border-radius: 6px; background: palette(window); padding: 8px;}' +) +"""Rounded card frame style used for layout sections.""" + +CARD_HEADER_STYLE = 'font-weight: bold; font-size: 12px; margin-bottom: 4px;' +"""Style for a card title label.""" + +CARD_SPACING = 8 +"""Pixels between cards in a grid or box layout."""