- 'newest' -> 'published_at' (valid API field) - Remove 'rating' sort (not supported by API) Co-Authored-By: Claude Opus 4.5 <[email protected]>
584 lines
22 KiB
Python
584 lines
22 KiB
Python
"""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}")
|