From 518a04a8b0962011d62a4f0089e9898e4ee743c0 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 14 Jan 2026 04:51:10 -0400 Subject: [PATCH] Add tool marketplace UI enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Browse all tools on page load without search - Category filter dropdown (Text, Developer, Data, etc.) - Sort options (downloads, rating, newest, name) - Star ratings display in results table - Clickable tags for filtering - Installed indicator (✓) for local tools - Update available indicator (↑) for newer versions - Pagination controls for large result sets - Publisher reputation info in details Co-Authored-By: Claude Opus 4.5 --- README.md | 13 +- src/cmdforge/gui/pages/registry_page.py | 412 +++++++++++++++++++++--- 2 files changed, 380 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 54e067e..520e019 100644 --- a/README.md +++ b/README.md @@ -431,9 +431,16 @@ The graphical interface provides a modern desktop experience: - Test tools before saving ### Registry Browser -- Search community tools by name or keyword -- View tool details, downloads, and ratings -- One-click install to your local machine +- **Browse all** tools on page load (no search required) +- **Search** by name, description, or keyword +- **Filter** by category (Text, Developer, Data, Other) +- **Sort** by popularity, rating, newest, or name +- **Star ratings** displayed in results and details +- **Clickable tags** to filter by tag +- **Installed indicator** (✓) shows which tools you have +- **Update available** (↑) when newer version exists +- **Pagination** for browsing large result sets +- **Publisher info** showing reputation and total downloads ### Provider Management - Add and configure AI providers diff --git a/src/cmdforge/gui/pages/registry_page.py b/src/cmdforge/gui/pages/registry_page.py index 82189f8..12d8877 100644 --- a/src/cmdforge/gui/pages/registry_page.py +++ b/src/cmdforge/gui/pages/registry_page.py @@ -3,30 +3,45 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QLabel, - QHeaderView, QGroupBox, QTextEdit, QSplitter, QMessageBox + QHeaderView, QGroupBox, QTextEdit, 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(list) + finished = Signal(object) # PaginatedResponse error = Signal(str) - def __init__(self, query: str, page: int = 1): + 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() - result = client.search_tools(self.query, page=self.page, per_page=20) - # result is a PaginatedResponse with data attribute - self.finished.emit(result.data) + result = client.search_tools( + self.query, + category=self.category if self.category and self.category != "All" else None, + 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)) @@ -59,6 +74,10 @@ class RegistryPage(QWidget): 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): @@ -68,26 +87,70 @@ class RegistryPage(QWidget): layout.setSpacing(16) # Header + header = QHBoxLayout() title = QLabel("Tool Registry") title.setObjectName("heading") - layout.addWidget(title) + header.addWidget(title) + header.addStretch() - # Search bar - search_box = QWidget() - search_layout = QHBoxLayout(search_box) - search_layout.setContentsMargins(0, 0, 0, 0) - search_layout.setSpacing(8) + # 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) - search_layout.addWidget(self.search_input, 1) + 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("Highest Rated", "rating") + self.sort_combo.addItem("Newest", "newest") + 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) - search_layout.addWidget(self.btn_search) + filters_layout.addWidget(self.btn_search) - layout.addWidget(search_box) + 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) @@ -98,18 +161,46 @@ class RegistryPage(QWidget): left_layout.setContentsMargins(0, 0, 0, 0) self.results_table = QTableWidget() - self.results_table.setColumnCount(4) - self.results_table.setHorizontalHeaderLabels(["Name", "Owner", "Downloads", "Version"]) - self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - self.results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + 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 @@ -123,18 +214,31 @@ class RegistryPage(QWidget): self.details_text = QTextEdit() self.details_text.setReadOnly(True) self.details_text.setPlaceholderText("Select a tool to view details") + self.details_text.anchorClicked = self._on_tag_clicked + self.details_text.setOpenExternalLinks(False) details_layout.addWidget(self.details_text) right_layout.addWidget(details_box, 1) - # Install button + # Action buttons + actions = QHBoxLayout() + self.btn_install = QPushButton("Install") self.btn_install.clicked.connect(self._install_tool) self.btn_install.setEnabled(False) - right_layout.addWidget(self.btn_install) + 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([500, 500]) + splitter.setSizes([550, 450]) layout.addWidget(splitter, 1) @@ -144,43 +248,147 @@ class RegistryPage(QWidget): layout.addWidget(self.status_label) def refresh(self): - """Refresh on page enter.""" - pass # Could auto-search popular tools + """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.""" + """Perform search with current filters.""" query = self.search_input.text().strip() - if not query: - return + 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) + 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, tools: list): + def _on_search_complete(self, result): """Handle search results.""" self.btn_search.setEnabled(True) - self.status_label.setText(f"Found {len(tools)} tools") + 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): - name_item = QTableWidgetItem(tool.get("name", "")) + 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, 0, name_item) - self.results_table.setItem(row, 1, QTableWidgetItem(tool.get("owner", ""))) - self.results_table.setItem(row, 2, QTableWidgetItem(str(tool.get("downloads", 0)))) - self.results_table.setItem(row, 3, QTableWidgetItem(tool.get("version", "1.0.0"))) + 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() @@ -188,35 +396,141 @@ class RegistryPage(QWidget): 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, 0) + name_item = self.results_table.item(row, 1) tool = name_item.data(Qt.UserRole) self._selected_tool = tool self._show_tool_details(tool) - self.btn_install.setEnabled(True) + + # 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 = [] - lines.append(f"

{tool.get('owner', '')}/{tool.get('name', '')}

") + owner = tool.get('owner', '') + name = tool.get('name', '') + + lines.append(f"

{owner}/{name}

") if tool.get("description"): - lines.append(f"

{tool.get('description')}

") + lines.append(f"

{tool.get('description')}

") + + # 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"

Rating: {stars} ({rating:.1f}/5 from {rating_count} reviews)

") lines.append(f"

Version: {tool.get('version', '1.0.0')}

") - lines.append(f"

Downloads: {tool.get('downloads', 0)}

") + lines.append(f"

Downloads: {tool.get('downloads', 0):,}

") if tool.get("category"): lines.append(f"

Category: {tool.get('category')}

") + # Clickable tags if tool.get("tags"): - tags = ", ".join(tool.get("tags", [])) - lines.append(f"

Tags: {tags}

") + tags_html = [] + for tag in tool.get("tags", []): + tags_html.append( + f'{tag}' + ) + lines.append(f"

Tags: {''.join(tags_html)}

") + + # Publisher info + if tool.get("publisher_reputation"): + rep = tool.get("publisher_reputation", {}) + lines.append(f"

" + f"Publisher: @{owner} · {rep.get('total_tools', 0)} tools · " + f"{rep.get('total_downloads', 0):,} total downloads

") + + # 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"

" + f"✓ Installed (v{installed_version}) - Update available!

") + else: + lines.append(f"

✓ Installed (v{installed_version})

") 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: @@ -226,6 +540,7 @@ class RegistryPage(QWidget): 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) @@ -238,10 +553,23 @@ class RegistryPage(QWidget): 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}")