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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 80 additions & 36 deletions synodic_client/application/screen/action_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,21 @@
import html as html_mod
import logging

from porringer.backend.command.core.action_builder import PHASE_ORDER
from porringer.schema import SetupAction, SetupActionResult, SkipReason
from porringer.schema.plugin import PluginKind
from PySide6.QtCore import QRect, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QTextCursor
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFrame,
QHBoxLayout,
QLabel,
QScrollArea,
QSizePolicy,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
Expand Down Expand Up @@ -53,6 +56,10 @@
ACTION_CARD_STYLE,
ACTION_CARD_TYPE_BADGE_STYLE,
ACTION_CARD_VERSION_STYLE,
COPY_BTN_SIZE,
COPY_BTN_STYLE,
COPY_FEEDBACK_MS,
COPY_ICON,
LOG_COLOR_ERROR,
LOG_COLOR_PHASE,
LOG_COLOR_STDERR,
Expand All @@ -74,19 +81,9 @@
_SPINNER_ARC = 90


#: Sort priority for each :class:`PluginKind`.
#: Lower numbers appear first. Matches the execution phase order
#: defined in ``porringer.backend.command.core.action_builder.PHASE_ORDER``
#: so that cards are displayed in the same order they execute:
#: runtime → package → tool → project → SCM.
_KIND_ORDER: dict[PluginKind | None, int] = {
PluginKind.RUNTIME: 0,
PluginKind.PACKAGE: 1,
PluginKind.TOOL: 2,
PluginKind.PROJECT: 3,
PluginKind.SCM: 4,
None: 99, # bare commands are excluded from ActionCardList anyway
}
#: Sort priority derived from porringer's execution phase order so the
#: display order always matches the order actions actually execute.
_KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}


def action_key(action: SetupAction) -> tuple[object, ...]:
Expand All @@ -102,17 +99,17 @@ def action_key(action: SetupAction) -> tuple[object, ...]:
return (action.kind, action.installer, pkg_name, pt_name, cmd)


def action_sort_key(action: SetupAction) -> tuple[int, str]:
"""Return a sort key so cards are grouped by kind then alphabetical.
def action_sort_key(action: SetupAction) -> int:
"""Return a sort key that groups cards by execution phase.

