Files
CmdForge/src/cmdforge/gui/pages/registry_page.py
T
robandClaude Opus 4.5 9d56f703cd Fix sort field values to match API
- 'newest' -> 'published_at' (valid API field)
- Remove 'rating' sort (not supported by API)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-14 05:03:55 -04:00

584 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Registry page - browse and install tools from registry."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QTableWidget, QTableWidgetItem, QLabel,
QHeaderView, QGroupBox, QTextBrowser, QSplitter, QMessageBox,
QComboBox, QFrame
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor
from ...registry_client import RegistryClient, RegistryError
from ...config import load_config
from ...tool import list_tools, load_tool
class SearchWorker(QThread):
"""Background worker for registry search."""
finished = Signal(object) # PaginatedResponse
error = Signal(str)
def __init__(self, query: str = "", category: str = None, sort: str = "downloads",
tags: list = None, page: int = 1, per_page: int = 20):
super().__init__()
self.query = query
self.category = category
self.sort = sort
self.tags = tags
self.page = page
self.per_page = per_page
def run(self):
try:
client = RegistryClient()
category = self.category if self.category and self.category != "All" else None
# Use list_tools for browsing (no query/tags), search_tools for searching
if not self.query and not self.tags:
result = client.list_tools(
category=category,
page=self.page,
per_page=self.per_page,
sort=self.sort
)
else:
result = client.search_tools(
self.query,
category=category,
tags=self.tags,
page=self.page,
per_page=self.per_page,
sort=self.sort,
include_facets=True
)
self.finished.emit(result)
except Exception as e:
self.error.emit(str(e))
class InstallWorker(QThread):
"""Background worker for tool installation."""
finished = Signal(str)
error = Signal(str)
def __init__(self, owner: str, name: str):
super().__init__()
self.owner = owner
self.name = name
def run(self):
try:
client = RegistryClient()
client.install_tool(self.owner, self.name)
self.finished.emit(f"{self.owner}/{self.name}")
except Exception as e:
self.error.emit(str(e))
class RegistryPage(QWidget):
"""Registry browser page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._search_worker = None
self._install_worker = None
self._selected_tool = None
self._current_page = 1
self._total_pages = 1
self._current_tags = []
self._installed_tools = {} # name -> version
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QHBoxLayout()
title = QLabel("Tool Registry")
title.setObjectName("heading")
header.addWidget(title)
header.addStretch()
# Browse all button
self.btn_browse = QPushButton("Browse All")
self.btn_browse.clicked.connect(self._browse_all)
header.addWidget(self.btn_browse)
layout.addLayout(header)
# Search and filters row
filters_box = QWidget()
filters_layout = QHBoxLayout(filters_box)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.setSpacing(12)
# Search input
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search tools...")
self.search_input.returnPressed.connect(self._do_search)
filters_layout.addWidget(self.search_input, 2)
# Category filter
cat_label = QLabel("Category:")
filters_layout.addWidget(cat_label)
self.category_combo = QComboBox()
self.category_combo.addItems(["All", "Text", "Developer", "Data", "Other"])
self.category_combo.setMinimumWidth(100)
self.category_combo.currentTextChanged.connect(self._on_filter_changed)
filters_layout.addWidget(self.category_combo)
# Sort dropdown
sort_label = QLabel("Sort:")
filters_layout.addWidget(sort_label)
self.sort_combo = QComboBox()
self.sort_combo.addItem("Most Popular", "downloads")
self.sort_combo.addItem("Newest", "published_at")
self.sort_combo.addItem("Name (A-Z)", "name")
self.sort_combo.setMinimumWidth(120)
self.sort_combo.currentIndexChanged.connect(self._on_filter_changed)
filters_layout.addWidget(self.sort_combo)
# Search button
self.btn_search = QPushButton("Search")
self.btn_search.clicked.connect(self._do_search)
filters_layout.addWidget(self.btn_search)
layout.addWidget(filters_box)
# Active tags display
self.tags_widget = QWidget()
self.tags_layout = QHBoxLayout(self.tags_widget)
self.tags_layout.setContentsMargins(0, 0, 0, 0)
self.tags_layout.setSpacing(8)
self.tags_label = QLabel("Filtering by tags:")
self.tags_label.setStyleSheet("color: #718096;")
self.tags_layout.addWidget(self.tags_label)
self.tags_layout.addStretch()
self.tags_widget.hide()
layout.addWidget(self.tags_widget)
# Content splitter
splitter = QSplitter(Qt.Horizontal)
# Left: Results table
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
self.results_table = QTableWidget()
self.results_table.setColumnCount(6)
self.results_table.setHorizontalHeaderLabels(["", "Name", "Owner", "Rating", "Downloads", "Version"])
self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
self.results_table.setColumnWidth(0, 30) # Installed indicator
self.results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
self.results_table.setSelectionMode(QTableWidget.SingleSelection)
self.results_table.verticalHeader().setVisible(False)
self.results_table.itemSelectionChanged.connect(self._on_selection_changed)
left_layout.addWidget(self.results_table)
# Pagination controls
pagination = QWidget()
pag_layout = QHBoxLayout(pagination)
pag_layout.setContentsMargins(0, 8, 0, 0)
self.btn_prev = QPushButton("← Previous")
self.btn_prev.clicked.connect(self._prev_page)
self.btn_prev.setEnabled(False)
pag_layout.addWidget(self.btn_prev)
pag_layout.addStretch()
self.page_label = QLabel("Page 1 of 1")
self.page_label.setStyleSheet("color: #718096;")
pag_layout.addWidget(self.page_label)
pag_layout.addStretch()
self.btn_next = QPushButton("Next →")
self.btn_next.clicked.connect(self._next_page)
self.btn_next.setEnabled(False)
pag_layout.addWidget(self.btn_next)
left_layout.addWidget(pagination)
splitter.addWidget(left)
# Right: Tool details
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
details_box = QGroupBox("Tool Details")
details_layout = QVBoxLayout(details_box)
self.details_text = QTextBrowser()
self.details_text.setOpenExternalLinks(False)
self.details_text.anchorClicked.connect(self._on_tag_clicked)
details_layout.addWidget(self.details_text)
right_layout.addWidget(details_box, 1)
# Action buttons
actions = QHBoxLayout()
self.btn_install = QPushButton("Install")
self.btn_install.clicked.connect(self._install_tool)
self.btn_install.setEnabled(False)
actions.addWidget(self.btn_install)
self.btn_update = QPushButton("Update Available")
self.btn_update.clicked.connect(self._install_tool)
self.btn_update.setEnabled(False)
self.btn_update.setStyleSheet("background-color: #48bb78; color: white;")
self.btn_update.hide()
actions.addWidget(self.btn_update)
right_layout.addLayout(actions)
splitter.addWidget(right)
splitter.setSizes([550, 450])
layout.addWidget(splitter, 1)
# Status label
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #718096;")
layout.addWidget(self.status_label)
def refresh(self):
"""Refresh on page enter - load popular tools."""
self._load_installed_tools()
self._browse_all()
def _load_installed_tools(self):
"""Load list of installed tools and their versions."""
self._installed_tools = {}
for name in list_tools():
tool = load_tool(name)
if tool:
self._installed_tools[name] = tool.version or "1.0.0"
def _browse_all(self):
"""Browse all tools (no search query)."""
self.search_input.clear()
self._current_page = 1
self._do_search()
def _on_filter_changed(self):
"""Handle filter/sort change."""
self._current_page = 1
self._do_search()
def _do_search(self):
"""Perform search with current filters."""
query = self.search_input.text().strip()
category = self.category_combo.currentText()
sort = self.sort_combo.currentData()
self.btn_search.setEnabled(False)
self.btn_browse.setEnabled(False)
self.status_label.setText("Searching...")
self.results_table.setRowCount(0)
self._search_worker = SearchWorker(
query=query,
category=category,
sort=sort,
tags=self._current_tags if self._current_tags else None,
page=self._current_page,
per_page=20
)
self._search_worker.finished.connect(self._on_search_complete)
self._search_worker.error.connect(self._on_search_error)
self._search_worker.start()
def _on_search_complete(self, result):
"""Handle search results."""
self.btn_search.setEnabled(True)
self.btn_browse.setEnabled(True)
tools = result.data
self._total_pages = result.total_pages or 1
total = result.total or len(tools)
self.status_label.setText(f"Found {total} tools")
self.page_label.setText(f"Page {self._current_page} of {self._total_pages}")
# Update pagination buttons
self.btn_prev.setEnabled(self._current_page > 1)
self.btn_next.setEnabled(self._current_page < self._total_pages)
self.results_table.setRowCount(len(tools))
for row, tool in enumerate(tools):
tool_name = tool.get("name", "")
# Installed indicator
installed_item = QTableWidgetItem()
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
installed_item.setText("↑") # Update available
installed_item.setToolTip(f"Update available: {installed_version} → {registry_version}")
installed_item.setForeground(QColor("#48bb78"))
else:
installed_item.setText("✓")
installed_item.setToolTip("Installed")
installed_item.setForeground(QColor("#4299e1"))
installed_item.setTextAlignment(Qt.AlignCenter)
self.results_table.setItem(row, 0, installed_item)
# Name
name_item = QTableWidgetItem(tool_name)
name_item.setData(Qt.UserRole, tool)
self.results_table.setItem(row, 1, name_item)
# Owner
self.results_table.setItem(row, 2, QTableWidgetItem(tool.get("owner", "")))
# Rating (stars)
rating = tool.get("average_rating", 0) or 0
rating_count = tool.get("rating_count", 0) or 0
stars = self._rating_to_stars(rating)
rating_item = QTableWidgetItem(f"{stars} ({rating_count})")
rating_item.setToolTip(f"{rating:.1f}/5 from {rating_count} ratings")
self.results_table.setItem(row, 3, rating_item)
# Downloads
downloads = tool.get("downloads", 0)
downloads_str = self._format_downloads(downloads)
self.results_table.setItem(row, 4, QTableWidgetItem(downloads_str))
# Version
self.results_table.setItem(row, 5, QTableWidgetItem(tool.get("version", "1.0.0")))
def _rating_to_stars(self, rating: float) -> str:
"""Convert rating to star display."""
if rating <= 0:
return "☆☆☆☆☆"
full_stars = int(rating)
half_star = rating - full_stars >= 0.5
empty_stars = 5 - full_stars - (1 if half_star else 0)
return "★" * full_stars + ("½" if half_star else "") + "☆" * empty_stars
def _format_downloads(self, downloads: int) -> str:
"""Format download count."""
if downloads >= 1000000:
return f"{downloads/1000000:.1f}M"
elif downloads >= 1000:
return f"{downloads/1000:.1f}K"
return str(downloads)
def _on_search_error(self, error: str):
"""Handle search error."""
self.btn_search.setEnabled(True)
self.btn_browse.setEnabled(True)
self.status_label.setText(f"Error: {error}")
def _prev_page(self):
"""Go to previous page."""
if self._current_page > 1:
self._current_page -= 1
self._do_search()
def _next_page(self):
"""Go to next page."""
if self._current_page < self._total_pages:
self._current_page += 1
self._do_search()
def _on_selection_changed(self):
"""Handle selection change."""
items = self.results_table.selectedItems()
if not items:
self._selected_tool = None
self.details_text.clear()
self.btn_install.setEnabled(False)
self.btn_update.hide()
return
row = items[0].row()
name_item = self.results_table.item(row, 1)
tool = name_item.data(Qt.UserRole)
self._selected_tool = tool
self._show_tool_details(tool)
# Check if installed / update available
tool_name = tool.get("name", "")
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
self.btn_install.hide()
self.btn_update.show()
self.btn_update.setEnabled(True)
self.btn_update.setText(f"Update ({installed_version} → {registry_version})")
else:
self.btn_install.setText("Reinstall")
self.btn_install.setEnabled(True)
self.btn_install.show()
self.btn_update.hide()
else:
self.btn_install.setText("Install")
self.btn_install.setEnabled(True)
self.btn_install.show()
self.btn_update.hide()
def _show_tool_details(self, tool: dict):
"""Show tool details."""
lines = []
owner = tool.get('owner', '')
name = tool.get('name', '')
lines.append(f"<h2>{owner}/{name}</h2>")
if tool.get("description"):
lines.append(f"<p style='color: #4a5568;'>{tool.get('description')}</p>")
# Rating display
rating = tool.get("average_rating", 0) or 0
rating_count = tool.get("rating_count", 0) or 0
stars = self._rating_to_stars(rating)
lines.append(f"<p><strong>Rating:</strong> {stars} ({rating:.1f}/5 from {rating_count} reviews)</p>")
lines.append(f"<p><strong>Version:</strong> {tool.get('version', '1.0.0')}</p>")
lines.append(f"<p><strong>Downloads:</strong> {tool.get('downloads', 0):,}</p>")
if tool.get("category"):
lines.append(f"<p><strong>Category:</strong> {tool.get('category')}</p>")
# Clickable tags
if tool.get("tags"):
tags_html = []
for tag in tool.get("tags", []):
tags_html.append(
f'<a href="tag:{tag}" style="background: #edf2f7; padding: 2px 8px; '
f'border-radius: 4px; text-decoration: none; color: #4a5568; margin-right: 4px;">{tag}</a>'
)
lines.append(f"<p><strong>Tags:</strong> {''.join(tags_html)}</p>")
# Publisher info
if tool.get("publisher_reputation"):
rep = tool.get("publisher_reputation", {})
lines.append(f"<p style='margin-top: 16px; color: #718096; font-size: 12px;'>"
f"Publisher: @{owner} · {rep.get('total_tools', 0)} tools · "
f"{rep.get('total_downloads', 0):,} total downloads</p>")
# Installed status
tool_name = tool.get("name", "")
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
lines.append(f"<p style='color: #48bb78; font-weight: 600;'>"
f"✓ Installed (v{installed_version}) - Update available!</p>")
else:
lines.append(f"<p style='color: #4299e1;'>✓ Installed (v{installed_version})</p>")
self.details_text.setHtml("\n".join(lines))
def _on_tag_clicked(self, url):
"""Handle tag click to filter by tag."""
tag = url.toString().replace("tag:", "")
if tag not in self._current_tags:
self._current_tags.append(tag)
self._update_tags_display()
self._current_page = 1
self._do_search()
def _update_tags_display(self):
"""Update the active tags display."""
# Clear existing tag buttons
while self.tags_layout.count() > 2:
item = self.tags_layout.takeAt(2)
if item.widget():
item.widget().deleteLater()
if self._current_tags:
self.tags_widget.show()
for tag in self._current_tags:
tag_btn = QPushButton(f"{tag} ×")
tag_btn.setStyleSheet(
"background: #4299e1; color: white; border: none; "
"padding: 4px 8px; border-radius: 4px;"
)
tag_btn.clicked.connect(lambda checked, t=tag: self._remove_tag(t))
self.tags_layout.insertWidget(self.tags_layout.count() - 1, tag_btn)
# Add clear all button
clear_btn = QPushButton("Clear all")
clear_btn.setStyleSheet("color: #e53e3e;")
clear_btn.setFlat(True)
clear_btn.clicked.connect(self._clear_tags)
self.tags_layout.insertWidget(self.tags_layout.count() - 1, clear_btn)
else:
self.tags_widget.hide()
def _remove_tag(self, tag: str):
"""Remove a tag from the filter."""
if tag in self._current_tags:
self._current_tags.remove(tag)
self._update_tags_display()
self._current_page = 1
self._do_search()
def _clear_tags(self):
"""Clear all tag filters."""
self._current_tags = []
self._update_tags_display()
self._current_page = 1
self._do_search()
def _install_tool(self):
"""Install selected tool."""
if not self._selected_tool:
return
owner = self._selected_tool.get("owner", "")
name = self._selected_tool.get("name", "")
self.btn_install.setEnabled(False)
self.btn_update.setEnabled(False)
self.status_label.setText(f"Installing {owner}/{name}...")
self._install_worker = InstallWorker(owner, name)
self._install_worker.finished.connect(self._on_install_complete)
self._install_worker.error.connect(self._on_install_error)
self._install_worker.start()
def _on_install_complete(self, tool_id: str):
"""Handle install complete."""
self.btn_install.setEnabled(True)
self.status_label.setText(f"Installed {tool_id}")
self.main_window.show_status(f"Installed {tool_id}")
# Refresh installed tools list
self._load_installed_tools()
# Update the display
if self._selected_tool:
self._show_tool_details(self._selected_tool)
self._on_selection_changed()
# Refresh the table to update indicators
self._do_search()
QMessageBox.information(self, "Success", f"Successfully installed {tool_id}")
def _on_install_error(self, error: str):
"""Handle install error."""
self.btn_install.setEnabled(True)
self.btn_update.setEnabled(True)
self.status_label.setText(f"Error: {error}")
QMessageBox.critical(self, "Install Error", f"Failed to install tool:\n{error}")