synodic-client 0.0.1.dev35__tar.gz → 0.0.1.dev37__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/PKG-INFO +2 -2
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/pyproject.toml +10 -2
- synodic_client-0.0.1.dev37/synodic_client/_version.py +1 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/bootstrap.py +1 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/icon.py +5 -7
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/__init__.py +15 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/action_card.py +67 -30
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/card.py +1 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/install.py +31 -158
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/screen.py +18 -2
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/settings.py +5 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/spinner.py +1 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/tray.py +45 -140
- synodic_client-0.0.1.dev37/synodic_client/application/screen/update_banner.py +309 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/theme.py +73 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/cli.py +1 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/config.py +8 -5
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/logging.py +9 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/resolution.py +1 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_action_card.py +112 -13
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_install_preview.py +7 -5
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_log_panel.py +7 -5
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_logging.py +71 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_settings.py +12 -3
- synodic_client-0.0.1.dev37/tests/unit/qt/test_update_banner.py +219 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_resolution.py +4 -16
- synodic_client-0.0.1.dev35/synodic_client/_version.py +0 -1
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/README.md +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/qt.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/workers.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/tests/unit/windows/test_startup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: synodic_client
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.dev37
|
|
4
4
|
Author-Email: Synodic Software <contact@synodic.software>
|
|
5
5
|
License: LGPL-3.0-or-later
|
|
6
6
|
Project-URL: homepage, https://github.com/synodic/synodic-client
|
|
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
|
|
|
8
8
|
Requires-Python: <3.15,>=3.14
|
|
9
9
|
Requires-Dist: pyside6>=6.10.2
|
|
10
10
|
Requires-Dist: packaging>=26.0
|
|
11
|
-
Requires-Dist: porringer>=0.2.1.
|
|
11
|
+
Requires-Dist: porringer>=0.2.1.dev53
|
|
12
12
|
Requires-Dist: qasync>=0.28.0
|
|
13
13
|
Requires-Dist: velopack>=0.0.1442.dev64255
|
|
14
14
|
Requires-Dist: typer>=0.24.1
|
|
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
|
|
|
10
10
|
dependencies = [
|
|
11
11
|
"pyside6>=6.10.2",
|
|
12
12
|
"packaging>=26.0",
|
|
13
|
-
"porringer>=0.2.1.
|
|
13
|
+
"porringer>=0.2.1.dev53",
|
|
14
14
|
"qasync>=0.28.0",
|
|
15
15
|
"velopack>=0.0.1442.dev64255",
|
|
16
16
|
"typer>=0.24.1",
|
|
17
17
|
]
|
|
18
|
-
version = "0.0.1.
|
|
18
|
+
version = "0.0.1.dev37"
|
|
19
19
|
|
|
20
20
|
[project.license]
|
|
21
21
|
text = "LGPL-3.0-or-later"
|
|
@@ -75,6 +75,14 @@ select = [
|
|
|
75
75
|
"PT",
|
|
76
76
|
]
|
|
77
77
|
|
|
78
|
+
[tool.ruff.lint.per-file-ignores]
|
|
79
|
+
"synodic_client/application/bootstrap.py" = [
|
|
80
|
+
"E402",
|
|
81
|
+
]
|
|
82
|
+
"synodic_client/cli.py" = [
|
|
83
|
+
"PLC0415",
|
|
84
|
+
]
|
|
85
|
+
|
|
78
86
|
[tool.ruff.lint.pydocstyle]
|
|
79
87
|
convention = "google"
|
|
80
88
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.0.1.dev37'
|
{synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/bootstrap.py
RENAMED
|
@@ -44,7 +44,7 @@ if not _dev_mode:
|
|
|
44
44
|
remove_startup()
|
|
45
45
|
|
|
46
46
|
# Heavy imports happen here — PySide6, porringer, etc.
|
|
47
|
-
from synodic_client.application.qt import application
|
|
47
|
+
from synodic_client.application.qt import application
|
|
48
48
|
|
|
49
49
|
_uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
|
|
50
50
|
application(uri=_uri, dev_mode=_dev_mode)
|
{synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/icon.py
RENAMED
|
@@ -4,13 +4,14 @@ Loads the icon once from package resources and caches the ``QIcon``
|
|
|
4
4
|
so every caller shares the same instance.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import functools
|
|
8
|
+
|
|
7
9
|
from PySide6.QtGui import QIcon, QPixmap
|
|
8
10
|
|
|
9
11
|
from synodic_client.client import Client
|
|
10
12
|
|
|
11
|
-
_cached_icon: QIcon | None = None
|
|
12
|
-
|
|
13
13
|
|
|
14
|
+
@functools.cache
|
|
14
15
|
def app_icon() -> QIcon:
|
|
15
16
|
"""Return the shared application ``QIcon``, loading it on first call.
|
|
16
17
|
|
|
@@ -20,8 +21,5 @@ def app_icon() -> QIcon:
|
|
|
20
21
|
Returns:
|
|
21
22
|
A ``QIcon`` backed by the application logo.
|
|
22
23
|
"""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
with Client.resource(Client.icon) as icon_path:
|
|
26
|
-
_cached_icon = QIcon(QPixmap(str(icon_path)))
|
|
27
|
-
return _cached_icon
|
|
24
|
+
with Client.resource(Client.icon) as icon_path:
|
|
25
|
+
return QIcon(QPixmap(str(icon_path)))
|
|
@@ -6,7 +6,7 @@ execution log panel live here to avoid circular imports.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from porringer.schema import SkipReason
|
|
9
|
+
from porringer.schema import SetupAction, SkipReason
|
|
10
10
|
from porringer.schema.plugin import PluginKind
|
|
11
11
|
|
|
12
12
|
ACTION_KIND_LABELS: dict[PluginKind | None, str] = {
|
|
@@ -54,3 +54,17 @@ def skip_reason_label(reason: SkipReason | None) -> str:
|
|
|
54
54
|
if reason is None:
|
|
55
55
|
return 'Skipped'
|
|
56
56
|
return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_cli_command(action: SetupAction) -> str:
|
|
60
|
+
"""Return a human-readable CLI command string for *action*.
|
|
61
|
+
|
|
62
|
+
Prefers ``cli_command``, falls back to ``command``, then synthesises
|
|
63
|
+
an ``installer install <package>`` string for package actions, and
|
|
64
|
+
finally returns the action description as a last resort.
|
|
65
|
+
"""
|
|
66
|
+
if parts := (action.cli_command or action.command):
|
|
67
|
+
return ' '.join(parts)
|
|
68
|
+
if action.kind == PluginKind.PACKAGE and action.package:
|
|
69
|
+
return f'{action.installer or "pip"} install {action.package}'
|
|
70
|
+
return action.description
|
|
@@ -33,7 +33,7 @@ from PySide6.QtWidgets import (
|
|
|
33
33
|
QWidget,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label
|
|
36
|
+
from synodic_client.application.screen import ACTION_KIND_LABELS, format_cli_command, skip_reason_label
|
|
37
37
|
from synodic_client.application.theme import (
|
|
38
38
|
ACTION_CARD_COMMAND_STYLE,
|
|
39
39
|
ACTION_CARD_DESC_STYLE,
|
|
@@ -48,6 +48,7 @@ from synodic_client.application.theme import (
|
|
|
48
48
|
ACTION_CARD_STATUS_DONE,
|
|
49
49
|
ACTION_CARD_STATUS_FAILED,
|
|
50
50
|
ACTION_CARD_STATUS_NEEDED,
|
|
51
|
+
ACTION_CARD_STATUS_PENDING,
|
|
51
52
|
ACTION_CARD_STATUS_RUNNING,
|
|
52
53
|
ACTION_CARD_STATUS_SATISFIED,
|
|
53
54
|
ACTION_CARD_STATUS_SKIPPED,
|
|
@@ -113,12 +114,14 @@ def action_sort_key(action: SetupAction) -> int:
|
|
|
113
114
|
|
|
114
115
|
|
|
115
116
|
def _format_command(action: SetupAction) -> str:
|
|
116
|
-
"""Return a short CLI command string for display.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
"""Return a short CLI command string for display.
|
|
118
|
+
|
|
119
|
+
Wraps :func:`~synodic_client.application.screen.format_cli_command`
|
|
120
|
+
but returns an empty string instead of the description fallback so
|
|
121
|
+
cards only show an explicit command line.
|
|
122
|
+
"""
|
|
123
|
+
text = format_cli_command(action)
|
|
124
|
+
return '' if text == action.description else text
|
|
122
125
|
|
|
123
126
|
|
|
124
127
|
# ---------------------------------------------------------------------------
|
|
@@ -138,7 +141,7 @@ class _CardSpinner(QWidget):
|
|
|
138
141
|
self._angle = 0
|
|
139
142
|
self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE)
|
|
140
143
|
|
|
141
|
-
def paintEvent(self, _event: object) -> None:
|
|
144
|
+
def paintEvent(self, _event: object) -> None:
|
|
142
145
|
"""Draw the muted track and animated highlight arc."""
|
|
143
146
|
painter = QPainter(self)
|
|
144
147
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
@@ -379,7 +382,7 @@ class ActionCard(QFrame):
|
|
|
379
382
|
# Mouse events (toggle log)
|
|
380
383
|
# ------------------------------------------------------------------
|
|
381
384
|
|
|
382
|
-
def mousePressEvent(self, event: object) -> None:
|
|
385
|
+
def mousePressEvent(self, event: object) -> None:
|
|
383
386
|
"""Toggle the inline log body on click."""
|
|
384
387
|
if self._is_skeleton or not hasattr(self, '_log_output'):
|
|
385
388
|
return
|
|
@@ -471,23 +474,8 @@ class ActionCard(QFrame):
|
|
|
471
474
|
|
|
472
475
|
self._version_label.setText('')
|
|
473
476
|
|
|
474
|
-
# Status
|
|
475
|
-
|
|
476
|
-
action.installer is not None
|
|
477
|
-
and action.installer in plugin_installed
|
|
478
|
-
and not plugin_installed[action.installer]
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
if installer_missing:
|
|
482
|
-
self._status_label.setText('Not installed')
|
|
483
|
-
self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
|
|
484
|
-
self._status_label.show()
|
|
485
|
-
else:
|
|
486
|
-
# Show spinner instead of status text while checking
|
|
487
|
-
self._status_label.hide()
|
|
488
|
-
self._checking = True
|
|
489
|
-
self._spinner_canvas.show()
|
|
490
|
-
self._spinner_timer.start()
|
|
477
|
+
# Status
|
|
478
|
+
self._populate_status(action, plugin_installed)
|
|
491
479
|
|
|
492
480
|
# Pre-release checkbox
|
|
493
481
|
if action.package is not None:
|
|
@@ -525,6 +513,40 @@ class ActionCard(QFrame):
|
|
|
525
513
|
else:
|
|
526
514
|
self._command_row.hide()
|
|
527
515
|
|
|
516
|
+
def _populate_status(
|
|
517
|
+
self,
|
|
518
|
+
action: SetupAction,
|
|
519
|
+
plugin_installed: dict[str, bool],
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Set the initial status badge during :meth:`populate`.
|
|
522
|
+
|
|
523
|
+
Bare-command actions (``kind is None``) show a static *Pending*
|
|
524
|
+
badge. Plugin-backed actions either flag a missing installer or
|
|
525
|
+
start the dry-run spinner.
|
|
526
|
+
"""
|
|
527
|
+
if action.kind is None:
|
|
528
|
+
self._status_label.setText('Pending')
|
|
529
|
+
self._status_label.setStyleSheet(ACTION_CARD_STATUS_PENDING)
|
|
530
|
+
self._status_label.show()
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
installer_missing = (
|
|
534
|
+
action.installer is not None
|
|
535
|
+
and action.installer in plugin_installed
|
|
536
|
+
and not plugin_installed[action.installer]
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if installer_missing:
|
|
540
|
+
self._status_label.setText('Not installed')
|
|
541
|
+
self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
|
|
542
|
+
self._status_label.show()
|
|
543
|
+
else:
|
|
544
|
+
# Show spinner instead of status text while checking
|
|
545
|
+
self._status_label.hide()
|
|
546
|
+
self._checking = True
|
|
547
|
+
self._spinner_canvas.show()
|
|
548
|
+
self._spinner_timer.start()
|
|
549
|
+
|
|
528
550
|
def initial_status(self) -> str:
|
|
529
551
|
"""Return the initial status text set during :meth:`populate`."""
|
|
530
552
|
if self._is_skeleton or not hasattr(self, '_status_label'):
|
|
@@ -549,6 +571,15 @@ class ActionCard(QFrame):
|
|
|
549
571
|
def set_check_result(self, result: SetupActionResult) -> None:
|
|
550
572
|
"""Update the card with a dry-run check result.
|
|
551
573
|
|
|
574
|
+
Handles four cases:
|
|
575
|
+
|
|
576
|
+
* **Skipped (update available)** — amber "Update available" badge.
|
|
577
|
+
* **Skipped (other)** — muted satisfied badge.
|
|
578
|
+
* **Failed** — red "Failed" badge with diagnostic tooltip.
|
|
579
|
+
This covers backend failures surfaced during the dry-run
|
|
580
|
+
(e.g. missing SCM plugin, unresolvable deferred action).
|
|
581
|
+
* **Needed** — default blue badge.
|
|
582
|
+
|
|
552
583
|
Args:
|
|
553
584
|
result: The action check result from the preview worker.
|
|
554
585
|
"""
|
|
@@ -565,6 +596,15 @@ class ActionCard(QFrame):
|
|
|
565
596
|
label = skip_reason_label(result.skip_reason)
|
|
566
597
|
self._status_label.setText(label)
|
|
567
598
|
self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
|
|
599
|
+
elif not result.success:
|
|
600
|
+
label = 'Failed'
|
|
601
|
+
self._status_label.setText(label)
|
|
602
|
+
self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED)
|
|
603
|
+
logger.warning(
|
|
604
|
+
'Dry-run check failed for %s: %s',
|
|
605
|
+
self._action.description if self._action else '(unknown)',
|
|
606
|
+
result.message or 'unknown error',
|
|
607
|
+
)
|
|
568
608
|
else:
|
|
569
609
|
label = 'Needed'
|
|
570
610
|
self._status_label.setText(label)
|
|
@@ -768,10 +808,7 @@ class ActionCardList(QScrollArea):
|
|
|
768
808
|
prerelease_overrides: Package names with user pre-release overrides.
|
|
769
809
|
"""
|
|
770
810
|
self.clear()
|
|
771
|
-
sorted_actions = sorted(
|
|
772
|
-
(a for a in actions if a.kind is not None),
|
|
773
|
-
key=action_sort_key,
|
|
774
|
-
)
|
|
811
|
+
sorted_actions = sorted(actions, key=action_sort_key)
|
|
775
812
|
for act in sorted_actions:
|
|
776
813
|
card = ActionCard(self._container)
|
|
777
814
|
card.populate(
|
{synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/card.py
RENAMED
|
@@ -64,7 +64,7 @@ class ClickableHeader(QWidget):
|
|
|
64
64
|
|
|
65
65
|
# --- Event handling ---------------------------------------------------
|
|
66
66
|
|
|
67
|
-
def mousePressEvent(self, _event: object) -> None:
|
|
67
|
+
def mousePressEvent(self, _event: object) -> None:
|
|
68
68
|
"""Emit :attr:`clicked` on any mouse press."""
|
|
69
69
|
self.clicked.emit()
|
|
70
70
|
|
|
@@ -14,6 +14,7 @@ import asyncio
|
|
|
14
14
|
import logging
|
|
15
15
|
import shutil
|
|
16
16
|
import tempfile
|
|
17
|
+
from dataclasses import dataclass, field
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any
|
|
19
20
|
from urllib.parse import urlparse
|
|
@@ -32,11 +33,8 @@ from porringer.schema import (
|
|
|
32
33
|
SubActionProgress,
|
|
33
34
|
SyncStrategy,
|
|
34
35
|
)
|
|
35
|
-
from
|
|
36
|
-
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
|
37
|
-
from PySide6.QtGui import QFont
|
|
36
|
+
from PySide6.QtCore import QThread, QTimer, Signal
|
|
38
37
|
from PySide6.QtWidgets import (
|
|
39
|
-
QApplication,
|
|
40
38
|
QFileDialog,
|
|
41
39
|
QFrame,
|
|
42
40
|
QHBoxLayout,
|
|
@@ -45,7 +43,6 @@ from PySide6.QtWidgets import (
|
|
|
45
43
|
QMainWindow,
|
|
46
44
|
QMessageBox,
|
|
47
45
|
QPushButton,
|
|
48
|
-
QToolButton,
|
|
49
46
|
QVBoxLayout,
|
|
50
47
|
QWidget,
|
|
51
48
|
)
|
|
@@ -56,19 +53,12 @@ from synodic_client.application.screen.card import CardFrame
|
|
|
56
53
|
from synodic_client.application.theme import (
|
|
57
54
|
ACTION_CARD_SKELETON_BAR_STYLE,
|
|
58
55
|
CARD_SPACING,
|
|
59
|
-
COMMAND_HEADER_STYLE,
|
|
60
56
|
COMPACT_MARGINS,
|
|
61
57
|
CONTENT_MARGINS,
|
|
62
|
-
COPY_BTN_SIZE,
|
|
63
|
-
COPY_BTN_STYLE,
|
|
64
|
-
COPY_FEEDBACK_MS,
|
|
65
|
-
COPY_ICON,
|
|
66
58
|
HEADER_STYLE,
|
|
67
59
|
INSTALL_PREVIEW_MIN_SIZE,
|
|
68
60
|
METADATA_SKELETON_HEIGHT,
|
|
69
61
|
METADATA_SKELETON_STYLE,
|
|
70
|
-
MONOSPACE_FAMILY,
|
|
71
|
-
MONOSPACE_SIZE,
|
|
72
62
|
MUTED_STYLE,
|
|
73
63
|
NO_MARGINS,
|
|
74
64
|
)
|
|
@@ -94,13 +84,13 @@ def normalize_manifest_key(path_or_url: str) -> str:
|
|
|
94
84
|
return path_or_url
|
|
95
85
|
|
|
96
86
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class InstallConfig:
|
|
89
|
+
"""Optional execution parameters for :class:`InstallWorker`."""
|
|
90
|
+
|
|
91
|
+
project_directory: Path | None = None
|
|
92
|
+
strategy: SyncStrategy = SyncStrategy.MINIMAL
|
|
93
|
+
prerelease_packages: set[str] | None = field(default=None)
|
|
104
94
|
|
|
105
95
|
|
|
106
96
|
class InstallWorker(QThread):
|
|
@@ -116,15 +106,12 @@ class InstallWorker(QThread):
|
|
|
116
106
|
sub_progress = Signal(object, object) # (SetupAction, SubActionProgress)
|
|
117
107
|
error = Signal(str)
|
|
118
108
|
|
|
119
|
-
def __init__(
|
|
109
|
+
def __init__(
|
|
120
110
|
self,
|
|
121
111
|
porringer: API,
|
|
122
112
|
manifest_path: Path,
|
|
123
113
|
cancellation_token: CancellationToken,
|
|
124
|
-
|
|
125
|
-
project_directory: Path | None = None,
|
|
126
|
-
strategy: SyncStrategy = SyncStrategy.MINIMAL,
|
|
127
|
-
prerelease_packages: set[str] | None = None,
|
|
114
|
+
config: InstallConfig | None = None,
|
|
128
115
|
) -> None:
|
|
129
116
|
"""Initialize the worker.
|
|
130
117
|
|
|
@@ -132,19 +119,14 @@ class InstallWorker(QThread):
|
|
|
132
119
|
porringer: The porringer API instance.
|
|
133
120
|
manifest_path: Path to the manifest file to execute.
|
|
134
121
|
cancellation_token: Token for cooperative cancellation.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
prerelease_packages: Package names whose ``include_prereleases``
|
|
138
|
-
flag should be forced to ``True``, overriding the manifest
|
|
139
|
-
default.
|
|
122
|
+
config: Optional execution parameters (directory, strategy,
|
|
123
|
+
prerelease overrides).
|
|
140
124
|
"""
|
|
141
125
|
super().__init__()
|
|
142
126
|
self._porringer = porringer
|
|
143
127
|
self._manifest_path = manifest_path
|
|
144
128
|
self._cancellation_token = cancellation_token
|
|
145
|
-
self.
|
|
146
|
-
self._strategy = strategy
|
|
147
|
-
self._prerelease_packages = prerelease_packages
|
|
129
|
+
self._config = config or InstallConfig()
|
|
148
130
|
|
|
149
131
|
def run(self) -> None:
|
|
150
132
|
"""Execute the setup actions on this thread's event loop."""
|
|
@@ -161,9 +143,9 @@ class InstallWorker(QThread):
|
|
|
161
143
|
"""Stream execution events and collect results."""
|
|
162
144
|
params = SetupParameters(
|
|
163
145
|
paths=[self._manifest_path],
|
|
164
|
-
project_directory=self.
|
|
165
|
-
strategy=self.
|
|
166
|
-
prerelease_packages=self.
|
|
146
|
+
project_directory=self._config.project_directory,
|
|
147
|
+
strategy=self._config.strategy,
|
|
148
|
+
prerelease_packages=self._config.prerelease_packages,
|
|
167
149
|
)
|
|
168
150
|
actions: list[SetupAction] = []
|
|
169
151
|
collected: list[SetupActionResult] = []
|
|
@@ -195,109 +177,6 @@ class InstallWorker(QThread):
|
|
|
195
177
|
)
|
|
196
178
|
|
|
197
179
|
|
|
198
|
-
class PostInstallSection(QWidget):
|
|
199
|
-
"""Always-visible section showing bare-command (post-install) actions.
|
|
200
|
-
|
|
201
|
-
Bare-command actions (``kind is None``) cannot be dry-run checked, so
|
|
202
|
-
they are excluded from the main actions table. This widget gives
|
|
203
|
-
them a dedicated, always-visible home with copyable CLI text.
|
|
204
|
-
"""
|
|
205
|
-
|
|
206
|
-
def __init__(self, parent: QWidget | None = None) -> None:
|
|
207
|
-
"""Initialise the section (hidden until :meth:`populate` is called)."""
|
|
208
|
-
super().__init__(parent)
|
|
209
|
-
self.hide()
|
|
210
|
-
|
|
211
|
-
self._layout = QVBoxLayout(self)
|
|
212
|
-
self._layout.setContentsMargins(*NO_MARGINS)
|
|
213
|
-
self._layout.setSpacing(4)
|
|
214
|
-
|
|
215
|
-
header = QLabel('Post-Install Commands')
|
|
216
|
-
header.setStyleSheet(COMMAND_HEADER_STYLE)
|
|
217
|
-
self._layout.addWidget(header)
|
|
218
|
-
|
|
219
|
-
self._content = QWidget()
|
|
220
|
-
self._content_layout = QVBoxLayout(self._content)
|
|
221
|
-
self._content_layout.setContentsMargins(*NO_MARGINS)
|
|
222
|
-
self._content_layout.setSpacing(4)
|
|
223
|
-
self._layout.addWidget(self._content)
|
|
224
|
-
|
|
225
|
-
def populate(self, actions: list[SetupAction]) -> None:
|
|
226
|
-
"""Show command actions from *actions*.
|
|
227
|
-
|
|
228
|
-
Only actions whose ``kind`` is ``None`` are shown. If there are
|
|
229
|
-
none the widget stays hidden.
|
|
230
|
-
|
|
231
|
-
Args:
|
|
232
|
-
actions: The full list of setup actions.
|
|
233
|
-
"""
|
|
234
|
-
# Clear previous content
|
|
235
|
-
while self._content_layout.count():
|
|
236
|
-
item = self._content_layout.takeAt(0)
|
|
237
|
-
if item is not None:
|
|
238
|
-
widget = item.widget()
|
|
239
|
-
if widget is not None:
|
|
240
|
-
widget.deleteLater()
|
|
241
|
-
|
|
242
|
-
commands = [(i, a) for i, a in enumerate(actions, 1) if a.kind is None]
|
|
243
|
-
if not commands:
|
|
244
|
-
self.hide()
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
mono = QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE)
|
|
248
|
-
for idx, action in commands:
|
|
249
|
-
desc = action.package_description or action.description
|
|
250
|
-
label = QLabel(f'{idx}. {desc}')
|
|
251
|
-
label.setStyleSheet(COMMAND_HEADER_STYLE)
|
|
252
|
-
self._content_layout.addWidget(label)
|
|
253
|
-
|
|
254
|
-
field = QLineEdit(format_cli_command(action))
|
|
255
|
-
field.setReadOnly(True)
|
|
256
|
-
field.setFont(mono)
|
|
257
|
-
|
|
258
|
-
row_layout = QHBoxLayout()
|
|
259
|
-
row_layout.setContentsMargins(*NO_MARGINS)
|
|
260
|
-
row_layout.setSpacing(4)
|
|
261
|
-
row_layout.addWidget(field)
|
|
262
|
-
row_layout.addWidget(_make_copy_button(field))
|
|
263
|
-
|
|
264
|
-
row_widget = QWidget()
|
|
265
|
-
row_widget.setLayout(row_layout)
|
|
266
|
-
self._content_layout.addWidget(row_widget)
|
|
267
|
-
|
|
268
|
-
self.show()
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def _make_copy_button(field: QLineEdit) -> QToolButton:
|
|
272
|
-
"""Create a copy-to-clipboard button bound to *field*."""
|
|
273
|
-
btn = QToolButton()
|
|
274
|
-
btn.setText(COPY_ICON)
|
|
275
|
-
btn.setToolTip('Copy to clipboard')
|
|
276
|
-
btn.setFixedSize(*COPY_BTN_SIZE)
|
|
277
|
-
btn.setStyleSheet(COPY_BTN_STYLE)
|
|
278
|
-
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
279
|
-
btn.clicked.connect(lambda: _copy_command(field, btn))
|
|
280
|
-
return btn
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _copy_command(field: QLineEdit, button: QToolButton) -> None:
|
|
284
|
-
"""Copy the field text to the clipboard and briefly show a check mark."""
|
|
285
|
-
clipboard = QApplication.clipboard()
|
|
286
|
-
if clipboard:
|
|
287
|
-
clipboard.setText(field.text())
|
|
288
|
-
button.setText('\u2713')
|
|
289
|
-
button.setToolTip('Copied!')
|
|
290
|
-
|
|
291
|
-
def _restore() -> None:
|
|
292
|
-
try:
|
|
293
|
-
button.setText(COPY_ICON)
|
|
294
|
-
button.setToolTip('Copy to clipboard')
|
|
295
|
-
except RuntimeError:
|
|
296
|
-
pass
|
|
297
|
-
|
|
298
|
-
QTimer.singleShot(COPY_FEEDBACK_MS, _restore)
|
|
299
|
-
|
|
300
|
-
|
|
301
180
|
# ---------------------------------------------------------------------------
|
|
302
181
|
# SetupPreviewWidget — reusable preview + install widget
|
|
303
182
|
# ---------------------------------------------------------------------------
|
|
@@ -400,10 +279,6 @@ class SetupPreviewWidget(QWidget):
|
|
|
400
279
|
self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
|
|
401
280
|
outer.addWidget(self._card_list, stretch=1)
|
|
402
281
|
|
|
403
|
-
# Post-install section lives below the card list but still scrolls.
|
|
404
|
-
# It starts hidden and is inserted into the layout after populate().
|
|
405
|
-
self._post_install_section = PostInstallSection()
|
|
406
|
-
|
|
407
282
|
# --- Button bar (fixed at bottom) ---
|
|
408
283
|
button_bar = self._init_button_bar()
|
|
409
284
|
outer.addLayout(button_bar)
|
|
@@ -524,7 +399,6 @@ class SetupPreviewWidget(QWidget):
|
|
|
524
399
|
self._prerelease_debounce.stop()
|
|
525
400
|
|
|
526
401
|
self._card_list.clear()
|
|
527
|
-
self._post_install_section.hide()
|
|
528
402
|
self._name_label.hide()
|
|
529
403
|
self._description_label.hide()
|
|
530
404
|
self._meta_label.hide()
|
|
@@ -643,8 +517,6 @@ class SetupPreviewWidget(QWidget):
|
|
|
643
517
|
|
|
644
518
|
# Mark installer-missing actions as 'Not installed' in the status list
|
|
645
519
|
for i, action in enumerate(preview.actions):
|
|
646
|
-
if action.kind is None:
|
|
647
|
-
continue
|
|
648
520
|
installer_missing = (
|
|
649
521
|
action.installer is not None
|
|
650
522
|
and action.installer in self._plugin_installed
|
|
@@ -653,13 +525,6 @@ class SetupPreviewWidget(QWidget):
|
|
|
653
525
|
if installer_missing:
|
|
654
526
|
self._action_statuses[i] = 'Not installed'
|
|
655
527
|
|
|
656
|
-
# Populate post-install commands and place them after all cards.
|
|
657
|
-
self._post_install_section.populate(preview.actions)
|
|
658
|
-
self._card_list._layout.insertWidget(
|
|
659
|
-
self._card_list._layout.count() - 1,
|
|
660
|
-
self._post_install_section,
|
|
661
|
-
)
|
|
662
|
-
|
|
663
528
|
self._install_btn.setEnabled(True)
|
|
664
529
|
|
|
665
530
|
def on_preview_resolved(self, preview: SetupResults) -> None:
|
|
@@ -688,6 +553,8 @@ class SetupPreviewWidget(QWidget):
|
|
|
688
553
|
self._upgradable_rows.add(row)
|
|
689
554
|
elif result.skipped:
|
|
690
555
|
label = skip_reason_label(result.skip_reason)
|
|
556
|
+
elif not result.success:
|
|
557
|
+
label = 'Failed'
|
|
691
558
|
else:
|
|
692
559
|
label = 'Needed'
|
|
693
560
|
|
|
@@ -724,7 +591,8 @@ class SetupPreviewWidget(QWidget):
|
|
|
724
591
|
needed = sum(1 for s in self._action_statuses if s == 'Needed')
|
|
725
592
|
upgradable = len(self._upgradable_rows)
|
|
726
593
|
unavailable = sum(1 for s in self._action_statuses if s == 'Not installed')
|
|
727
|
-
|
|
594
|
+
failed = sum(1 for s in self._action_statuses if s == 'Failed')
|
|
595
|
+
satisfied = total - needed - upgradable - unavailable - failed
|
|
728
596
|
|
|
729
597
|
parts: list[str] = []
|
|
730
598
|
if needed:
|
|
@@ -735,21 +603,24 @@ class SetupPreviewWidget(QWidget):
|
|
|
735
603
|
parts.append(f'{satisfied} already satisfied')
|
|
736
604
|
if unavailable:
|
|
737
605
|
parts.append(f'{unavailable} unavailable (plugin not installed)')
|
|
606
|
+
if failed:
|
|
607
|
+
parts.append(f'{failed} failed')
|
|
738
608
|
|
|
739
609
|
actionable = needed + upgradable
|
|
740
|
-
if actionable == 0 and unavailable == 0:
|
|
610
|
+
if actionable == 0 and unavailable == 0 and failed == 0:
|
|
741
611
|
self._status_label.setText(f'{total} action(s) \u2014 all already satisfied.')
|
|
742
612
|
self._install_btn.setEnabled(False)
|
|
743
613
|
else:
|
|
744
614
|
self._status_label.setText(f'{total} action(s): {", ".join(parts)}.')
|
|
745
615
|
|
|
746
616
|
logger.info(
|
|
747
|
-
'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable',
|
|
617
|
+
'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable, %d failed',
|
|
748
618
|
total,
|
|
749
619
|
needed,
|
|
750
620
|
upgradable,
|
|
751
621
|
satisfied,
|
|
752
622
|
unavailable,
|
|
623
|
+
failed,
|
|
753
624
|
)
|
|
754
625
|
|
|
755
626
|
def on_preview_error(self, message: str) -> None:
|
|
@@ -818,9 +689,11 @@ class SetupPreviewWidget(QWidget):
|
|
|
818
689
|
self._porringer,
|
|
819
690
|
self._manifest_path,
|
|
820
691
|
self._cancellation_token,
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
692
|
+
InstallConfig(
|
|
693
|
+
project_directory=self._project_directory,
|
|
694
|
+
strategy=strategy,
|
|
695
|
+
prerelease_packages=self._prerelease_overrides or None,
|
|
696
|
+
),
|
|
824
697
|
)
|
|
825
698
|
worker.action_started.connect(self._on_action_started)
|
|
826
699
|
worker.sub_progress.connect(self._on_sub_progress)
|
|
@@ -38,6 +38,7 @@ from synodic_client.application.screen.install import (
|
|
|
38
38
|
normalize_manifest_key,
|
|
39
39
|
)
|
|
40
40
|
from synodic_client.application.screen.spinner import SpinnerWidget
|
|
41
|
+
from synodic_client.application.screen.update_banner import UpdateBanner
|
|
41
42
|
from synodic_client.application.theme import (
|
|
42
43
|
CARD_SPACING,
|
|
43
44
|
COMPACT_MARGINS,
|
|
@@ -565,7 +566,7 @@ class ProjectsView(QWidget):
|
|
|
565
566
|
self._loading_spinner.raise_()
|
|
566
567
|
|
|
567
568
|
# ------------------------------------------------------------------
|
|
568
|
-
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
569
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
569
570
|
"""Keep the overlay spinner filling the entire view."""
|
|
570
571
|
super().resizeEvent(event)
|
|
571
572
|
self._loading_spinner.setGeometry(self.rect())
|
|
@@ -815,6 +816,9 @@ class MainWindow(QMainWindow):
|
|
|
815
816
|
self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
|
|
816
817
|
self.setWindowIcon(app_icon())
|
|
817
818
|
|
|
819
|
+
# Update banner — always available, starts hidden.
|
|
820
|
+
self._update_banner = UpdateBanner(self)
|
|
821
|
+
|
|
818
822
|
@property
|
|
819
823
|
def porringer(self) -> API | None:
|
|
820
824
|
"""Return the porringer API instance, if available."""
|
|
@@ -825,6 +829,11 @@ class MainWindow(QMainWindow):
|
|
|
825
829
|
"""Return the plugins view, if initialised."""
|
|
826
830
|
return self._plugins_view
|
|
827
831
|
|
|
832
|
+
@property
|
|
833
|
+
def update_banner(self) -> UpdateBanner:
|
|
834
|
+
"""Return the update banner widget."""
|
|
835
|
+
return self._update_banner
|
|
836
|
+
|
|
828
837
|
def show(self) -> None:
|
|
829
838
|
"""Show the window, initializing UI lazily on first show."""
|
|
830
839
|
if self._tabs is None and self._porringer is not None and self._config is not None:
|
|
@@ -843,7 +852,14 @@ class MainWindow(QMainWindow):
|
|
|
843
852
|
gear_btn.clicked.connect(self.settings_requested.emit)
|
|
844
853
|
self._tabs.setCornerWidget(gear_btn)
|
|
845
854
|
|
|
846
|
-
|
|
855
|
+
# Container: banner above tabs
|
|
856
|
+
container = QWidget(self)
|
|
857
|
+
container_layout = QVBoxLayout(container)
|
|
858
|
+
container_layout.setContentsMargins(0, 0, 0, 0)
|
|
859
|
+
container_layout.setSpacing(0)
|
|
860
|
+
container_layout.addWidget(self._update_banner)
|
|
861
|
+
container_layout.addWidget(self._tabs)
|
|
862
|
+
self.setCentralWidget(container)
|
|
847
863
|
|
|
848
864
|
# Paint the window immediately, then refresh data asynchronously
|
|
849
865
|
super().show()
|