synodic-client 0.0.1.dev28__tar.gz → 0.0.1.dev29__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/pyproject.toml +1 -1
  3. synodic_client-0.0.1.dev29/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/action_card.py +80 -36
  5. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/install.py +7 -3
  6. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/screen.py +5 -3
  7. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/tray.py +7 -4
  8. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/updater.py +14 -0
  9. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_action_card.py +38 -20
  10. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_install_preview.py +152 -0
  11. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_updater.py +28 -0
  12. synodic_client-0.0.1.dev28/synodic_client/_version.py +0 -1
  13. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/LICENSE.md +0 -0
  14. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/README.md +0 -0
  15. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/__init__.py +0 -0
  16. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/__main__.py +0 -0
  17. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/__init__.py +0 -0
  18. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/bootstrap.py +0 -0
  19. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/icon.py +0 -0
  20. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/instance.py +0 -0
  21. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/qt.py +0 -0
  22. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/__init__.py +0 -0
  23. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/card.py +0 -0
  24. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/log_panel.py +0 -0
  25. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/spinner.py +0 -0
  26. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/theme.py +0 -0
  27. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/uri.py +0 -0
  28. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/cli.py +0 -0
  29. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/client.py +0 -0
  30. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/config.py +0 -0
  31. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/logging.py +0 -0
  32. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/protocol.py +0 -0
  33. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/py.typed +0 -0
  34. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/resolution.py +0 -0
  35. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/startup.py +0 -0
  36. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/__init__.py +0 -0
  37. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/conftest.py +0 -0
  38. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/__init__.py +0 -0
  39. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/__init__.py +0 -0
  40. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/conftest.py +0 -0
  41. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_log_panel.py +0 -0
  42. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_logging.py +0 -0
  43. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_cli.py +0 -0
  44. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_client_updater.py +0 -0
  45. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_client_version.py +0 -0
  46. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_config.py +0 -0
  47. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_examples.py +0 -0
  48. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_install.py +0 -0
  49. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_resolution.py +0 -0
  50. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_uri.py +0 -0
  51. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/__init__.py +0 -0
  52. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/conftest.py +0 -0
  53. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/test_protocol.py +0 -0
  54. {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/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.dev28
3
+ Version: 0.0.1.dev29
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
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1369.dev7516",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev28"
18
+ version = "0.0.1.dev29"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev29'
@@ -14,11 +14,13 @@ from __future__ import annotations
14
14
  import html as html_mod
15
15
  import logging
16
16
 
17
+ from porringer.backend.command.core.action_builder import PHASE_ORDER
17
18
  from porringer.schema import SetupAction, SetupActionResult, SkipReason
18
19
  from porringer.schema.plugin import PluginKind
19
20
  from PySide6.QtCore import QRect, Qt, QTimer, Signal
20
21
  from PySide6.QtGui import QColor, QFont, QPainter, QPen, QTextCursor
21
22
  from PySide6.QtWidgets import (
23
+ QApplication,
22
24
  QCheckBox,
23
25
  QFrame,
24
26
  QHBoxLayout,
@@ -26,6 +28,7 @@ from PySide6.QtWidgets import (
26
28
  QScrollArea,
27
29
  QSizePolicy,
28
30
  QTextEdit,
31
+ QToolButton,
29
32
  QVBoxLayout,
30
33
  QWidget,
31
34
  )
@@ -53,6 +56,10 @@ from synodic_client.application.theme import (
53
56
  ACTION_CARD_STYLE,
54
57
  ACTION_CARD_TYPE_BADGE_STYLE,
55
58
  ACTION_CARD_VERSION_STYLE,
59
+ COPY_BTN_SIZE,
60
+ COPY_BTN_STYLE,
61
+ COPY_FEEDBACK_MS,
62
+ COPY_ICON,
56
63
  LOG_COLOR_ERROR,
57
64
  LOG_COLOR_PHASE,
58
65
  LOG_COLOR_STDERR,
@@ -74,19 +81,9 @@ _SPINNER_INTERVAL = 50
74
81
  _SPINNER_ARC = 90
75
82
 
76
83
 
77
- #: Sort priority for each :class:`PluginKind`.
78
- #: Lower numbers appear first. Matches the execution phase order
79
- #: defined in ``porringer.backend.command.core.action_builder.PHASE_ORDER``
80
- #: so that cards are displayed in the same order they execute:
81
- #: runtime → package → tool → project → SCM.
82
- _KIND_ORDER: dict[PluginKind | None, int] = {
83
- PluginKind.RUNTIME: 0,
84
- PluginKind.PACKAGE: 1,
85
- PluginKind.TOOL: 2,
86
- PluginKind.PROJECT: 3,
87
- PluginKind.SCM: 4,
88
- None: 99, # bare commands are excluded from ActionCardList anyway
89
- }
84
+ #: Sort priority derived from porringer's execution phase order so the
85
+ #: display order always matches the order actions actually execute.
86
+ _KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}
90
87
 
91
88
 
92
89
  def action_key(action: SetupAction) -> tuple[object, ...]:
@@ -102,17 +99,17 @@ def action_key(action: SetupAction) -> tuple[object, ...]:
102
99
  return (action.kind, action.installer, pkg_name, pt_name, cmd)
103
100
 
104
101
 
105
- def action_sort_key(action: SetupAction) -> tuple[int, str]:
106
- """Return a sort key so cards are grouped by kind then alphabetical.
102
+ def action_sort_key(action: SetupAction) -> int:
103
+ """Return a sort key that groups cards by execution phase.
107
104
 
108
- The ordering matches the execution phase order
109
- (runtime package tool project → SCM) so that displayed
110
- cards appear in the same sequence as they execute. Within a
111
- group, actions are sorted case-insensitively by package name.
105
+ The ordering is derived from :data:`porringer.backend.command.core.
106
+ action_builder.PHASE_ORDER` so that displayed cards appear in the
107
+ same sequence as they execute. Within a phase group the original
108
+ order from porringer is preserved (Python sort is stable), which
109
+ respects dependency ordering (e.g. a tool must be installed before
110
+ its plugins).
112
111
  """
113
- kind_order = _KIND_ORDER.get(action.kind, 50)
114
- pkg_name = str(action.package.name).lower() if action.package else ''
115
- return (kind_order, pkg_name)
112
+ return _KIND_ORDER.get(action.kind, len(PHASE_ORDER))
116
113
 
117
114
 
118
115
  def _format_command(action: SetupAction) -> str:
@@ -284,7 +281,13 @@ class ActionCard(QFrame):
284
281
  outer.setContentsMargins(6, 6, 6, 6)
285
282
  outer.setSpacing(2)
286
283
 
287
- # --- Top row: type badge | package name ... version | status/spinner | prerelease ---
284
+ outer.addLayout(self._build_top_row())
285
+ outer.addWidget(self._build_description_row())
286
+ outer.addWidget(self._build_command_row())
287
+ outer.addWidget(self._build_log_output())
288
+
289
+ def _build_top_row(self) -> QHBoxLayout:
290
+ """Build the top row: type badge | package name ... version | status/spinner | prerelease."""
288
291
  top = QHBoxLayout()
289
292
  top.setSpacing(8)
290
293
 
@@ -317,24 +320,45 @@ class ActionCard(QFrame):
317
320
  self._prerelease_cb.hide()
318
321
  top.addWidget(self._prerelease_cb)
319
322
 
320
- outer.addLayout(top)
323
+ return top
321
324
 
322
- # --- Description row ---
325
+ def _build_description_row(self) -> QLabel:
326
+ """Build the description label."""
323
327
  self._desc_label = QLabel()
324
328
  self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE)
325
329
  self._desc_label.setWordWrap(True)
326
- outer.addWidget(self._desc_label)
330
+ return self._desc_label
331
+
332
+ def _build_command_row(self) -> QWidget:
333
+ """Build the CLI command row with copy button."""
334
+ self._command_row = QWidget()
335
+ cmd_layout = QHBoxLayout(self._command_row)
336
+ cmd_layout.setContentsMargins(0, 0, 0, 0)
337
+ cmd_layout.setSpacing(4)
327
338
 
328
- # --- CLI command row (always visible, muted monospace) ---
329
339
  self._command_label = QLabel()
330
340
  self._command_label.setStyleSheet(ACTION_CARD_COMMAND_STYLE)
331
341
  self._command_label.setTextInteractionFlags(
332
342
  Qt.TextInteractionFlag.TextSelectableByMouse,
333
343
  )
334
- self._command_label.hide()
335
- outer.addWidget(self._command_label)
344
+ cmd_layout.addWidget(self._command_label)
345
+
346
+ self._copy_btn = QToolButton()
347
+ self._copy_btn.setText(COPY_ICON)
348
+ self._copy_btn.setToolTip('Copy to clipboard')
349
+ self._copy_btn.setFixedSize(*COPY_BTN_SIZE)
350
+ self._copy_btn.setStyleSheet(COPY_BTN_STYLE)
351
+ self._copy_btn.setCursor(Qt.CursorShape.PointingHandCursor)
352
+ self._copy_btn.clicked.connect(self._copy_command)
353
+ cmd_layout.addWidget(self._copy_btn)
354
+
355
+ cmd_layout.addStretch()
356
+
357
+ self._command_row.hide()
358
+ return self._command_row
336
359
 
337
- # --- Inline log body (hidden by default) ---
360
+ def _build_log_output(self) -> QTextEdit:
361
+ """Build the inline log body (hidden by default)."""
338
362
  self._log_output = QTextEdit()
339
363
  self._log_output.setReadOnly(True)
340
364
  self._log_output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE))
@@ -343,16 +367,19 @@ class ActionCard(QFrame):
343
367
  self._log_output.setMaximumHeight(250)
344
368
  self._log_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
345
369
  self._log_output.hide()
346
- outer.addWidget(self._log_output)
370
+ return self._log_output
347
371
 
348
372
  # ------------------------------------------------------------------
349
373
  # Mouse events (toggle log)
350
374
  # ------------------------------------------------------------------
351
375
 
352
- def mousePressEvent(self, _event: object) -> None: # noqa: N802
376
+ def mousePressEvent(self, event: object) -> None: # noqa: N802
353
377
  """Toggle the inline log body on click."""
354
378
  if self._is_skeleton or not hasattr(self, '_log_output'):
355
379
  return
380
+ # Don't toggle the log when clicking the copy button
381
+ if hasattr(self, '_copy_btn') and self._copy_btn.underMouse():
382
+ return
356
383
  self._toggle_log()
357
384
 
358
385
  def _toggle_log(self) -> None:
@@ -360,6 +387,23 @@ class ActionCard(QFrame):
360
387
  self._log_expanded = not self._log_expanded
361
388
  self._log_output.setVisible(self._log_expanded)
362
389
 
390
+ def _copy_command(self) -> None:
391
+ """Copy the command label text to the clipboard with brief feedback."""
392
+ clipboard = QApplication.clipboard()
393
+ if clipboard:
394
+ clipboard.setText(self._command_label.text())
395
+ self._copy_btn.setText('\u2713')
396
+ self._copy_btn.setToolTip('Copied!')
397
+
398
+ def _restore() -> None:
399
+ try:
400
+ self._copy_btn.setText(COPY_ICON)
401
+ self._copy_btn.setToolTip('Copy to clipboard')
402
+ except RuntimeError:
403
+ pass
404
+
405
+ QTimer.singleShot(COPY_FEEDBACK_MS, _restore)
406
+
363
407
  # ------------------------------------------------------------------
364
408
  # Public API — populate from action data
365
409
  # ------------------------------------------------------------------
@@ -409,9 +453,9 @@ class ActionCard(QFrame):
409
453
  cmd_text = _format_command(action)
410
454
  if cmd_text:
411
455
  self._command_label.setText(cmd_text)
412
- self._command_label.show()
456
+ self._command_row.show()
413
457
  else:
414
- self._command_label.hide()
458
+ self._command_row.hide()
415
459
 
416
460
  # Version — populated later by set_check_result()
417
461
 
@@ -467,9 +511,9 @@ class ActionCard(QFrame):
467
511
  cmd_text = _format_command(action)
468
512
  if cmd_text:
469
513
  self._command_label.setText(cmd_text)
470
- self._command_label.show()
514
+ self._command_row.show()
471
515
  else:
472
- self._command_label.hide()
516
+ self._command_row.hide()
473
517
 
474
518
  def initial_status(self) -> str:
475
519
  """Return the initial status text set during :meth:`populate`."""
@@ -400,9 +400,9 @@ class SetupPreviewWidget(QWidget):
400
400
  self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
401
401
  outer.addWidget(self._card_list, stretch=1)
402
402
 
403
- # Post-install section lives below the card list but still scrolls
403
+ # Post-install section lives below the card list but still scrolls.
404
+ # It starts hidden and is inserted into the layout after populate().
404
405
  self._post_install_section = PostInstallSection()
405
- self._card_list._layout.insertWidget(self._card_list._layout.count() - 1, self._post_install_section)
406
406
 
407
407
  # --- Button bar (fixed at bottom) ---
408
408
  button_bar = self._init_button_bar()
@@ -653,8 +653,12 @@ class SetupPreviewWidget(QWidget):
653
653
  if installer_missing:
654
654
  self._action_statuses[i] = 'Not installed'
655
655
 
656
- # Populate the post-install commands section
656
+ # Populate post-install commands and place them after all cards.
657
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
+ )
658
662
 
659
663
  self._install_btn.setEnabled(True)
660
664
 
@@ -753,12 +753,14 @@ class ProjectsView(QWidget):
753
753
  manifest_key = normalize_manifest_key(str(selected_path))
754
754
  overrides = set((self._config.prerelease_packages or {}).get(manifest_key, []))
755
755
 
756
- # Defer project directory assignment until the preview result
757
- # provides root_directory handles both file and directory inputs.
756
+ # For file paths, use the parent directory so the dry-run
757
+ # can detect already-cloned repositories on disk. The final
758
+ # project directory may still be overridden once porringer
759
+ # returns ``root_directory`` in the preview result.
758
760
  preview_worker = PreviewWorker(
759
761
  self._porringer,
760
762
  str(selected_path),
761
- project_directory=selected_path if selected_path.is_dir() else None,
763
+ project_directory=selected_path if selected_path.is_dir() else selected_path.parent,
762
764
  detect_updates=self._config.detect_updates,
763
765
  prerelease_packages=overrides or None,
764
766
  )
@@ -485,11 +485,14 @@ class TrayScreen:
485
485
 
486
486
  if result.error:
487
487
  if not silent:
488
- self.tray.showMessage(
489
- 'Update Check Failed',
490
- f'Failed to check for updates: {result.error}',
491
- QSystemTrayIcon.MessageIcon.Warning,
488
+ # Distinguish informational messages (no releases for channel)
489
+ # from genuine failures.
490
+ is_no_releases = 'No releases found' in result.error
491
+ title = 'No Updates Available' if is_no_releases else 'Update Check Failed'
492
+ icon = (
493
+ QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning
492
494
  )
495
+ self.tray.showMessage(title, result.error, icon)
493
496
  else:
494
497
  logger.warning('Automatic update check failed: %s', result.error)
495
498
  return
@@ -193,6 +193,20 @@ class Updater:
193
193
  return self._update_info
194
194
 
195
195
  except Exception as e:
196
+ if '404' in str(e):
197
+ channel = self._config.channel_name
198
+ msg = (
199
+ f"No releases found for the '{channel}' channel. "
200
+ "Try switching to the 'Development' channel in Settings \u2192 Channel."
201
+ )
202
+ logger.debug('No releases for channel %s: %s', channel, e)
203
+ self._state = UpdateState.NO_UPDATE
204
+ return UpdateInfo(
205
+ available=False,
206
+ current_version=self._current_version,
207
+ error=msg,
208
+ )
209
+
196
210
  logger.exception('Failed to check for updates')
197
211
  self._state = UpdateState.FAILED
198
212
  return UpdateInfo(
@@ -664,7 +664,7 @@ class TestActionCardCommandLabel:
664
664
  action = _make_action(package='ruff', installer='pip')
665
665
  card.populate(action)
666
666
  assert card._command_label.text() == 'pip install ruff'
667
- assert not card._command_label.isHidden()
667
+ assert not card._command_row.isHidden()
668
668
 
669
669
  @staticmethod
670
670
  def test_explicit_cli_command() -> None:
@@ -694,22 +694,22 @@ class TestActionCardCommandLabel:
694
694
  resolved = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
695
695
  card.update_command(resolved)
696
696
  assert card._command_label.text() == 'uv tool install ruff'
697
- assert not card._command_label.isHidden()
697
+ assert not card._command_row.isHidden()
698
698
 
699
699
  @staticmethod
700
700
  def test_update_command_hides_label_when_empty() -> None:
701
- """update_command hides the label when the resolved action has no command."""
701
+ """update_command hides the row when the resolved action has no command."""
702
702
  card = ActionCard()
703
703
  action = _make_action(package='ruff', installer='pip')
704
704
  card.populate(action)
705
- assert not card._command_label.isHidden()
705
+ assert not card._command_row.isHidden()
706
706
 
707
707
  empty_action = _make_action(kind=PluginKind.RUNTIME)
708
708
  empty_action.cli_command = None
709
709
  empty_action.command = None
710
710
  empty_action.package = None
711
711
  card.update_command(empty_action)
712
- assert card._command_label.isHidden()
712
+ assert card._command_row.isHidden()
713
713
 
714
714
  @staticmethod
715
715
  def test_update_command_noop_on_skeleton() -> None:
@@ -719,6 +719,29 @@ class TestActionCardCommandLabel:
719
719
  # Should not raise — skeleton simply returns early
720
720
  card.update_command(action)
721
721
 
722
+ @staticmethod
723
+ def test_copy_button_copies_command(monkeypatch: object) -> None:
724
+ """Clicking the copy button copies the command text to the clipboard."""
725
+ card = ActionCard()
726
+ action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
727
+ card.populate(action)
728
+
729
+ clipboard = QApplication.clipboard()
730
+ assert clipboard is not None
731
+ clipboard.clear()
732
+ card._copy_btn.click()
733
+ assert clipboard.text() == 'uv tool install ruff'
734
+
735
+ @staticmethod
736
+ def test_copy_button_shows_feedback() -> None:
737
+ """Clicking copy shows a check-mark on the button."""
738
+ card = ActionCard()
739
+ action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
740
+ card.populate(action)
741
+
742
+ card._copy_btn.click()
743
+ assert card._copy_btn.text() == '\u2713'
744
+
722
745
 
723
746
  # ---------------------------------------------------------------------------
724
747
  # ActionCard — per-card spinner
@@ -806,18 +829,11 @@ class TestActionSortKey:
806
829
  assert action_sort_key(package) < action_sort_key(tool)
807
830
 
808
831
  @staticmethod
809
- def test_alphabetical_within_kind() -> None:
810
- """Same-kind actions are sorted alphabetically by package name."""
832
+ def test_same_kind_returns_equal_key() -> None:
833
+ """Same-kind actions get equal sort keys so stable sort preserves order."""
811
834
  alpha = _make_action(package='alpha')
812
835
  beta = _make_action(package='beta')
813
- assert action_sort_key(alpha) < action_sort_key(beta)
814
-
815
- @staticmethod
816
- def test_case_insensitive() -> None:
817
- """Package name comparison is case-insensitive."""
818
- upper = _make_action(package='Alpha')
819
- lower = _make_action(package='alpha')
820
- assert action_sort_key(upper) == action_sort_key(lower)
836
+ assert action_sort_key(alpha) == action_sort_key(beta)
821
837
 
822
838
 
823
839
  # ---------------------------------------------------------------------------
@@ -829,19 +845,21 @@ class TestActionCardListOrdering:
829
845
  """Tests for card ordering in ActionCardList."""
830
846
 
831
847
  @staticmethod
832
- def test_cards_sorted_by_kind_then_name() -> None:
833
- """Cards are sorted by kind priority, then alphabetically."""
848
+ def test_cards_grouped_by_kind_preserving_order() -> None:
849
+ """Cards are grouped by execution phase, preserving porringer order within."""
834
850
  card_list = ActionCardList()
835
851
  a_pkg_b = _make_action(kind=PluginKind.PACKAGE, package='beta')
836
852
  a_tool = _make_action(kind=PluginKind.TOOL, package='ruff')
837
853
  a_pkg_a = _make_action(kind=PluginKind.PACKAGE, package='alpha')
838
854
  a_runtime = _make_action(kind=PluginKind.RUNTIME, package='python')
839
855
 
840
- # Populate in unsorted order
856
+ # Populate in porringer's execution order
841
857
  card_list.populate([a_pkg_b, a_tool, a_pkg_a, a_runtime])
842
858
 
843
- # Execution-phase order: RUNTIME(0) → PACKAGE(1) → TOOL(2)
844
- expected = ['python', 'alpha', 'beta', 'ruff']
859
+ # Grouped by phase: RUNTIME(0) → PACKAGE(1) → TOOL(2)
860
+ # Within PACKAGE group, original (porringer) order is preserved:
861
+ # beta came before alpha in the input list.
862
+ expected = ['python', 'beta', 'alpha', 'ruff']
845
863
  assert card_list.card_count() == len(expected)
846
864
  for i, name in enumerate(expected):
847
865
  card = card_list.card_at(i)
@@ -842,6 +842,158 @@ class TestNormalizeManifestKey:
842
842
  assert Path(result).is_absolute()
843
843
 
844
844
 
845
+ class TestPreviewWorkerProjectDirectory:
846
+ """Tests for project_directory forwarding to the dry-run."""
847
+
848
+ @staticmethod
849
+ def test_file_path_forwards_parent_as_project_directory(tmp_path: Path) -> None:
850
+ """When a manifest *file* is selected, its parent dir is forwarded."""
851
+ manifest = tmp_path / 'porringer.json'
852
+ manifest.write_text('{}')
853
+
854
+ porringer = MagicMock()
855
+ preview = SetupResults(actions=[])
856
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
857
+
858
+ captured_params: list[Any] = []
859
+
860
+ async def mock_stream(params: Any) -> Any:
861
+ captured_params.append(params)
862
+ yield manifest_event
863
+
864
+ porringer.sync.execute_stream = mock_stream
865
+
866
+ worker = PreviewWorker(
867
+ porringer,
868
+ str(manifest),
869
+ project_directory=manifest.parent,
870
+ )
871
+ worker.run()
872
+
873
+ assert len(captured_params) == 1
874
+ assert captured_params[0].project_directory == tmp_path
875
+
876
+ @staticmethod
877
+ def test_directory_path_forwarded_directly(tmp_path: Path) -> None:
878
+ """When a directory is selected its path is forwarded as-is."""
879
+ manifest = tmp_path / 'porringer.json'
880
+ manifest.write_text('{}')
881
+
882
+ porringer = MagicMock()
883
+ preview = SetupResults(actions=[])
884
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
885
+
886
+ captured_params: list[Any] = []
887
+
888
+ async def mock_stream(params: Any) -> Any:
889
+ captured_params.append(params)
890
+ yield manifest_event
891
+
892
+ porringer.sync.execute_stream = mock_stream
893
+
894
+ worker = PreviewWorker(
895
+ porringer,
896
+ str(tmp_path),
897
+ project_directory=tmp_path,
898
+ )
899
+ worker.run()
900
+
901
+ assert len(captured_params) == 1
902
+ assert captured_params[0].project_directory == tmp_path
903
+
904
+
905
+ class TestSCMPreviewActions:
906
+ """Tests for SCM (git clone) actions in the preview dry-run flow."""
907
+
908
+ @staticmethod
909
+ def _make_scm_action(description: str = 'Clone repo') -> MagicMock:
910
+ action = MagicMock()
911
+ action.kind = PluginKind.SCM
912
+ action.description = description
913
+ action.installer = 'git'
914
+ action.package = None
915
+ action.command = None
916
+ action.cli_command = None
917
+ return action
918
+
919
+ def test_scm_already_installed_emits_correct_result(self, tmp_path: Path) -> None:
920
+ """An SCM action with ALREADY_INSTALLED is surfaced via action_checked."""
921
+ manifest = tmp_path / 'porringer.json'
922
+ manifest.write_text('{}')
923
+
924
+ porringer = MagicMock()
925
+ action = self._make_scm_action()
926
+ preview = SetupResults(actions=[action])
927
+
928
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
929
+ result = SetupActionResult(
930
+ action=action,
931
+ success=True,
932
+ skipped=True,
933
+ skip_reason=SkipReason.ALREADY_INSTALLED,
934
+ )
935
+ completed_event = ProgressEvent(
936
+ kind=ProgressEventKind.ACTION_COMPLETED,
937
+ action=action,
938
+ result=result,
939
+ )
940
+
941
+ async def mock_stream(*args: Any, **kwargs: Any) -> Any:
942
+ yield manifest_event
943
+ yield completed_event
944
+
945
+ porringer.sync.execute_stream = mock_stream
946
+
947
+ worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path)
948
+
949
+ checked: list[tuple[int, SetupActionResult]] = []
950
+ worker.action_checked.connect(lambda row, r: checked.append((row, r)))
951
+ worker.run()
952
+
953
+ assert len(checked) == 1
954
+ assert checked[0] == (0, result)
955
+ assert checked[0][1].skipped is True
956
+ assert checked[0][1].skip_reason == SkipReason.ALREADY_INSTALLED
957
+
958
+ def test_scm_needed_emits_correct_result(self, tmp_path: Path) -> None:
959
+ """An SCM action that is *not* installed is surfaced as Needed."""
960
+ manifest = tmp_path / 'porringer.json'
961
+ manifest.write_text('{}')
962
+
963
+ porringer = MagicMock()
964
+ action = self._make_scm_action()
965
+ preview = SetupResults(actions=[action])
966
+
967
+ manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
968
+ result = SetupActionResult(
969
+ action=action,
970
+ success=True,
971
+ skipped=False,
972
+ skip_reason=None,
973
+ )
974
+ completed_event = ProgressEvent(
975
+ kind=ProgressEventKind.ACTION_COMPLETED,
976
+ action=action,
977
+ result=result,
978
+ )
979
+
980
+ async def mock_stream(*args: Any, **kwargs: Any) -> Any:
981
+ yield manifest_event
982
+ yield completed_event
983
+
984
+ porringer.sync.execute_stream = mock_stream
985
+
986
+ worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path)
987
+
988
+ checked: list[tuple[int, SetupActionResult]] = []
989
+ worker.action_checked.connect(lambda row, r: checked.append((row, r)))
990
+ worker.run()
991
+
992
+ assert len(checked) == 1
993
+ assert checked[0][1].skipped is False
994
+ assert checked[0][1].skip_reason is None
995
+
996
+
845
997
  class TestPrereleaseCheckboxLock:
846
998
  """Tests for the pre-release checkbox lock/unlock decision logic.
847
999
 
@@ -150,6 +150,34 @@ class TestUpdaterCheckForUpdate:
150
150
  assert info.error == 'Network error'
151
151
  assert updater.state == UpdateState.FAILED
152
152
 
153
+ @staticmethod
154
+ def test_check_404_returns_friendly_message(updater: Updater) -> None:
155
+ """Verify a 404 from GitHub returns a friendly no-releases message."""
156
+ mock_manager = MagicMock()
157
+ mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 404')
158
+
159
+ with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
160
+ info = updater.check_for_update()
161
+
162
+ assert info.available is False
163
+ assert info.error is not None
164
+ assert 'No releases found' in info.error
165
+ assert updater._config.channel_name in info.error
166
+ # A missing channel is informational, not a hard failure
167
+ assert updater.state == UpdateState.NO_UPDATE
168
+
169
+ @staticmethod
170
+ def test_check_non_404_http_error_is_failed(updater: Updater) -> None:
171
+ """Verify non-404 HTTP errors still produce FAILED state."""
172
+ mock_manager = MagicMock()
173
+ mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 500')
174
+
175
+ with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
176
+ info = updater.check_for_update()
177
+
178
+ assert info.available is False
179
+ assert updater.state == UpdateState.FAILED
180
+
153
181
 
154
182
  class TestUpdaterDownloadUpdate:
155
183
  """Tests for download_update method."""
@@ -1 +0,0 @@
1
- __version__ = '0.0.1.dev28'