1386 lines
53 KiB
Python
1386 lines
53 KiB
Python
"""Tools page - main view for managing tools."""
|
|
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple, Dict, Any
|
|
|
|
import yaml
|
|
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
|
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
|
QPushButton, QGroupBox, QMessageBox, QFrame, QLineEdit
|
|
)
|
|
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
|
from PySide6.QtGui import QFont, QColor, QBrush, QShortcut, QKeySequence
|
|
|
|
from ...tool import (
|
|
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
|
list_tools, load_tool, delete_tool, get_all_categories,
|
|
get_tools_dir
|
|
)
|
|
from ...config import load_config
|
|
|
|
|
|
class StatusSyncWorker(QThread):
|
|
"""Background worker to sync tool statuses from registry using hash-based batch lookup."""
|
|
finished = Signal()
|
|
tool_updated = Signal(str) # Emits tool name when status changes
|
|
slug_fetched = Signal(str) # Emits current user's slug
|
|
|
|
def __init__(self, tool_names: list):
|
|
super().__init__()
|
|
self.tool_names = tool_names
|
|
|
|
def run(self):
|
|
"""Sync status for all tools with registry_hash using batch hash lookup."""
|
|
from ...registry_client import RegistryClient, RegistryError
|
|
from collections import defaultdict
|
|
|
|
try:
|
|
config = load_config()
|
|
if not config.registry.token:
|
|
return
|
|
|
|
client = RegistryClient()
|
|
client.token = config.registry.token
|
|
|
|
# Fetch current user slug (needed for rating ownership checks)
|
|
try:
|
|
me = client.get_me()
|
|
slug = me.get("slug")
|
|
if slug:
|
|
self.slug_fetched.emit(slug)
|
|
except Exception:
|
|
pass
|
|
|
|
# Collect all tools that have a registry_hash
|
|
# Multiple tools can share the same hash (copies/forks)
|
|
tools_dir = get_tools_dir()
|
|
tool_hashes = defaultdict(list) # hash -> [(tool_name, config_path), ...]
|
|
for tool_name in self.tool_names:
|
|
config_path = tools_dir / tool_name / "config.yaml"
|
|
if not config_path.exists():
|
|
continue
|
|
try:
|
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
|
h = config_data.get("registry_hash")
|
|
if h:
|
|
tool_hashes[h].append((tool_name, config_path))
|
|
except Exception:
|
|
continue
|
|
|
|
if not tool_hashes:
|
|
return
|
|
|
|
# Batch requests in chunks of 100 (server limit)
|
|
all_hashes = list(tool_hashes.keys())
|
|
results = {}
|
|
for i in range(0, len(all_hashes), 100):
|
|
chunk = all_hashes[i:i + 100]
|
|
results.update(client.get_tool_status_by_hashes(chunk))
|
|
|
|
# Update local configs from results
|
|
for h, tool_entries in tool_hashes.items():
|
|
if h in results:
|
|
for tool_name, config_path in tool_entries:
|
|
try:
|
|
self._update_local_config(tool_name, config_path, results[h])
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass # Silently fail - this is background sync
|
|
finally:
|
|
self.finished.emit()
|
|
|
|
def _update_local_config(self, tool_name: str, config_path, status_data: dict):
|
|
"""Update a tool's local config with registry status."""
|
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
|
|
|
new_status = status_data.get("status", "pending")
|
|
new_feedback = status_data.get("feedback")
|
|
new_owner = status_data.get("owner")
|
|
|
|
old_status = config_data.get("registry_status", "pending")
|
|
old_feedback = config_data.get("registry_feedback")
|
|
|
|
changed = False
|
|
if old_status != new_status:
|
|
config_data["registry_status"] = new_status
|
|
changed = True
|
|
if new_feedback != old_feedback:
|
|
if new_feedback:
|
|
config_data["registry_feedback"] = new_feedback
|
|
elif "registry_feedback" in config_data:
|
|
del config_data["registry_feedback"]
|
|
changed = True
|
|
# Backfill registry_owner from sync data
|
|
if new_owner and config_data.get("registry_owner") != new_owner:
|
|
config_data["registry_owner"] = new_owner
|
|
changed = True
|
|
|
|
if changed:
|
|
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
|
self.tool_updated.emit(tool_name)
|
|
|
|
|
|
def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
|
"""
|
|
Get the publish state of a tool.
|
|
|
|
Returns:
|
|
Tuple of (state, registry_hash) where state is:
|
|
- "published" - approved in registry
|
|
- "pending" - submitted but awaiting moderation
|
|
- "changes_requested" - admin requested changes before approval
|
|
- "rejected" - rejected by admin
|
|
- "installed" - installed from registry (has hash but no explicit status)
|
|
- "local" - no registry_hash (never published)
|
|
"""
|
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
|
if not config_path.exists():
|
|
return ("local", None)
|
|
|
|
try:
|
|
config = yaml.safe_load(config_path.read_text())
|
|
registry_hash = config.get("registry_hash")
|
|
|
|
if not registry_hash:
|
|
return ("local", None)
|
|
|
|
# Only use explicit registry_status - don't default to "pending"
|
|
registry_status = config.get("registry_status")
|
|
|
|
if not registry_status:
|
|
# Has registry_hash but no status = installed from registry
|
|
return ("installed", registry_hash)
|
|
|
|
if registry_status == "approved":
|
|
return ("published", registry_hash)
|
|
elif registry_status == "changes_requested":
|
|
return ("changes_requested", registry_hash)
|
|
elif registry_status == "rejected":
|
|
return ("rejected", registry_hash)
|
|
elif registry_status == "pending":
|
|
return ("pending", registry_hash)
|
|
else:
|
|
return ("installed", registry_hash)
|
|
except Exception:
|
|
return ("local", None)
|
|
|
|
|
|
def get_tool_registry_info(tool_name: str, fallback_owner: Optional[str] = None) -> Optional[Tuple[str, str]]:
|
|
"""
|
|
Get registry owner/name for a tool if it's registry-sourced.
|
|
|
|
Args:
|
|
tool_name: Qualified or simple tool name.
|
|
fallback_owner: Owner slug to use when the tool has a registry_hash
|
|
but no owner can be determined from the path or config (e.g. the
|
|
user's own published tools stored in a flat directory).
|
|
|
|
Returns:
|
|
(owner, name) tuple if tool is from registry, None if local-only.
|
|
"""
|
|
tools_dir = get_tools_dir()
|
|
config_path = tools_dir / tool_name / "config.yaml"
|
|
if not config_path.exists():
|
|
return None
|
|
|
|
try:
|
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
|
if not config_data.get("registry_hash"):
|
|
return None
|
|
|
|
# Check if stored under owner subdir: ~/.cmdforge/<owner>/<name>/
|
|
if "/" in tool_name:
|
|
owner, name = tool_name.split("/", 1)
|
|
return (owner, name)
|
|
|
|
# Flat dir: check registry_owner (saved at publish time)
|
|
registry_owner = config_data.get("registry_owner")
|
|
if registry_owner:
|
|
return (registry_owner, tool_name)
|
|
|
|
# Check source.author or installed_from in config
|
|
source = config_data.get("source", {})
|
|
if isinstance(source, dict) and source.get("author"):
|
|
return (source["author"], tool_name)
|
|
if config_data.get("installed_from"):
|
|
parts = config_data["installed_from"].split("/", 1)
|
|
if len(parts) == 2:
|
|
return (parts[0], parts[1])
|
|
|
|
# Last resort: use fallback owner (current user's slug)
|
|
if fallback_owner:
|
|
return (fallback_owner, tool_name)
|
|
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class RatingWorker(QThread):
|
|
"""Background worker to fetch rating summary and user's review for a tool."""
|
|
finished = Signal(str, dict) # tool_name, result dict
|
|
error = Signal(str, str) # tool_name, error message
|
|
|
|
def __init__(self, tool_name: str, owner: str, name: str):
|
|
super().__init__()
|
|
self.tool_name = tool_name
|
|
self.owner = owner
|
|
self.name = name
|
|
|
|
def run(self):
|
|
from ...registry_client import RegistryClient, RegistryError
|
|
try:
|
|
config = load_config()
|
|
client = RegistryClient()
|
|
client.token = config.registry.token
|
|
|
|
result: Dict[str, Any] = {"rating": None, "my_review": None}
|
|
|
|
try:
|
|
result["rating"] = client.get_tool_rating(self.owner, self.name)
|
|
except RegistryError:
|
|
pass
|
|
|
|
if client.token:
|
|
try:
|
|
result["my_review"] = client.get_my_review(self.owner, self.name)
|
|
except RegistryError:
|
|
pass
|
|
|
|
self.finished.emit(self.tool_name, result)
|
|
except Exception as e:
|
|
self.error.emit(self.tool_name, str(e))
|
|
|
|
|
|
class SubmitReviewWorker(QThread):
|
|
"""Background worker to submit or update a review."""
|
|
finished = Signal(str) # success message
|
|
error = Signal(str) # error message
|
|
|
|
def __init__(self, owner: str, name: str, rating: int, title: str, content: str,
|
|
review_id: Optional[int] = None):
|
|
super().__init__()
|
|
self.owner = owner
|
|
self.name = name
|
|
self.rating = rating
|
|
self.title = title
|
|
self.content = content
|
|
self.review_id = review_id # None = new, int = update
|
|
|
|
def run(self):
|
|
from ...registry_client import RegistryClient, RegistryError, RateLimitError
|
|
try:
|
|
config = load_config()
|
|
client = RegistryClient()
|
|
client.token = config.registry.token
|
|
|
|
if self.review_id is not None:
|
|
client.update_review(self.review_id, self.rating, self.title, self.content)
|
|
self.finished.emit("Review updated successfully")
|
|
else:
|
|
client.submit_review(self.owner, self.name, self.rating, self.title, self.content)
|
|
self.finished.emit("Review submitted successfully")
|
|
except RateLimitError as e:
|
|
minutes = max(1, e.retry_after // 60)
|
|
self.error.emit(f"Too many reviews. Try again in {minutes} minute(s).")
|
|
except RegistryError as e:
|
|
self.error.emit(e.message)
|
|
except Exception as e:
|
|
self.error.emit(str(e))
|
|
|
|
|
|
class DeleteReviewWorker(QThread):
|
|
"""Background worker to delete a review."""
|
|
finished = Signal(str)
|
|
error = Signal(str)
|
|
|
|
def __init__(self, review_id: int):
|
|
super().__init__()
|
|
self.review_id = review_id
|
|
|
|
def run(self):
|
|
from ...registry_client import RegistryClient, RegistryError
|
|
try:
|
|
config = load_config()
|
|
client = RegistryClient()
|
|
client.token = config.registry.token
|
|
client.delete_review(self.review_id)
|
|
self.finished.emit("Review deleted successfully")
|
|
except RegistryError as e:
|
|
self.error.emit(e.message)
|
|
except Exception as e:
|
|
self.error.emit(str(e))
|
|
|
|
|
|
class SubmitIssueWorker(QThread):
|
|
"""Background worker to submit an issue."""
|
|
finished = Signal(str)
|
|
error = Signal(str)
|
|
|
|
def __init__(self, owner: str, name: str, issue_type: str,
|
|
severity: str, title: str, description: str):
|
|
super().__init__()
|
|
self.owner = owner
|
|
self.name = name
|
|
self.issue_type = issue_type
|
|
self.severity = severity
|
|
self.title = title
|
|
self.description = description
|
|
|
|
def run(self):
|
|
from ...registry_client import RegistryClient, RegistryError
|
|
try:
|
|
config = load_config()
|
|
client = RegistryClient()
|
|
client.token = config.registry.token
|
|
client.submit_issue(
|
|
self.owner, self.name, self.issue_type,
|
|
self.severity, self.title, self.description
|
|
)
|
|
self.finished.emit("Issue reported successfully")
|
|
except RegistryError as e:
|
|
self.error.emit(e.message)
|
|
except Exception as e:
|
|
self.error.emit(str(e))
|
|
|
|
|
|
class ToolsPage(QWidget):
|
|
"""Main tools management page."""
|
|
|
|
def __init__(self, main_window):
|
|
super().__init__()
|
|
self.main_window = main_window
|
|
self._current_tool = None
|
|
self._sync_worker = None
|
|
self._syncing = False # Prevent re-sync during update
|
|
self._poll_timer = None # Timer for automatic status polling
|
|
self._has_pending_tools = False # Track if we need to poll
|
|
self._rating_cache: Dict[str, Dict] = {} # tool_name -> {rating, my_review}
|
|
self._rating_worker = None
|
|
self._review_worker = None
|
|
self._issue_worker = None
|
|
self._my_slug: Optional[str] = None # Cached current user slug
|
|
self._my_slug_fetched = False
|
|
self._readme_loaded_for: Optional[str] = None # Track which tool's README is loaded
|
|
self._setup_ui()
|
|
self.refresh()
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(24, 24, 24, 24)
|
|
layout.setSpacing(16)
|
|
|
|
# Header
|
|
header = QWidget()
|
|
header_layout = QHBoxLayout(header)
|
|
header_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
title = QLabel("My Tools")
|
|
title.setObjectName("heading")
|
|
header_layout.addWidget(title)
|
|
|
|
header_layout.addStretch()
|
|
|
|
# Connection status
|
|
self.connection_status = QLabel()
|
|
self._update_connection_status()
|
|
header_layout.addWidget(self.connection_status)
|
|
|
|
layout.addWidget(header)
|
|
|
|
# Main content splitter
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Left side: Tool list
|
|
left = QWidget()
|
|
left_layout = QVBoxLayout(left)
|
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
left_layout.setSpacing(8)
|
|
|
|
# Search box
|
|
self.search_box = QLineEdit()
|
|
self.search_box.setPlaceholderText("Search tools...")
|
|
self.search_box.setClearButtonEnabled(True)
|
|
self.search_box.textChanged.connect(self._filter_tools)
|
|
left_layout.addWidget(self.search_box)
|
|
|
|
self.tool_tree = QTreeWidget()
|
|
self.tool_tree.setHeaderHidden(True)
|
|
self.tool_tree.setIndentation(16)
|
|
self.tool_tree.setRootIsDecorated(True)
|
|
self.tool_tree.itemSelectionChanged.connect(self._on_selection_changed)
|
|
self.tool_tree.itemDoubleClicked.connect(self._on_double_click)
|
|
left_layout.addWidget(self.tool_tree, 1)
|
|
|
|
splitter.addWidget(left)
|
|
|
|
# Right side: Info panel
|
|
right = QWidget()
|
|
right_layout = QVBoxLayout(right)
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
right_layout.setSpacing(16)
|
|
|
|
# Tool info
|
|
info_box = QGroupBox("Tool Details")
|
|
info_layout = QVBoxLayout(info_box)
|
|
|
|
self.info_text = QTextEdit()
|
|
self.info_text.setReadOnly(True)
|
|
self.info_text.setPlaceholderText("Select a tool to view details")
|
|
info_layout.addWidget(self.info_text)
|
|
|
|
# Rating bar at bottom of details panel
|
|
self.rating_bar = QWidget()
|
|
rating_bar_layout = QHBoxLayout(self.rating_bar)
|
|
rating_bar_layout.setContentsMargins(4, 4, 4, 4)
|
|
rating_bar_layout.setSpacing(12)
|
|
|
|
self.rating_label = QLabel("")
|
|
self.rating_label.setTextFormat(Qt.RichText)
|
|
self.rating_label.setStyleSheet("color: #4a5568; font-size: 12px;")
|
|
rating_bar_layout.addWidget(self.rating_label)
|
|
|
|
rating_bar_layout.addStretch()
|
|
|
|
self.btn_rate = QPushButton("Rate Tool")
|
|
self.btn_rate.setObjectName("secondary")
|
|
self.btn_rate.clicked.connect(self._rate_tool)
|
|
self.btn_rate.setEnabled(False)
|
|
self.btn_rate.setToolTip("Local tools can't be rated")
|
|
rating_bar_layout.addWidget(self.btn_rate)
|
|
|
|
self.btn_report_issue = QPushButton("Report Issue")
|
|
self.btn_report_issue.setObjectName("secondary")
|
|
self.btn_report_issue.clicked.connect(self._report_issue)
|
|
rating_bar_layout.addWidget(self.btn_report_issue)
|
|
|
|
self.rating_bar.setVisible(False)
|
|
|
|
# Collapsible README section
|
|
self.readme_container = QWidget()
|
|
readme_container_layout = QVBoxLayout(self.readme_container)
|
|
readme_container_layout.setContentsMargins(0, 8, 0, 0)
|
|
readme_container_layout.setSpacing(0)
|
|
|
|
self.readme_toggle = QPushButton("▶ README")
|
|
self.readme_toggle.setStyleSheet(
|
|
"QPushButton { text-align: left; padding: 6px 10px; "
|
|
"background: #edf2f7; border: 1px solid #e2e8f0; border-radius: 4px; "
|
|
"color: #4a5568; font-weight: 600; font-size: 12px; }"
|
|
"QPushButton:hover { background: #e2e8f0; }"
|
|
)
|
|
self.readme_toggle.setCursor(Qt.PointingHandCursor)
|
|
self.readme_toggle.clicked.connect(self._on_readme_toggled)
|
|
readme_container_layout.addWidget(self.readme_toggle)
|
|
|
|
self.readme_text = QTextEdit()
|
|
self.readme_text.setReadOnly(True)
|
|
self.readme_text.setMinimumHeight(200)
|
|
self.readme_text.setStyleSheet(
|
|
"QTextEdit { font-family: monospace; font-size: 12px; "
|
|
"border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 4px 4px; }"
|
|
)
|
|
self.readme_text.setVisible(False)
|
|
readme_container_layout.addWidget(self.readme_text)
|
|
|
|
self.readme_container.setVisible(False)
|
|
info_layout.addWidget(self.readme_container)
|
|
|
|
info_layout.addWidget(self.rating_bar)
|
|
|
|
right_layout.addWidget(info_box, 1)
|
|
|
|
splitter.addWidget(right)
|
|
|
|
# Set splitter sizes
|
|
splitter.setSizes([350, 650])
|
|
|
|
layout.addWidget(splitter, 1)
|
|
|
|
# Action buttons
|
|
buttons = QWidget()
|
|
btn_layout = QHBoxLayout(buttons)
|
|
btn_layout.setContentsMargins(0, 0, 0, 0)
|
|
btn_layout.setSpacing(12)
|
|
|
|
self.btn_create = QPushButton("Create")
|
|
self.btn_create.clicked.connect(self._create_tool)
|
|
btn_layout.addWidget(self.btn_create)
|
|
|
|
self.btn_edit = QPushButton("Edit")
|
|
self.btn_edit.setObjectName("secondary")
|
|
self.btn_edit.clicked.connect(self._edit_tool)
|
|
self.btn_edit.setEnabled(False)
|
|
btn_layout.addWidget(self.btn_edit)
|
|
|
|
self.btn_configure = QPushButton("Configure")
|
|
self.btn_configure.setObjectName("secondary")
|
|
self.btn_configure.setToolTip("Edit tool settings (only available for tools with configurable settings)")
|
|
self.btn_configure.clicked.connect(self._configure_tool)
|
|
self.btn_configure.setEnabled(False)
|
|
btn_layout.addWidget(self.btn_configure)
|
|
|
|
self.btn_delete = QPushButton("Delete")
|
|
self.btn_delete.setObjectName("danger")
|
|
self.btn_delete.clicked.connect(self._delete_tool)
|
|
self.btn_delete.setEnabled(False)
|
|
btn_layout.addWidget(self.btn_delete)
|
|
|
|
btn_layout.addStretch()
|
|
|
|
# Connect/Publish button
|
|
config = load_config()
|
|
if config.registry.token:
|
|
self.btn_publish = QPushButton("Publish")
|
|
self.btn_publish.clicked.connect(self._publish_tool)
|
|
self.btn_publish.setEnabled(False)
|
|
else:
|
|
self.btn_publish = QPushButton("Connect")
|
|
self.btn_publish.clicked.connect(self._connect_registry)
|
|
btn_layout.addWidget(self.btn_publish)
|
|
|
|
layout.addWidget(buttons)
|
|
|
|
# Keyboard shortcut: F5 to sync status
|
|
self._sync_shortcut = QShortcut(QKeySequence("F5"), self)
|
|
self._sync_shortcut.activated.connect(self._sync_tool_status)
|
|
|
|
def _update_connection_status(self):
|
|
"""Update the connection status label."""
|
|
config = load_config()
|
|
if config.registry.token:
|
|
self.connection_status.setText("Connected to Registry")
|
|
self.connection_status.setStyleSheet("color: #38a169; font-weight: 500;")
|
|
else:
|
|
self.connection_status.setText("Not connected")
|
|
self.connection_status.setStyleSheet("color: #718096;")
|
|
|
|
def refresh(self):
|
|
"""Refresh the tool list."""
|
|
self.search_box.clear()
|
|
self.tool_tree.clear()
|
|
self._current_tool = None
|
|
self.info_text.clear()
|
|
self.rating_bar.setVisible(False)
|
|
self.readme_container.setVisible(False)
|
|
self._has_pending_tools = False # Reset, will be set during tree building
|
|
|
|
tools = list_tools()
|
|
|
|
# Group tools by category
|
|
tools_by_category = defaultdict(list)
|
|
for name in tools:
|
|
tool = load_tool(name)
|
|
if tool:
|
|
category = tool.category if tool.category else "Other"
|
|
tools_by_category[category].append((name, tool))
|
|
|
|
# Build tree with categories (get_all_categories returns defaults + custom)
|
|
all_categories = get_all_categories()
|
|
|
|
for category in all_categories:
|
|
if category in tools_by_category and tools_by_category[category]:
|
|
# Category item
|
|
cat_item = QTreeWidgetItem([category])
|
|
cat_item.setExpanded(True)
|
|
font = cat_item.font(0)
|
|
font.setBold(True)
|
|
cat_item.setFont(0, font)
|
|
cat_item.setFlags(cat_item.flags() & ~Qt.ItemIsSelectable)
|
|
|
|
# Tools in category
|
|
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
|
|
# Get publish state
|
|
state, registry_hash = get_tool_publish_state(name)
|
|
|
|
# Track if we have pending tools for polling
|
|
if state in ("pending", "changes_requested"):
|
|
self._has_pending_tools = True
|
|
|
|
# Show state indicator in display name
|
|
if state == "published":
|
|
display_name = f"{name} ✓"
|
|
tooltip = "Published to registry - approved"
|
|
color = QColor(56, 161, 105) # Green
|
|
elif state == "pending":
|
|
display_name = f"{name} ◐"
|
|
tooltip = "Submitted to registry - pending review"
|
|
color = QColor(214, 158, 46) # Yellow/amber
|
|
elif state == "changes_requested":
|
|
display_name = f"{name} ⚠"
|
|
tooltip = "Changes requested - see feedback"
|
|
color = QColor(245, 158, 11) # Orange/amber
|
|
elif state == "rejected":
|
|
display_name = f"{name} ✗"
|
|
tooltip = "Rejected by moderator"
|
|
color = QColor(220, 38, 38) # Red
|
|
elif state == "installed":
|
|
display_name = f"{name} ↓"
|
|
tooltip = "Installed from registry"
|
|
color = QColor(56, 178, 172) # Teal
|
|
else:
|
|
display_name = name
|
|
tooltip = "Local tool - not published"
|
|
color = None
|
|
|
|
tool_item = QTreeWidgetItem([display_name])
|
|
tool_item.setData(0, Qt.UserRole, name)
|
|
|
|
if color:
|
|
tool_item.setForeground(0, QBrush(color))
|
|
|
|
# Build tooltip
|
|
if tool.source and tool.source.type == "imported":
|
|
tooltip = f"Imported from {tool.source.url or 'registry'}"
|
|
tool_item.setToolTip(0, tooltip)
|
|
|
|
cat_item.addChild(tool_item)
|
|
|
|
self.tool_tree.addTopLevelItem(cat_item)
|
|
|
|
# Update button states
|
|
self._update_buttons()
|
|
|
|
if not tools:
|
|
self.info_text.setPlaceholderText(
|
|
"No tools found.\n\nClick 'Create' to build your first tool, "
|
|
"or browse the Registry to install community tools."
|
|
)
|
|
|
|
# Start background sync for published tools
|
|
self._start_background_sync(tools)
|
|
|
|
# Manage polling timer based on pending state
|
|
self._manage_poll_timer()
|
|
|
|
def _start_background_sync(self, tools: list):
|
|
"""Start background sync for tools that have been published."""
|
|
if self._syncing:
|
|
return # Avoid re-syncing during an update
|
|
|
|
config = load_config()
|
|
if not config.registry.token:
|
|
return # Not connected
|
|
|
|
# Collect tools with registry_hash
|
|
published_tools = []
|
|
for name in tools:
|
|
config_path = get_tools_dir() / name / "config.yaml"
|
|
if config_path.exists():
|
|
try:
|
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
|
if config_data.get("registry_hash"):
|
|
published_tools.append(name)
|
|
except Exception:
|
|
pass
|
|
|
|
if not published_tools:
|
|
return
|
|
|
|
# Stop any existing sync
|
|
if self._sync_worker and self._sync_worker.isRunning():
|
|
self._sync_worker.wait(1000)
|
|
|
|
# Start new sync
|
|
self._sync_worker = StatusSyncWorker(published_tools)
|
|
self._sync_worker.tool_updated.connect(self._on_tool_status_updated)
|
|
self._sync_worker.slug_fetched.connect(self._on_slug_fetched)
|
|
self._sync_worker.finished.connect(self._on_sync_finished)
|
|
self._syncing = True
|
|
self._sync_worker.start()
|
|
|
|
def _on_sync_finished(self):
|
|
"""Handle background sync completion."""
|
|
self._syncing = False
|
|
|
|
def _manage_poll_timer(self):
|
|
"""Start or stop the polling timer based on pending tools."""
|
|
config = load_config()
|
|
should_poll = self._has_pending_tools and config.registry.token
|
|
|
|
if should_poll:
|
|
if not self._poll_timer:
|
|
# Create timer that polls every 30 seconds
|
|
self._poll_timer = QTimer(self)
|
|
self._poll_timer.timeout.connect(self._poll_status)
|
|
if not self._poll_timer.isActive():
|
|
self._poll_timer.start(30000) # 30 seconds
|
|
else:
|
|
# No pending tools, stop polling
|
|
if self._poll_timer and self._poll_timer.isActive():
|
|
self._poll_timer.stop()
|
|
|
|
def _poll_status(self):
|
|
"""Timer callback to poll status for pending tools."""
|
|
if self._syncing:
|
|
return # Already syncing
|
|
|
|
# Get list of tools with pending status
|
|
tools = list_tools()
|
|
pending_tools = []
|
|
|
|
for name in tools:
|
|
config_path = get_tools_dir() / name / "config.yaml"
|
|
if config_path.exists():
|
|
try:
|
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
|
status = config_data.get("registry_status")
|
|
if status in ("pending", "changes_requested") and config_data.get("registry_hash"):
|
|
pending_tools.append(name)
|
|
except Exception:
|
|
pass
|
|
|
|
if pending_tools:
|
|
self._start_background_sync(pending_tools)
|
|
else:
|
|
# No more pending tools, stop timer
|
|
if self._poll_timer and self._poll_timer.isActive():
|
|
self._poll_timer.stop()
|
|
self._has_pending_tools = False
|
|
|
|
def _on_tool_status_updated(self, tool_name: str):
|
|
"""Handle background sync updating a tool's status."""
|
|
# Refresh the display - _syncing flag prevents re-triggering sync
|
|
self.refresh()
|
|
self.main_window.show_status(f"Status updated for '{tool_name}'")
|
|
|
|
def _filter_tools(self, text: str):
|
|
"""Filter tools by name or description based on search text."""
|
|
search = text.lower().strip()
|
|
|
|
for i in range(self.tool_tree.topLevelItemCount()):
|
|
category_item = self.tool_tree.topLevelItem(i)
|
|
visible_children = 0
|
|
|
|
for j in range(category_item.childCount()):
|
|
tool_item = category_item.child(j)
|
|
tool_name = tool_item.data(0, Qt.UserRole)
|
|
|
|
if not search:
|
|
# No filter - show all
|
|
tool_item.setHidden(False)
|
|
visible_children += 1
|
|
else:
|
|
# Check if name matches
|
|
matches = search in tool_name.lower()
|
|
|
|
# Also check description if we have the tool loaded
|
|
if not matches:
|
|
tool = load_tool(tool_name)
|
|
if tool and tool.description:
|
|
matches = search in tool.description.lower()
|
|
|
|
tool_item.setHidden(not matches)
|
|
if matches:
|
|
visible_children += 1
|
|
|
|
# Hide category if no children match
|
|
category_item.setHidden(visible_children == 0)
|
|
|
|
# Expand categories when filtering to show results
|
|
if search and visible_children > 0:
|
|
category_item.setExpanded(True)
|
|
|
|
def _on_selection_changed(self):
|
|
"""Handle tool selection change."""
|
|
items = self.tool_tree.selectedItems()
|
|
if not items:
|
|
self._current_tool = None
|
|
self.info_text.clear()
|
|
self.rating_bar.setVisible(False)
|
|
self._update_buttons()
|
|
return
|
|
|
|
item = items[0]
|
|
tool_name = item.data(0, Qt.UserRole)
|
|
if not tool_name:
|
|
self._current_tool = None
|
|
self.info_text.clear()
|
|
self.rating_bar.setVisible(False)
|
|
self.readme_container.setVisible(False)
|
|
self._update_buttons()
|
|
return
|
|
|
|
tool = load_tool(tool_name)
|
|
if tool:
|
|
self._current_tool = tool
|
|
self._show_tool_info(tool, tool_name)
|
|
self._fetch_rating_if_needed(tool_name)
|
|
self._update_buttons()
|
|
|
|
def _get_qualified_name(self) -> Optional[str]:
|
|
"""Get the qualified name (e.g., 'official/summarize') of the selected tool."""
|
|
items = self.tool_tree.selectedItems()
|
|
if items:
|
|
return items[0].data(0, Qt.UserRole)
|
|
return self._current_tool.name if self._current_tool else None
|
|
|
|
def _on_double_click(self, item, column):
|
|
"""Handle double-click on tool."""
|
|
tool_name = item.data(0, Qt.UserRole)
|
|
if tool_name:
|
|
self._edit_tool()
|
|
|
|
def _get_tool_feedback(self, tool_name: str) -> Optional[str]:
|
|
"""Get the feedback for a tool from its config."""
|
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
|
if config_path.exists():
|
|
try:
|
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
|
return config_data.get("registry_feedback")
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def _show_tool_info(self, tool: Tool, qualified_name: Optional[str] = None):
|
|
"""Display tool information.
|
|
|
|
Args:
|
|
tool: The Tool object.
|
|
qualified_name: The qualified name (e.g. 'official/foo') used for
|
|
config path lookups. Falls back to tool.name if not provided.
|
|
"""
|
|
qname = qualified_name or tool.name
|
|
lines = []
|
|
|
|
# Name and description
|
|
lines.append(f"<h2 style='margin: 0 0 8px 0; color: #2d3748;'>{tool.name}</h2>")
|
|
if tool.description:
|
|
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
|
|
|
# Publish state
|
|
state, registry_hash = get_tool_publish_state(qname)
|
|
if state == "published":
|
|
lines.append(
|
|
"<p style='background: #c6f6d5; color: #276749; padding: 6px 10px; "
|
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
|
"✓ Published to registry - approved</p>"
|
|
)
|
|
elif state == "installed":
|
|
lines.append(
|
|
"<p style='background: #e2e8f0; color: #4a5568; padding: 6px 10px; "
|
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
|
"↓ Installed from registry</p>"
|
|
)
|
|
elif state == "pending":
|
|
lines.append(
|
|
"<p style='background: #fef3c7; color: #92400e; padding: 6px 10px; "
|
|
"border-radius: 4px; margin-bottom: 12px; font-size: 12px;'>"
|
|
"◐ Submitted to registry - pending review</p>"
|
|
)
|
|
elif state == "changes_requested":
|
|
lines.append(
|
|
"<p style='background: #fef3c7; color: #92400e; padding: 6px 10px; "
|
|
"border-radius: 4px; margin-bottom: 8px; font-size: 12px;'>"
|
|
"⚠ Changes requested - please address feedback and republish</p>"
|
|
)
|
|
# Show feedback if available
|
|
feedback = self._get_tool_feedback(qname)
|
|
if feedback:
|
|
feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "<br>")
|
|
lines.append(
|
|
f"<div style='background: #fff7ed; border-left: 3px solid #f59e0b; "
|
|
f"padding: 8px 12px; margin-bottom: 12px; font-size: 12px;'>"
|
|
f"<strong>Feedback:</strong><br>{feedback_escaped}</div>"
|
|
)
|
|
elif state == "rejected":
|
|
lines.append(
|
|
"<p style='background: #fee2e2; color: #991b1b; padding: 6px 10px; "
|
|
"border-radius: 4px; margin-bottom: 8px; font-size: 12px;'>"
|
|
"✗ Rejected by moderator</p>"
|
|
)
|
|
# Show feedback if available
|
|
feedback = self._get_tool_feedback(qname)
|
|
if feedback:
|
|
feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "<br>")
|
|
lines.append(
|
|
f"<div style='background: #fef2f2; border-left: 3px solid #dc2626; "
|
|
f"padding: 8px 12px; margin-bottom: 12px; font-size: 12px;'>"
|
|
f"<strong>Reason:</strong><br>{feedback_escaped}</div>"
|
|
)
|
|
|
|
# Source info
|
|
if tool.source:
|
|
source_type = tool.source.type
|
|
if source_type == "imported":
|
|
source_url = tool.source.url or "registry"
|
|
lines.append(f"<p style='color: #718096; font-size: 12px;'>Imported from {source_url}</p>")
|
|
elif source_type == "forked":
|
|
lines.append(f"<p style='color: #718096; font-size: 12px;'>Forked from {tool.source.original_tool}</p>")
|
|
|
|
# Arguments
|
|
if tool.arguments:
|
|
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Arguments</h3>")
|
|
lines.append("<ul style='margin: 8px 0;'>")
|
|
for arg in tool.arguments:
|
|
default = f" (default: {arg.default})" if arg.default else ""
|
|
lines.append(f"<li><code>{arg.flag}</code> → <code>${arg.variable}</code>{default}</li>")
|
|
lines.append("</ul>")
|
|
|
|
# Steps
|
|
if tool.steps:
|
|
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Steps</h3>")
|
|
lines.append("<ol style='margin: 8px 0;'>")
|
|
for i, step in enumerate(tool.steps, 1):
|
|
if isinstance(step, PromptStep):
|
|
lines.append(f"<li><strong>Prompt</strong> using <code>{step.provider}</code> → <code>${step.output_var}</code></li>")
|
|
elif isinstance(step, CodeStep):
|
|
lines.append(f"<li><strong>Code</strong> (python) → <code>${step.output_var}</code></li>")
|
|
elif isinstance(step, ToolStep):
|
|
lines.append(f"<li><strong>Tool</strong>: <code>{step.tool}</code> → <code>${step.output_var}</code></li>")
|
|
lines.append("</ol>")
|
|
|
|
# Output
|
|
if tool.output:
|
|
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Output Template</h3>")
|
|
output_escaped = tool.output.replace("<", "<").replace(">", ">")
|
|
lines.append(f"<pre style='background: #edf2f7; padding: 8px; border-radius: 4px;'>{output_escaped}</pre>")
|
|
|
|
# Category
|
|
if tool.category:
|
|
lines.append(f"<p style='color: #718096; font-size: 12px; margin-top: 16px;'>Category: {tool.category}</p>")
|
|
|
|
self.info_text.setHtml("\n".join(lines))
|
|
|
|
# Update rating bar below the detail text
|
|
self._update_rating_bar(qname)
|
|
|
|
# Reset README section (collapsed, visible, lazy-loaded on expand)
|
|
self._current_tool = tool
|
|
self._readme_loaded_for = None
|
|
self.readme_text.clear()
|
|
self.readme_text.setVisible(False)
|
|
self.readme_toggle.setText("▶ README")
|
|
self.readme_container.setVisible(tool.path is not None)
|
|
|
|
def _on_readme_toggled(self):
|
|
"""Toggle README section visibility and lazy-load content on first expand."""
|
|
expanding = not self.readme_text.isVisible()
|
|
self.readme_text.setVisible(expanding)
|
|
self.readme_toggle.setText("▼ README" if expanding else "▶ README")
|
|
|
|
if not expanding:
|
|
return
|
|
|
|
tool = self._current_tool
|
|
if not tool or not tool.path:
|
|
return
|
|
|
|
# Avoid reloading if already loaded for this tool
|
|
if self._readme_loaded_for == tool.name:
|
|
return
|
|
|
|
readme_path = tool.path.parent / "README.md"
|
|
if readme_path.exists():
|
|
try:
|
|
content = readme_path.read_text()
|
|
escaped = content.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
self.readme_text.setHtml(
|
|
f"<pre style='white-space: pre-wrap; font-family: monospace; "
|
|
f"font-size: 12px; color: #2d3748;'>{escaped}</pre>"
|
|
)
|
|
except Exception as e:
|
|
self.readme_text.setPlainText(f"Error reading README: {e}")
|
|
else:
|
|
self.readme_text.setHtml(
|
|
"<p style='color: #718096; font-style: italic;'>No README.md found for this tool.</p>"
|
|
)
|
|
|
|
self._readme_loaded_for = tool.name
|
|
|
|
def _update_rating_bar(self, qname: str):
|
|
"""Update the rating bar widget at the bottom of the detail panel."""
|
|
registry_info = get_tool_registry_info(qname, self._my_slug)
|
|
if not registry_info:
|
|
self.rating_bar.setVisible(False)
|
|
return
|
|
|
|
self.rating_bar.setVisible(True)
|
|
cached = self._rating_cache.get(qname)
|
|
|
|
if cached:
|
|
rating_data = cached.get("rating")
|
|
my_review = cached.get("my_review")
|
|
parts = []
|
|
if rating_data:
|
|
avg = rating_data.get("average_rating", 0)
|
|
count = rating_data.get("rating_count", 0)
|
|
if count > 0:
|
|
filled = round(avg)
|
|
stars = "".join("★" if i < filled else "☆" for i in range(5))
|
|
parts.append(f"<span style='color: #d69e2e; font-size: 14px;'>{stars}</span>"
|
|
f" {avg:.1f}/5 from {count} review{'s' if count != 1 else ''}")
|
|
else:
|
|
parts.append("No ratings yet")
|
|
if my_review:
|
|
my_stars = "★" * my_review["rating"] + "☆" * (5 - my_review["rating"])
|
|
parts.append(f"<span style='color: #805ad5;'>Your rating: {my_stars}</span>")
|
|
self.rating_label.setText(" · ".join(parts) if parts else "")
|
|
else:
|
|
self.rating_label.setText("Loading ratings...")
|
|
|
|
def _has_settings(self, tool: Tool) -> bool:
|
|
"""Check if a tool has configurable settings."""
|
|
if not tool or not tool.path:
|
|
return False
|
|
defaults_path = tool.path.parent / "defaults.yaml"
|
|
settings_path = tool.path.parent / "settings.yaml"
|
|
return defaults_path.exists() or settings_path.exists()
|
|
|
|
def _update_buttons(self):
|
|
"""Update button enabled states."""
|
|
has_selection = self._current_tool is not None
|
|
self.btn_edit.setEnabled(has_selection)
|
|
self.btn_delete.setEnabled(has_selection)
|
|
|
|
# Configure button only for tools with settings
|
|
has_settings = has_selection and self._has_settings(self._current_tool)
|
|
self.btn_configure.setEnabled(has_settings)
|
|
|
|
config = load_config()
|
|
if config.registry.token:
|
|
# Connected - enable Publish when tool selected
|
|
self.btn_publish.setEnabled(has_selection)
|
|
else:
|
|
# Not connected - Connect button is always enabled
|
|
self.btn_publish.setEnabled(True)
|
|
|
|
# Rate button state
|
|
self._update_rate_button()
|
|
|
|
def _create_tool(self):
|
|
"""Create a new tool."""
|
|
self.main_window.open_tool_builder()
|
|
|
|
def _edit_tool(self):
|
|
"""Edit the selected tool."""
|
|
if self._current_tool:
|
|
self.main_window.open_tool_builder(self._get_qualified_name())
|
|
|
|
def _configure_tool(self):
|
|
"""Open settings dialog for the selected tool."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
if not self._has_settings(self._current_tool):
|
|
QMessageBox.information(
|
|
self,
|
|
"No Settings",
|
|
f"Tool '{self._current_tool.name}' has no configurable settings.\n\n"
|
|
"Add a defaults.yaml to the tool to enable settings."
|
|
)
|
|
return
|
|
|
|
from ..dialogs.settings_dialog import SettingsDialog
|
|
qualified_name = self._get_qualified_name()
|
|
dialog = SettingsDialog(self, qualified_name)
|
|
if dialog.exec():
|
|
self.main_window.show_status(f"Settings saved for '{qualified_name}'")
|
|
|
|
def _delete_tool(self):
|
|
"""Delete the selected tool."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
qualified_name = self._get_qualified_name()
|
|
reply = QMessageBox.question(
|
|
self,
|
|
"Delete Tool",
|
|
f"Are you sure you want to delete '{qualified_name}'?\n\n"
|
|
"This will remove the tool configuration and wrapper script.",
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No
|
|
)
|
|
|
|
if reply == QMessageBox.Yes:
|
|
try:
|
|
delete_tool(qualified_name)
|
|
self.main_window.show_status(f"Deleted tool '{qualified_name}'")
|
|
self.refresh()
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to delete tool:\n{e}")
|
|
|
|
def _connect_registry(self):
|
|
"""Open connect dialog."""
|
|
from ..dialogs.connect_dialog import ConnectDialog
|
|
dialog = ConnectDialog(self)
|
|
if dialog.exec():
|
|
self.refresh()
|
|
self._update_connection_status()
|
|
self.main_window.show_status("Connected to registry")
|
|
# Recreate publish button
|
|
self.btn_publish.setText("Publish")
|
|
self.btn_publish.clicked.disconnect()
|
|
self.btn_publish.clicked.connect(self._publish_tool)
|
|
|
|
def _publish_tool(self):
|
|
"""Publish the selected tool."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
tool_name = self._current_tool.name # Save before refresh clears it
|
|
from ..dialogs.publish_dialog import PublishDialog
|
|
dialog = PublishDialog(self, self._current_tool)
|
|
result = dialog.exec()
|
|
# Always refresh after dialog closes - tool config may have been updated
|
|
# even if user canceled (e.g., server processed request before client timeout)
|
|
self.refresh()
|
|
if result:
|
|
self.main_window.show_status(f"Published '{tool_name}'")
|
|
|
|
def _update_rate_button(self):
|
|
"""Update the Rate button visibility and state based on current selection."""
|
|
if not self._current_tool:
|
|
self.btn_rate.setVisible(False)
|
|
return
|
|
|
|
tool_name = self._get_qualified_name()
|
|
if not tool_name:
|
|
self.btn_rate.setVisible(False)
|
|
return
|
|
|
|
registry_info = get_tool_registry_info(tool_name, self._my_slug)
|
|
if not registry_info:
|
|
self.btn_rate.setVisible(False)
|
|
return
|
|
|
|
config = load_config()
|
|
if not config.registry.token:
|
|
self.btn_rate.setVisible(False)
|
|
return
|
|
|
|
owner, name = registry_info
|
|
|
|
# Hide button entirely for your own tools
|
|
if self._my_slug and owner == self._my_slug:
|
|
self.btn_rate.setVisible(False)
|
|
return
|
|
|
|
# Show and enable for other registry tools
|
|
self.btn_rate.setVisible(True)
|
|
self.btn_rate.setEnabled(True)
|
|
|
|
cached = self._rating_cache.get(tool_name)
|
|
if cached and cached.get("my_review"):
|
|
self.btn_rate.setText("Edit Rating")
|
|
else:
|
|
self.btn_rate.setText("Rate Tool")
|
|
|
|
def _on_slug_fetched(self, slug: str):
|
|
"""Handle slug fetched from background sync."""
|
|
self._my_slug = slug
|
|
self._my_slug_fetched = True
|
|
# Re-evaluate the rate button now that we know the owner
|
|
self._update_rate_button()
|
|
|
|
def _fetch_rating_if_needed(self, tool_name: str):
|
|
"""Fetch rating data for a registry tool if not cached."""
|
|
registry_info = get_tool_registry_info(tool_name, self._my_slug)
|
|
if not registry_info:
|
|
return
|
|
|
|
if tool_name in self._rating_cache:
|
|
# Already cached - just update buttons
|
|
self._update_rate_button()
|
|
return
|
|
|
|
owner, name = registry_info
|
|
|
|
# Stop any existing rating worker
|
|
if self._rating_worker and self._rating_worker.isRunning():
|
|
self._rating_worker.wait(500)
|
|
|
|
self._rating_worker = RatingWorker(tool_name, owner, name)
|
|
self._rating_worker.finished.connect(self._on_rating_fetched)
|
|
self._rating_worker.error.connect(self._on_rating_error)
|
|
self._rating_worker.start()
|
|
|
|
def _on_rating_fetched(self, tool_name: str, result: Dict):
|
|
"""Handle rating data fetched from registry."""
|
|
self._rating_cache[tool_name] = result
|
|
|
|
# If this is still the selected tool, refresh its display
|
|
qualified = self._get_qualified_name()
|
|
current_name = qualified or (self._current_tool.name if self._current_tool else None)
|
|
if current_name == tool_name and self._current_tool:
|
|
self._show_tool_info(self._current_tool, tool_name)
|
|
self._update_rate_button()
|
|
|
|
def _on_rating_error(self, tool_name: str, error: str):
|
|
"""Handle rating fetch error (non-critical)."""
|
|
pass
|
|
|
|
def _rate_tool(self):
|
|
"""Open the review dialog for the selected tool."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
tool_name = self._get_qualified_name() or self._current_tool.name
|
|
registry_info = get_tool_registry_info(tool_name, self._my_slug)
|
|
if not registry_info:
|
|
return
|
|
|
|
owner, name = registry_info
|
|
cached = self._rating_cache.get(tool_name, {})
|
|
existing_review = cached.get("my_review")
|
|
|
|
from ..dialogs.review_dialog import ReviewDialog
|
|
dialog = ReviewDialog(self, owner, name, existing_review)
|
|
result = dialog.exec()
|
|
|
|
if dialog.delete_requested and existing_review:
|
|
# User wants to delete their review
|
|
self._delete_review(tool_name, existing_review["id"])
|
|
elif result:
|
|
# User submitted/updated
|
|
data = dialog.get_review_data()
|
|
review_id = existing_review["id"] if existing_review else None
|
|
self._submit_review(tool_name, owner, name, data, review_id)
|
|
|
|
def _submit_review(self, tool_name: str, owner: str, name: str,
|
|
data: Dict, review_id: Optional[int]):
|
|
"""Submit or update a review in background."""
|
|
if self._review_worker and self._review_worker.isRunning():
|
|
self._review_worker.wait(1000)
|
|
|
|
self._review_worker = SubmitReviewWorker(
|
|
owner, name, data["rating"], data["title"], data["content"],
|
|
review_id=review_id
|
|
)
|
|
self._review_worker.finished.connect(
|
|
lambda msg: self._on_review_action_done(tool_name, msg)
|
|
)
|
|
self._review_worker.error.connect(
|
|
lambda err: self._on_review_action_error(err)
|
|
)
|
|
self._review_worker.start()
|
|
|
|
def _delete_review(self, tool_name: str, review_id: int):
|
|
"""Delete a review in background."""
|
|
if self._review_worker and self._review_worker.isRunning():
|
|
self._review_worker.wait(1000)
|
|
|
|
self._review_worker = DeleteReviewWorker(review_id)
|
|
self._review_worker.finished.connect(
|
|
lambda msg: self._on_review_action_done(tool_name, msg)
|
|
)
|
|
self._review_worker.error.connect(
|
|
lambda err: self._on_review_action_error(err)
|
|
)
|
|
self._review_worker.start()
|
|
|
|
def _on_review_action_done(self, tool_name: str, message: str):
|
|
"""Handle review submit/update/delete success."""
|
|
self.main_window.show_status(message)
|
|
|
|
# Invalidate cache and re-fetch
|
|
self._rating_cache.pop(tool_name, None)
|
|
|
|
qualified = self._get_qualified_name()
|
|
current_name = qualified or (self._current_tool.name if self._current_tool else None)
|
|
if current_name == tool_name:
|
|
self._fetch_rating_if_needed(tool_name)
|
|
|
|
def _on_review_action_error(self, error: str):
|
|
"""Handle review action error."""
|
|
self.main_window.show_status(f"Review error: {error}")
|
|
|
|
def _report_issue(self):
|
|
"""Open the issue report dialog for the selected tool."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
tool_name = self._get_qualified_name() or self._current_tool.name
|
|
registry_info = get_tool_registry_info(tool_name, self._my_slug)
|
|
if not registry_info:
|
|
return
|
|
|
|
owner, name = registry_info
|
|
|
|
from ..dialogs.issue_dialog import IssueDialog
|
|
dialog = IssueDialog(self, owner, name)
|
|
if not dialog.exec():
|
|
return
|
|
|
|
data = dialog.get_issue_data()
|
|
|
|
self._issue_worker = SubmitIssueWorker(
|
|
owner, name, data["issue_type"], data["severity"],
|
|
data["title"], data["description"]
|
|
)
|
|
self._issue_worker.finished.connect(
|
|
lambda msg: self.main_window.show_status(msg)
|
|
)
|
|
self._issue_worker.error.connect(
|
|
lambda err: self.main_window.show_status(f"Issue report failed: {err}")
|
|
)
|
|
self._issue_worker.start()
|
|
|
|
def _sync_tool_status(self):
|
|
"""Sync the moderation status of the selected tool from the registry."""
|
|
if not self._current_tool:
|
|
return
|
|
|
|
tool_name = self._get_qualified_name() or self._current_tool.name
|
|
config_path = get_tools_dir() / tool_name / "config.yaml"
|
|
|
|
if not config_path.exists():
|
|
return
|
|
|
|
try:
|
|
from ...registry_client import RegistryClient, RegistryError
|
|
|
|
config = load_config()
|
|
client = RegistryClient()
|
|
client.token = config.registry.token
|
|
|
|
# Get status from registry
|
|
status_data = client.get_my_tool_status(tool_name)
|
|
new_status = status_data.get("status", "pending")
|
|
new_hash = status_data.get("config_hash")
|
|
new_feedback = status_data.get("feedback")
|
|
|
|
# Update local config
|
|
config_data = yaml.safe_load(config_path.read_text()) or {}
|
|
old_status = config_data.get("registry_status", "pending")
|
|
old_hash = config_data.get("registry_hash")
|
|
old_feedback = config_data.get("registry_feedback")
|
|
|
|
changed = False
|
|
if old_status != new_status:
|
|
config_data["registry_status"] = new_status
|
|
changed = True
|
|
if new_hash and old_hash != new_hash:
|
|
config_data["registry_hash"] = new_hash
|
|
changed = True
|
|
if new_feedback != old_feedback:
|
|
if new_feedback:
|
|
config_data["registry_feedback"] = new_feedback
|
|
elif "registry_feedback" in config_data:
|
|
del config_data["registry_feedback"]
|
|
changed = True
|
|
|
|
if changed:
|
|
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
|
|
self.refresh()
|
|
if new_status == "approved":
|
|
self.main_window.show_status(f"Tool '{tool_name}' has been approved!")
|
|
elif new_status == "rejected":
|
|
self.main_window.show_status(f"Tool '{tool_name}' was rejected")
|
|
elif new_status == "changes_requested":
|
|
self.main_window.show_status(f"Changes requested for '{tool_name}' - see feedback")
|
|
else:
|
|
self.main_window.show_status(f"Status updated: {new_status}")
|
|
else:
|
|
self.main_window.show_status(f"Status unchanged: {new_status}")
|
|
|
|
except RegistryError as e:
|
|
QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e.message}")
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e}")
|