The ordering matches the execution phase order
(runtime → package → tool → project → SCM) so that displayed
cards appear in the same sequence as they execute. Within a
group, actions are sorted case-insensitively by package name.
The ordering is derived from :data:`porringer.backend.command.core.
action_builder.PHASE_ORDER` so that displayed cards appear in the
same sequence as they execute. Within a phase group the original
order from porringer is preserved (Python sort is stable), which
respects dependency ordering (e.g. a tool must be installed before
its plugins).
"""
kind_order = _KIND_ORDER.get(action.kind, 50)
pkg_name = str(action.package.name).lower() if action.package else ''
return (kind_order, pkg_name)
return _KIND_ORDER.get(action.kind, len(PHASE_ORDER))


def _format_command(action: SetupAction) -> str:
Expand Down Expand Up @@ -284,7 +281,13 @@ def _init_real_ui(self) -> None:
outer.setContentsMargins(6, 6, 6, 6)
outer.setSpacing(2)

# --- Top row: type badge | package name ... version | status/spinner | prerelease ---
outer.addLayout(self._build_top_row())
outer.addWidget(self._build_description_row())
outer.addWidget(self._build_command_row())
outer.addWidget(self._build_log_output())

def _build_top_row(self) -> QHBoxLayout:
"""Build the top row: type badge | package name ... version | status/spinner | prerelease."""
top = QHBoxLayout()
top.setSpacing(8)

Expand Down Expand Up @@ -317,24 +320,45 @@ def _init_real_ui(self) -> None:
self._prerelease_cb.hide()
top.addWidget(self._prerelease_cb)

outer.addLayout(top)
return top

# --- Description row ---
def _build_description_row(self) -> QLabel:
"""Build the description label."""
self._desc_label = QLabel()
self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE)
self._desc_label.setWordWrap(True)
outer.addWidget(self._desc_label)
return self._desc_label

def _build_command_row(self) -> QWidget:
"""Build the CLI command row with copy button."""
self._command_row = QWidget()
cmd_layout = QHBoxLayout(self._command_row)
cmd_layout.setContentsMargins(0, 0, 0, 0)
cmd_layout.setSpacing(4)

# --- CLI command row (always visible, muted monospace) ---
self._command_label = QLabel()
self._command_label.setStyleSheet(ACTION_CARD_COMMAND_STYLE)
self._command_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse,
)
self._command_label.hide()
outer.addWidget(self._command_label)
cmd_layout.addWidget(self._command_label)

self._copy_btn = QToolButton()
self._copy_btn.setText(COPY_ICON)
self._copy_btn.setToolTip('Copy to clipboard')
self._copy_btn.setFixedSize(*COPY_BTN_SIZE)
self._copy_btn.setStyleSheet(COPY_BTN_STYLE)
self._copy_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._copy_btn.clicked.connect(self._copy_command)
cmd_layout.addWidget(self._copy_btn)

cmd_layout.addStretch()

self._command_row.hide()
return self._command_row

# --- Inline log body (hidden by default) ---
def _build_log_output(self) -> QTextEdit:
"""Build the inline log body (hidden by default)."""
self._log_output = QTextEdit()
self._log_output.setReadOnly(True)
self._log_output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE))
Expand All @@ -343,23 +367,43 @@ def _init_real_ui(self) -> None:
self._log_output.setMaximumHeight(250)
self._log_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self._log_output.hide()
outer.addWidget(self._log_output)
return self._log_output

# ------------------------------------------------------------------
# Mouse events (toggle log)
# ------------------------------------------------------------------

def mousePressEvent(self, _event: object) -> None: # noqa: N802
def mousePressEvent(self, event: object) -> None: # noqa: N802
"""Toggle the inline log body on click."""
if self._is_skeleton or not hasattr(self, '_log_output'):
return
# Don't toggle the log when clicking the copy button
if hasattr(self, '_copy_btn') and self._copy_btn.underMouse():
return
self._toggle_log()

def _toggle_log(self) -> None:
"""Expand or collapse the inline log body."""
self._log_expanded = not self._log_expanded
self._log_output.setVisible(self._log_expanded)

def _copy_command(self) -> None:
"""Copy the command label text to the clipboard with brief feedback."""
clipboard = QApplication.clipboard()
if clipboard:
clipboard.setText(self._command_label.text())
self._copy_btn.setText('\u2713')
self._copy_btn.setToolTip('Copied!')

def _restore() -> None:
try:
self._copy_btn.setText(COPY_ICON)
self._copy_btn.setToolTip('Copy to clipboard')
except RuntimeError:
pass

QTimer.singleShot(COPY_FEEDBACK_MS, _restore)

# ------------------------------------------------------------------
# Public API — populate from action data
# ------------------------------------------------------------------
Expand Down Expand Up @@ -409,9 +453,9 @@ def populate(
cmd_text = _format_command(action)
if cmd_text:
self._command_label.setText(cmd_text)
self._command_label.show()
self._command_row.show()
else:
self._command_label.hide()
self._command_row.hide()

# Version — populated later by set_check_result()

Expand Down Expand Up @@ -467,9 +511,9 @@ def update_command(self, action: SetupAction) -> None:
cmd_text = _format_command(action)
if cmd_text:
self._command_label.setText(cmd_text)
self._command_label.show()
self._command_row.show()
else:
self._command_label.hide()
self._command_row.hide()

def initial_status(self) -> str:
"""Return the initial status text set during :meth:`populate`."""
Expand Down
10 changes: 7 additions & 3 deletions synodic_client/application/screen/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,9 @@ def _init_ui(self) -> None:
self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
outer.addWidget(self._card_list, stretch=1)

# Post-install section lives below the card list but still scrolls
# Post-install section lives below the card list but still scrolls.
# It starts hidden and is inserted into the layout after populate().
self._post_install_section = PostInstallSection()
self._card_list._layout.insertWidget(self._card_list._layout.count() - 1, self._post_install_section)

# --- Button bar (fixed at bottom) ---
button_bar = self._init_button_bar()
Expand Down Expand Up @@ -653,8 +653,12 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p
if installer_missing:
self._action_statuses[i] = 'Not installed'

# Populate the post-install commands section
# Populate post-install commands and place them after all cards.
self._post_install_section.populate(preview.actions)
self._card_list._layout.insertWidget(
self._card_list._layout.count() - 1,
self._post_install_section,
)

self._install_btn.setEnabled(True)

Expand Down
8 changes: 5 additions & 3 deletions synodic_client/application/screen/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,12 +753,14 @@ def _load_preview(self) -> None:
manifest_key = normalize_manifest_key(str(selected_path))
overrides = set((self._config.prerelease_packages or {}).get(manifest_key, []))

# Defer project directory assignment until the preview result
# provides root_directory — handles both file and directory inputs.
# For file paths, use the parent directory so the dry-run
# can detect already-cloned repositories on disk. The final
# project directory may still be overridden once porringer
# returns ``root_directory`` in the preview result.
preview_worker = PreviewWorker(
self._porringer,
str(selected_path),
project_directory=selected_path if selected_path.is_dir() else None,
project_directory=selected_path if selected_path.is_dir() else selected_path.parent,
detect_updates=self._config.detect_updates,
prerelease_packages=overrides or None,
)
Expand Down
11 changes: 7 additions & 4 deletions synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,11 +485,14 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool =

if result.error:
if not silent:
self.tray.showMessage(
'Update Check Failed',
f'Failed to check for updates: {result.error}',
QSystemTrayIcon.MessageIcon.Warning,
# Distinguish informational messages (no releases for channel)
# from genuine failures.
is_no_releases = 'No releases found' in result.error
title = 'No Updates Available' if is_no_releases else 'Update Check Failed'
icon = (
QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning
)
self.tray.showMessage(title, result.error, icon)
else:
logger.warning('Automatic update check failed: %s', result.error)
return
Expand Down
14 changes: 14 additions & 0 deletions synodic_client/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,20 @@ def check_for_update(self) -> UpdateInfo:
return self._update_info

except Exception as e:
if '404' in str(e):
channel = self._config.channel_name
msg = (
f"No releases found for the '{channel}' channel. "
"Try switching to the 'Development' channel in Settings \u2192 Channel."
)
logger.debug('No releases for channel %s: %s', channel, e)
self._state = UpdateState.NO_UPDATE
return UpdateInfo(
available=False,
current_version=self._current_version,
error=msg,
)

logger.exception('Failed to check for updates')
self._state = UpdateState.FAILED
return UpdateInfo(
Expand Down
Loading
Loading