1232 lines
43 KiB
Python
1232 lines
43 KiB
Python
"""Collections page - manage local collections and browse registry collections."""
|
|
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
|
QListWidget, QListWidgetItem, QTextEdit, QLabel,
|
|
QPushButton, QGroupBox, QMessageBox, QFrame,
|
|
QLineEdit, QTabWidget, QDialog, QFormLayout,
|
|
QDialogButtonBox, QComboBox, QCheckBox
|
|
)
|
|
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
|
from PySide6.QtGui import QFont
|
|
|
|
from ...collection import (
|
|
Collection, list_collections, get_collection, COLLECTIONS_DIR,
|
|
classify_tool_reference, resolve_tool_references, gather_local_unpublished_deps
|
|
)
|
|
from ...tool import list_tools
|
|
from ...config import load_config
|
|
|
|
|
|
class CollectionStatusSyncWorker(QThread):
|
|
"""Background worker to sync collection statuses from registry."""
|
|
collection_updated = Signal(str) # collection name
|
|
finished = Signal()
|
|
|
|
def __init__(self, pending_collections: list):
|
|
"""
|
|
Args:
|
|
pending_collections: List of (collection_name, pending_tools) tuples
|
|
"""
|
|
super().__init__()
|
|
self.pending_collections = pending_collections
|
|
|
|
def run(self):
|
|
from ...registry_client import get_client, RegistryError
|
|
|
|
try:
|
|
client = get_client()
|
|
|
|
# Validate token first
|
|
is_valid, _ = client.validate_token()
|
|
if not is_valid:
|
|
return
|
|
|
|
for coll_name, pending_tools in self.pending_collections:
|
|
try:
|
|
self._sync_collection(client, coll_name, pending_tools)
|
|
except Exception:
|
|
pass # Skip collections that fail to sync
|
|
except Exception:
|
|
pass # Silently fail - this is background sync
|
|
finally:
|
|
self.finished.emit()
|
|
|
|
def _sync_collection(self, client, coll_name: str, pending_tools: list):
|
|
"""Sync a single collection's pending tool statuses."""
|
|
from ...registry_client import RegistryError
|
|
|
|
coll = get_collection(coll_name)
|
|
if not coll or not coll.pending_approval:
|
|
return
|
|
|
|
all_approved = True
|
|
has_rejected = False
|
|
still_pending = []
|
|
|
|
for tool_name in pending_tools:
|
|
try:
|
|
status_info = client.get_my_tool_status(tool_name)
|
|
status = status_info.get("status", "pending")
|
|
|
|
if status == "approved":
|
|
pass # Good
|
|
elif status == "rejected":
|
|
has_rejected = True
|
|
all_approved = False
|
|
else:
|
|
# Still pending or changes_requested
|
|
still_pending.append(tool_name)
|
|
all_approved = False
|
|
except RegistryError:
|
|
# Tool not found or other error - treat as still pending
|
|
still_pending.append(tool_name)
|
|
all_approved = False
|
|
|
|
# Update collection state based on results
|
|
changed = False
|
|
|
|
if all_approved:
|
|
# All tools approved - now actually publish the collection to registry!
|
|
try:
|
|
user_slug = coll.maintainer
|
|
if not user_slug:
|
|
me = client.get_me()
|
|
user_slug = me.get("slug", "")
|
|
|
|
# Build registry refs
|
|
registry_refs = []
|
|
transformed_pinned = {}
|
|
for tool_ref in coll.tools:
|
|
if "/" in tool_ref:
|
|
registry_refs.append(tool_ref)
|
|
if tool_ref in coll.pinned:
|
|
transformed_pinned[tool_ref] = coll.pinned[tool_ref]
|
|
else:
|
|
full_ref = f"{user_slug}/{tool_ref}"
|
|
registry_refs.append(full_ref)
|
|
if tool_ref in coll.pinned:
|
|
transformed_pinned[full_ref] = coll.pinned[tool_ref]
|
|
|
|
# Publish collection
|
|
payload = {
|
|
"name": coll.name,
|
|
"display_name": coll.display_name,
|
|
"description": coll.description,
|
|
"maintainer": user_slug,
|
|
"tools": registry_refs,
|
|
"pinned": transformed_pinned,
|
|
"tags": coll.tags,
|
|
}
|
|
client.publish_collection(payload)
|
|
|
|
# Update local state
|
|
coll.published = True
|
|
coll.registry_name = coll.name
|
|
coll.pending_approval = False
|
|
coll.pending_tools = []
|
|
coll.maintainer = user_slug
|
|
changed = True
|
|
except Exception:
|
|
# Failed to publish - just clear pending state
|
|
coll.pending_approval = False
|
|
coll.pending_tools = []
|
|
changed = True
|
|
elif has_rejected:
|
|
# Some tools rejected - clear pending state so user can retry
|
|
coll.pending_approval = False
|
|
coll.pending_tools = []
|
|
changed = True
|
|
elif still_pending != pending_tools:
|
|
# Some tools approved, update the list
|
|
coll.pending_tools = still_pending
|
|
changed = True
|
|
|
|
if changed:
|
|
coll.save()
|
|
self.collection_updated.emit(coll_name)
|
|
|
|
|
|
class CollectionCreateDialog(QDialog):
|
|
"""Dialog for creating a new collection."""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Create Collection")
|
|
self.setMinimumWidth(400)
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
# Form
|
|
form = QFormLayout()
|
|
|
|
self.name_edit = QLineEdit()
|
|
self.name_edit.setPlaceholderText("my-collection (lowercase, hyphens ok)")
|
|
form.addRow("Name:", self.name_edit)
|
|
|
|
self.display_name_edit = QLineEdit()
|
|
self.display_name_edit.setPlaceholderText("My Collection")
|
|
form.addRow("Display Name:", self.display_name_edit)
|
|
|
|
self.description_edit = QTextEdit()
|
|
self.description_edit.setMaximumHeight(80)
|
|
self.description_edit.setPlaceholderText("Description of your collection...")
|
|
form.addRow("Description:", self.description_edit)
|
|
|
|
self.tags_edit = QLineEdit()
|
|
self.tags_edit.setPlaceholderText("tag1, tag2, tag3")
|
|
form.addRow("Tags:", self.tags_edit)
|
|
|
|
layout.addLayout(form)
|
|
|
|
# Buttons
|
|
buttons = QDialogButtonBox(
|
|
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
|
)
|
|
buttons.accepted.connect(self.accept)
|
|
buttons.rejected.connect(self.reject)
|
|
layout.addWidget(buttons)
|
|
|
|
# Auto-fill display name from name
|
|
self.name_edit.textChanged.connect(self._update_display_name)
|
|
|
|
def _update_display_name(self, name: str):
|
|
if not self.display_name_edit.text():
|
|
display = name.replace("-", " ").title()
|
|
self.display_name_edit.setPlaceholderText(display or "My Collection")
|
|
|
|
def get_data(self) -> dict:
|
|
name = self.name_edit.text().strip().lower()
|
|
display_name = self.display_name_edit.text().strip()
|
|
if not display_name:
|
|
display_name = name.replace("-", " ").title()
|
|
|
|
tags_text = self.tags_edit.text().strip()
|
|
tags = [t.strip() for t in tags_text.split(",") if t.strip()]
|
|
|
|
return {
|
|
"name": name,
|
|
"display_name": display_name,
|
|
"description": self.description_edit.toPlainText().strip(),
|
|
"tags": tags,
|
|
}
|
|
|
|
|
|
class AddToolDialog(QDialog):
|
|
"""Dialog for adding a tool to a collection."""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Add Tool")
|
|
self.setMinimumWidth(350)
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
# Tool selection
|
|
form = QFormLayout()
|
|
|
|
self.tool_combo = QComboBox()
|
|
self.tool_combo.setEditable(True)
|
|
self.tool_combo.setPlaceholderText("Select or enter tool name...")
|
|
|
|
# Populate with local tools
|
|
local_tools = list_tools()
|
|
self.tool_combo.addItems(local_tools)
|
|
|
|
form.addRow("Tool:", self.tool_combo)
|
|
|
|
self.version_edit = QLineEdit()
|
|
self.version_edit.setPlaceholderText("Optional: ^1.0.0 or specific version")
|
|
form.addRow("Pin Version:", self.version_edit)
|
|
|
|
layout.addLayout(form)
|
|
|
|
# Hint
|
|
hint = QLabel("For registry tools, use format: owner/name")
|
|
hint.setStyleSheet("color: gray; font-size: 11px;")
|
|
layout.addWidget(hint)
|
|
|
|
# Buttons
|
|
buttons = QDialogButtonBox(
|
|
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
|
|
)
|
|
buttons.accepted.connect(self.accept)
|
|
buttons.rejected.connect(self.reject)
|
|
layout.addWidget(buttons)
|
|
|
|
def get_data(self) -> dict:
|
|
return {
|
|
"tool": self.tool_combo.currentText().strip(),
|
|
"version": self.version_edit.text().strip() or None,
|
|
}
|
|
|
|
|
|
class InstallWorker(QThread):
|
|
"""Background worker for installing registry collections."""
|
|
finished = Signal(bool, str, int, int) # success, message, installed, failed
|
|
progress = Signal(str) # status message
|
|
|
|
def __init__(self, collection_name: str):
|
|
super().__init__()
|
|
self.collection_name = collection_name
|
|
|
|
def run(self):
|
|
from ...registry_client import get_client, RegistryError
|
|
from ...resolver import install_from_registry
|
|
|
|
installed = 0
|
|
failed = 0
|
|
|
|
try:
|
|
client = get_client()
|
|
self.progress.emit(f"Fetching collection '{self.collection_name}'...")
|
|
coll = client.get_collection(self.collection_name)
|
|
|
|
tools = coll.get("tools", [])
|
|
if not tools:
|
|
self.finished.emit(True, "Collection has no tools", 0, 0)
|
|
return
|
|
|
|
total = len(tools)
|
|
for i, tool in enumerate(tools, 1):
|
|
if isinstance(tool, dict):
|
|
tool_ref = f"{tool['owner']}/{tool['name']}"
|
|
else:
|
|
tool_ref = tool
|
|
|
|
self.progress.emit(f"Installing {tool_ref} ({i}/{total})...")
|
|
try:
|
|
install_from_registry(tool_ref)
|
|
installed += 1
|
|
except Exception as e:
|
|
failed += 1
|
|
|
|
if failed:
|
|
self.finished.emit(False, f"Installed {installed} tools, {failed} failed", installed, failed)
|
|
else:
|
|
self.finished.emit(True, f"Installed {installed} tools from '{self.collection_name}'", installed, failed)
|
|
|
|
except RegistryError as e:
|
|
self.finished.emit(False, f"Registry error: {e.message}", installed, failed)
|
|
except Exception as e:
|
|
self.finished.emit(False, f"Error: {str(e)}", installed, failed)
|
|
|
|
|
|
class PublishAnalysisWorker(QThread):
|
|
"""Background worker to analyze collection before publishing."""
|
|
finished = Signal(dict) # result dict with analysis
|
|
|
|
def __init__(self, collection: Collection):
|
|
super().__init__()
|
|
self.collection = collection
|
|
|
|
def run(self):
|
|
from ...registry_client import get_client, RegistryError
|
|
|
|
result = {
|
|
"success": False,
|
|
"error": None,
|
|
"user_slug": None,
|
|
"resolution": None,
|
|
"dep_result": None, # Transitive dependency check result
|
|
}
|
|
|
|
try:
|
|
client = get_client()
|
|
|
|
# Validate token
|
|
is_valid, error = client.validate_token()
|
|
if not is_valid:
|
|
result["error"] = f"Not authenticated: {error}"
|
|
self.finished.emit(result)
|
|
return
|
|
|
|
# Get user slug
|
|
user_info = client.get_me()
|
|
user_slug = user_info.get("slug", "")
|
|
|
|
if not user_slug:
|
|
result["error"] = "Could not determine registry username"
|
|
self.finished.emit(result)
|
|
return
|
|
|
|
result["user_slug"] = user_slug
|
|
|
|
# Resolve tool references
|
|
resolution = resolve_tool_references(
|
|
self.collection.tools,
|
|
self.collection.pinned,
|
|
user_slug,
|
|
client
|
|
)
|
|
|
|
result["resolution"] = resolution
|
|
|
|
# Gather transitive dependencies from local tools in the collection
|
|
local_tool_names = [ref for ref in self.collection.tools if '/' not in ref]
|
|
if local_tool_names:
|
|
dep_result = gather_local_unpublished_deps(local_tool_names, client, user_slug)
|
|
result["dep_result"] = dep_result
|
|
|
|
result["success"] = True
|
|
self.finished.emit(result)
|
|
|
|
except RegistryError as e:
|
|
result["error"] = f"Registry error: {e.message}"
|
|
self.finished.emit(result)
|
|
except Exception as e:
|
|
result["error"] = f"Error: {str(e)}"
|
|
self.finished.emit(result)
|
|
|
|
|
|
class PublishWorker(QThread):
|
|
"""Background worker for publishing collections."""
|
|
finished = Signal(bool, str) # success, message
|
|
progress = Signal(str) # status message
|
|
|
|
def __init__(self, collection: Collection, user_slug: str, registry_refs: list,
|
|
transformed_pinned: dict, skip_unpublished: list = None,
|
|
publish_unpublished: list = None):
|
|
super().__init__()
|
|
self.collection = collection
|
|
self.user_slug = user_slug
|
|
self.registry_refs = registry_refs
|
|
self.transformed_pinned = transformed_pinned
|
|
self.skip_unpublished = skip_unpublished or []
|
|
self.publish_unpublished = publish_unpublished or []
|
|
|
|
def run(self):
|
|
from ...registry_client import get_client, RegistryError
|
|
|
|
try:
|
|
client = get_client()
|
|
|
|
# Publish unpublished local tools first if requested
|
|
if self.publish_unpublished:
|
|
from ...cli.collections_commands import _publish_single_tool
|
|
|
|
pending_tools = []
|
|
for tool_name in self.publish_unpublished:
|
|
self.progress.emit(f"Publishing tool: {tool_name}...")
|
|
result = _publish_single_tool(tool_name, client)
|
|
if not result.get("success"):
|
|
self.finished.emit(False, f"Failed to publish {tool_name}: {result.get('error')}")
|
|
return
|
|
if result.get("pending"):
|
|
pending_tools.append(tool_name)
|
|
|
|
if pending_tools:
|
|
# Save pending state and exit without publishing collection yet
|
|
self.collection.pending_approval = True
|
|
self.collection.pending_tools = pending_tools
|
|
self.collection.maintainer = self.user_slug
|
|
self.collection.save()
|
|
self.finished.emit(True,
|
|
"Tools submitted for review. Collection will publish once approved.")
|
|
return
|
|
|
|
# Filter out skipped tools
|
|
if self.skip_unpublished:
|
|
unpublished_full_refs = {f"{self.user_slug}/{u}" for u in self.skip_unpublished}
|
|
self.registry_refs = [ref for ref in self.registry_refs if ref not in unpublished_full_refs]
|
|
for ref in unpublished_full_refs:
|
|
self.transformed_pinned.pop(ref, None)
|
|
|
|
if not self.registry_refs:
|
|
self.finished.emit(False, "No tools remaining after filtering")
|
|
return
|
|
|
|
# Publish collection
|
|
self.progress.emit("Publishing collection...")
|
|
payload = {
|
|
"name": self.collection.name,
|
|
"display_name": self.collection.display_name,
|
|
"description": self.collection.description,
|
|
"maintainer": self.user_slug,
|
|
"tools": self.registry_refs,
|
|
"pinned": self.transformed_pinned,
|
|
"tags": self.collection.tags,
|
|
}
|
|
|
|
client.publish_collection(payload)
|
|
|
|
# Update local state
|
|
self.collection.published = True
|
|
self.collection.registry_name = self.collection.name
|
|
self.collection.maintainer = self.user_slug
|
|
self.collection.pending_approval = False
|
|
self.collection.pending_tools = []
|
|
|
|
# If we skipped tools, update the local collection to match
|
|
if self.skip_unpublished:
|
|
self.collection.tools = [t for t in self.collection.tools if t not in self.skip_unpublished]
|
|
for u in self.skip_unpublished:
|
|
self.collection.pinned.pop(u, None)
|
|
|
|
self.collection.save()
|
|
|
|
self.finished.emit(True, f"Collection published: {self.collection.name}")
|
|
|
|
except RegistryError as e:
|
|
self.finished.emit(False, f"Registry error: {e.message}")
|
|
except Exception as e:
|
|
self.finished.emit(False, f"Error: {str(e)}")
|
|
|
|
|
|
class CollectionsPage(QWidget):
|
|
"""Page for managing collections."""
|
|
|
|
def __init__(self, main_window):
|
|
super().__init__()
|
|
self.main_window = main_window
|
|
self._setup_ui()
|
|
self._publish_worker = None
|
|
self._analysis_worker = None
|
|
self._install_worker = None
|
|
self._pending_analysis = None # Stores analysis result for publish flow
|
|
|
|
# Status sync
|
|
self._sync_worker = None
|
|
self._poll_timer = None
|
|
self._has_pending_collections = False
|
|
self._syncing = False
|
|
|
|
def _setup_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
|
|
# Header
|
|
header = QHBoxLayout()
|
|
title = QLabel("Collections")
|
|
title.setFont(QFont("", 18, QFont.Bold))
|
|
header.addWidget(title)
|
|
header.addStretch()
|
|
|
|
self.btn_create = QPushButton("New Collection")
|
|
self.btn_create.clicked.connect(self._create_collection)
|
|
header.addWidget(self.btn_create)
|
|
|
|
self.btn_refresh = QPushButton("Refresh")
|
|
self.btn_refresh.clicked.connect(self.refresh)
|
|
header.addWidget(self.btn_refresh)
|
|
|
|
layout.addLayout(header)
|
|
|
|
# Tabs for local vs registry
|
|
self.tabs = QTabWidget()
|
|
|
|
# Local collections tab
|
|
local_widget = QWidget()
|
|
local_layout = QHBoxLayout(local_widget)
|
|
|
|
# Splitter for list and details
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Collection list
|
|
list_frame = QFrame()
|
|
list_layout = QVBoxLayout(list_frame)
|
|
list_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self.collection_list = QListWidget()
|
|
self.collection_list.currentItemChanged.connect(self._on_selection_changed)
|
|
list_layout.addWidget(self.collection_list)
|
|
|
|
splitter.addWidget(list_frame)
|
|
|
|
# Details panel
|
|
details_frame = QFrame()
|
|
details_layout = QVBoxLayout(details_frame)
|
|
|
|
self.details_title = QLabel("Select a collection")
|
|
self.details_title.setFont(QFont("", 14, QFont.Bold))
|
|
details_layout.addWidget(self.details_title)
|
|
|
|
self.details_description = QLabel("")
|
|
self.details_description.setWordWrap(True)
|
|
details_layout.addWidget(self.details_description)
|
|
|
|
self.details_status = QLabel("")
|
|
details_layout.addWidget(self.details_status)
|
|
|
|
# Tools list
|
|
tools_group = QGroupBox("Tools")
|
|
tools_layout = QVBoxLayout(tools_group)
|
|
|
|
self.tools_list = QListWidget()
|
|
tools_layout.addWidget(self.tools_list)
|
|
|
|
# Tool action buttons
|
|
tool_buttons = QHBoxLayout()
|
|
self.btn_add_tool = QPushButton("Add Tool")
|
|
self.btn_add_tool.clicked.connect(self._add_tool)
|
|
self.btn_add_tool.setEnabled(False)
|
|
tool_buttons.addWidget(self.btn_add_tool)
|
|
|
|
self.btn_remove_tool = QPushButton("Remove Tool")
|
|
self.btn_remove_tool.clicked.connect(self._remove_tool)
|
|
self.btn_remove_tool.setEnabled(False)
|
|
tool_buttons.addWidget(self.btn_remove_tool)
|
|
|
|
tool_buttons.addStretch()
|
|
tools_layout.addLayout(tool_buttons)
|
|
|
|
details_layout.addWidget(tools_group)
|
|
|
|
# Collection action buttons
|
|
action_buttons = QHBoxLayout()
|
|
|
|
self.btn_publish = QPushButton("Publish")
|
|
self.btn_publish.clicked.connect(self._publish_collection)
|
|
self.btn_publish.setEnabled(False)
|
|
action_buttons.addWidget(self.btn_publish)
|
|
|
|
self.btn_delete = QPushButton("Delete")
|
|
self.btn_delete.clicked.connect(self._delete_collection)
|
|
self.btn_delete.setEnabled(False)
|
|
action_buttons.addWidget(self.btn_delete)
|
|
|
|
action_buttons.addStretch()
|
|
details_layout.addLayout(action_buttons)
|
|
|
|
details_layout.addStretch()
|
|
|
|
splitter.addWidget(details_frame)
|
|
splitter.setSizes([250, 450])
|
|
|
|
local_layout.addWidget(splitter)
|
|
|
|
self.tabs.addTab(local_widget, "Local Collections")
|
|
|
|
# Registry collections tab
|
|
registry_widget = QWidget()
|
|
registry_layout = QVBoxLayout(registry_widget)
|
|
|
|
self.registry_list = QListWidget()
|
|
self.registry_list.itemDoubleClicked.connect(self._install_registry_collection)
|
|
registry_layout.addWidget(self.registry_list)
|
|
|
|
registry_hint = QLabel("Double-click to install a collection")
|
|
registry_hint.setStyleSheet("color: gray;")
|
|
registry_layout.addWidget(registry_hint)
|
|
|
|
self.tabs.addTab(registry_widget, "Registry Collections")
|
|
|
|
layout.addWidget(self.tabs)
|
|
|
|
def refresh(self):
|
|
"""Refresh both local and registry collections."""
|
|
self._has_pending_collections = False
|
|
self._load_local_collections()
|
|
self._load_registry_collections()
|
|
|
|
# Start background sync for pending collections
|
|
self._start_background_sync()
|
|
|
|
# Manage polling timer based on pending state
|
|
self._manage_poll_timer()
|
|
|
|
def _load_local_collections(self):
|
|
"""Load local collections into the list."""
|
|
self.collection_list.clear()
|
|
|
|
for name in list_collections():
|
|
coll = get_collection(name)
|
|
if coll:
|
|
item = QListWidgetItem(coll.display_name)
|
|
item.setData(Qt.UserRole, name)
|
|
|
|
# Show status indicator
|
|
if coll.pending_approval:
|
|
item.setText(f"{coll.display_name} (pending)")
|
|
self._has_pending_collections = True
|
|
elif coll.published:
|
|
item.setText(f"{coll.display_name} (published)")
|
|
|
|
self.collection_list.addItem(item)
|
|
|
|
if self.collection_list.count() == 0:
|
|
item = QListWidgetItem("No local collections")
|
|
item.setFlags(Qt.NoItemFlags)
|
|
self.collection_list.addItem(item)
|
|
|
|
def _load_registry_collections(self):
|
|
"""Load registry collections in background."""
|
|
self.registry_list.clear()
|
|
self.registry_list.addItem("Loading...")
|
|
|
|
# Load in background to avoid blocking UI
|
|
QTimer.singleShot(100, self._fetch_registry_collections)
|
|
|
|
def _fetch_registry_collections(self):
|
|
"""Actually fetch registry collections."""
|
|
from ...registry_client import get_client, RegistryError
|
|
|
|
self.registry_list.clear()
|
|
|
|
try:
|
|
client = get_client()
|
|
collections = client.get_collections()
|
|
|
|
if not collections:
|
|
self.registry_list.addItem("No registry collections available")
|
|
return
|
|
|
|
for coll in collections:
|
|
name = coll.get("name", "")
|
|
display_name = coll.get("display_name", name)
|
|
tool_count = coll.get("tool_count", 0)
|
|
maintainer = coll.get("maintainer", "")
|
|
|
|
item = QListWidgetItem(f"{display_name} ({tool_count} tools) - {maintainer}")
|
|
item.setData(Qt.UserRole, name)
|
|
self.registry_list.addItem(item)
|
|
|
|
except RegistryError as e:
|
|
self.registry_list.addItem(f"Error: {e.message}")
|
|
except Exception as e:
|
|
self.registry_list.addItem(f"Error: {str(e)}")
|
|
|
|
def _start_background_sync(self):
|
|
"""Start background sync for collections with pending tool approvals."""
|
|
if self._syncing:
|
|
return
|
|
|
|
config = load_config()
|
|
if not config.registry.token:
|
|
return # No auth, can't check status
|
|
|
|
# Gather pending collections
|
|
pending_collections = []
|
|
for name in list_collections():
|
|
coll = get_collection(name)
|
|
if coll and coll.pending_approval and coll.pending_tools:
|
|
pending_collections.append((name, list(coll.pending_tools)))
|
|
|
|
if not pending_collections:
|
|
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 = CollectionStatusSyncWorker(pending_collections)
|
|
self._sync_worker.collection_updated.connect(self._on_collection_status_updated)
|
|
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 collections."""
|
|
config = load_config()
|
|
should_poll = self._has_pending_collections 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 collections, 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 collections."""
|
|
if self._syncing:
|
|
return # Already syncing
|
|
|
|
# Check if we still have pending collections
|
|
pending_collections = []
|
|
for name in list_collections():
|
|
coll = get_collection(name)
|
|
if coll and coll.pending_approval and coll.pending_tools:
|
|
pending_collections.append((name, list(coll.pending_tools)))
|
|
|
|
if pending_collections:
|
|
# Stop existing sync if any
|
|
if self._sync_worker and self._sync_worker.isRunning():
|
|
self._sync_worker.wait(1000)
|
|
|
|
# Start new sync
|
|
self._sync_worker = CollectionStatusSyncWorker(pending_collections)
|
|
self._sync_worker.collection_updated.connect(self._on_collection_status_updated)
|
|
self._sync_worker.finished.connect(self._on_sync_finished)
|
|
self._syncing = True
|
|
self._sync_worker.start()
|
|
else:
|
|
# No more pending collections, stop timer
|
|
if self._poll_timer and self._poll_timer.isActive():
|
|
self._poll_timer.stop()
|
|
self._has_pending_collections = False
|
|
|
|
def _on_collection_status_updated(self, coll_name: str):
|
|
"""Handle background sync updating a collection's status."""
|
|
# Refresh the display
|
|
self._load_local_collections()
|
|
|
|
# Re-select the current item if it was the updated one
|
|
current = self.collection_list.currentItem()
|
|
if current and current.data(Qt.UserRole) == coll_name:
|
|
coll = get_collection(coll_name)
|
|
if coll:
|
|
self._show_collection_details(coll)
|
|
|
|
# Show status message
|
|
coll = get_collection(coll_name)
|
|
if coll:
|
|
if coll.pending_approval:
|
|
self.main_window.show_status(f"Collection '{coll_name}' still has pending tools")
|
|
elif coll.published:
|
|
self.main_window.show_status(f"Collection '{coll_name}' published to registry!")
|
|
self._load_registry_collections() # Refresh registry tab when auto-published
|
|
else:
|
|
self.main_window.show_status(f"Collection '{coll_name}' status updated (some tools may have been rejected)")
|
|
|
|
def _on_selection_changed(self, current, previous):
|
|
"""Handle collection selection change."""
|
|
if not current:
|
|
self._clear_details()
|
|
return
|
|
|
|
name = current.data(Qt.UserRole)
|
|
if not name:
|
|
self._clear_details()
|
|
return
|
|
|
|
coll = get_collection(name)
|
|
if not coll:
|
|
self._clear_details()
|
|
return
|
|
|
|
self._show_collection_details(coll)
|
|
|
|
def _clear_details(self):
|
|
"""Clear the details panel."""
|
|
self.details_title.setText("Select a collection")
|
|
self.details_description.setText("")
|
|
self.details_status.setText("")
|
|
self.tools_list.clear()
|
|
self.btn_add_tool.setEnabled(False)
|
|
self.btn_remove_tool.setEnabled(False)
|
|
self.btn_publish.setEnabled(False)
|
|
self.btn_delete.setEnabled(False)
|
|
|
|
def _show_collection_details(self, coll: Collection):
|
|
"""Show collection details in the panel."""
|
|
self.details_title.setText(coll.display_name)
|
|
self.details_description.setText(coll.description or "(No description)")
|
|
|
|
# Status
|
|
if coll.pending_approval:
|
|
self.details_status.setText("Status: Pending tool approvals")
|
|
self.details_status.setStyleSheet("color: orange;")
|
|
elif coll.published:
|
|
self.details_status.setText(f"Status: Published as '{coll.registry_name}'")
|
|
self.details_status.setStyleSheet("color: green;")
|
|
else:
|
|
self.details_status.setText("Status: Local only")
|
|
self.details_status.setStyleSheet("color: gray;")
|
|
|
|
# Tools list
|
|
self.tools_list.clear()
|
|
local_tools = set(list_tools())
|
|
|
|
for tool_ref in coll.tools:
|
|
owner, name, is_local = classify_tool_reference(tool_ref)
|
|
pinned = coll.pinned.get(tool_ref, "")
|
|
version_str = f" @ {pinned}" if pinned else ""
|
|
|
|
if is_local:
|
|
exists = name in local_tools
|
|
status = "" if exists else " (not found)"
|
|
item = QListWidgetItem(f"{name}{version_str} [local]{status}")
|
|
else:
|
|
item = QListWidgetItem(f"{tool_ref}{version_str} [registry]")
|
|
|
|
item.setData(Qt.UserRole, tool_ref)
|
|
self.tools_list.addItem(item)
|
|
|
|
# Enable buttons
|
|
self.btn_add_tool.setEnabled(True)
|
|
self.btn_remove_tool.setEnabled(True)
|
|
self.btn_publish.setEnabled(not coll.published)
|
|
self.btn_delete.setEnabled(True)
|
|
|
|
def _create_collection(self):
|
|
"""Create a new collection."""
|
|
dialog = CollectionCreateDialog(self)
|
|
if dialog.exec() != QDialog.Accepted:
|
|
return
|
|
|
|
data = dialog.get_data()
|
|
name = data["name"]
|
|
|
|
if not name:
|
|
QMessageBox.warning(self, "Error", "Collection name is required")
|
|
return
|
|
|
|
# Validate name format
|
|
import re
|
|
if not re.match(r'^[a-z0-9-]+$', name):
|
|
QMessageBox.warning(self, "Error",
|
|
"Collection name must be lowercase alphanumeric with hyphens only")
|
|
return
|
|
|
|
# Check if exists
|
|
if name in list_collections():
|
|
QMessageBox.warning(self, "Error", f"Collection '{name}' already exists")
|
|
return
|
|
|
|
# Create collection
|
|
coll = Collection(
|
|
name=name,
|
|
display_name=data["display_name"],
|
|
description=data["description"],
|
|
tags=data["tags"],
|
|
)
|
|
coll.save()
|
|
|
|
self._load_local_collections()
|
|
self.main_window.show_status(f"Created collection: {name}")
|
|
|
|
# Select the new collection
|
|
for i in range(self.collection_list.count()):
|
|
item = self.collection_list.item(i)
|
|
if item.data(Qt.UserRole) == name:
|
|
self.collection_list.setCurrentItem(item)
|
|
break
|
|
|
|
def _add_tool(self):
|
|
"""Add a tool to the selected collection."""
|
|
current = self.collection_list.currentItem()
|
|
if not current:
|
|
return
|
|
|
|
name = current.data(Qt.UserRole)
|
|
coll = get_collection(name)
|
|
if not coll:
|
|
return
|
|
|
|
dialog = AddToolDialog(self)
|
|
if dialog.exec() != QDialog.Accepted:
|
|
return
|
|
|
|
data = dialog.get_data()
|
|
tool_ref = data["tool"]
|
|
|
|
if not tool_ref:
|
|
QMessageBox.warning(self, "Error", "Tool name is required")
|
|
return
|
|
|
|
if tool_ref in coll.tools:
|
|
QMessageBox.information(self, "Info", f"Tool '{tool_ref}' already in collection")
|
|
return
|
|
|
|
coll.tools.append(tool_ref)
|
|
if data["version"]:
|
|
coll.pinned[tool_ref] = data["version"]
|
|
coll.save()
|
|
|
|
self._show_collection_details(coll)
|
|
self.main_window.show_status(f"Added '{tool_ref}' to collection")
|
|
|
|
def _remove_tool(self):
|
|
"""Remove selected tool from collection."""
|
|
current = self.collection_list.currentItem()
|
|
if not current:
|
|
return
|
|
|
|
name = current.data(Qt.UserRole)
|
|
coll = get_collection(name)
|
|
if not coll:
|
|
return
|
|
|
|
tool_item = self.tools_list.currentItem()
|
|
if not tool_item:
|
|
QMessageBox.warning(self, "Error", "Select a tool to remove")
|
|
return
|
|
|
|
tool_ref = tool_item.data(Qt.UserRole)
|
|
|
|
reply = QMessageBox.question(
|
|
self, "Remove Tool",
|
|
f"Remove '{tool_ref}' from collection?",
|
|
QMessageBox.Yes | QMessageBox.No
|
|
)
|
|
|
|
if reply != QMessageBox.Yes:
|
|
return
|
|
|
|
coll.tools.remove(tool_ref)
|
|
coll.pinned.pop(tool_ref, None)
|
|
coll.save()
|
|
|
|
self._show_collection_details(coll)
|
|
self.main_window.show_status(f"Removed '{tool_ref}' from collection")
|
|
|
|
def _publish_collection(self):
|
|
"""Publish the selected collection."""
|
|
current = self.collection_list.currentItem()
|
|
if not current:
|
|
return
|
|
|
|
name = current.data(Qt.UserRole)
|
|
coll = get_collection(name)
|
|
if not coll:
|
|
return
|
|
|
|
if not coll.tools:
|
|
QMessageBox.warning(self, "Error", "Collection has no tools")
|
|
return
|
|
|
|
# Start analysis first
|
|
self.btn_publish.setEnabled(False)
|
|
self.btn_publish.setText("Analyzing...")
|
|
self.main_window.show_status("Analyzing collection...")
|
|
|
|
self._analysis_worker = PublishAnalysisWorker(coll)
|
|
self._analysis_worker.finished.connect(self._on_analysis_finished)
|
|
self._analysis_worker.start()
|
|
|
|
def _on_analysis_finished(self, result: dict):
|
|
"""Handle publish analysis completion."""
|
|
self.btn_publish.setText("Publish")
|
|
self.btn_publish.setEnabled(True)
|
|
|
|
if not result["success"]:
|
|
QMessageBox.warning(self, "Analysis Failed", result["error"])
|
|
return
|
|
|
|
resolution = result["resolution"]
|
|
user_slug = result["user_slug"]
|
|
|
|
# Get current collection again
|
|
current = self.collection_list.currentItem()
|
|
if not current:
|
|
return
|
|
coll = get_collection(current.data(Qt.UserRole))
|
|
if not coll:
|
|
return
|
|
|
|
# Check for blocking visibility issues
|
|
if resolution.visibility_issues:
|
|
tools = ", ".join(t[0] for t in resolution.visibility_issues)
|
|
QMessageBox.warning(
|
|
self, "Cannot Publish",
|
|
f"The following tools are not public:\n\n{tools}\n\n"
|
|
"Collections can only include PUBLIC tools.\n"
|
|
"Update these tools' visibility to 'public' and republish them first."
|
|
)
|
|
return
|
|
|
|
# Check for registry tool issues
|
|
if resolution.registry_tool_issues:
|
|
issues = "\n".join(f" - {t[0]}: {t[1]}" for t in resolution.registry_tool_issues)
|
|
QMessageBox.warning(
|
|
self, "Cannot Publish",
|
|
f"The following registry tools have issues:\n\n{issues}\n\n"
|
|
"Collections can only include approved public tools."
|
|
)
|
|
return
|
|
|
|
# Gather all unpublished tools (collection tools + transitive deps)
|
|
dep_result = result.get("dep_result")
|
|
|
|
# Start with tools not in registry at all
|
|
collection_tools_unpub = list(resolution.local_unpublished)
|
|
|
|
# Also include tools that exist but aren't approved (rejected, pending, changes_requested)
|
|
for name, status, has_approved in resolution.local_published:
|
|
if status != "approved" and name not in collection_tools_unpub:
|
|
collection_tools_unpub.append(name)
|
|
|
|
dependency_tools_unpub = []
|
|
|
|
if dep_result and dep_result.unpublished:
|
|
for dep in dep_result.unpublished:
|
|
if dep not in collection_tools_unpub:
|
|
dependency_tools_unpub.append(dep)
|
|
|
|
all_unpublished = collection_tools_unpub + dependency_tools_unpub
|
|
|
|
# Handle unpublished local tools (both collection tools and their deps)
|
|
publish_list = []
|
|
skip_list = []
|
|
if all_unpublished:
|
|
# Build detailed message
|
|
message = "Some tools are not published yet.\n\n"
|
|
if collection_tools_unpub:
|
|
message += "Collection tools:\n"
|
|
message += "\n".join(f" - {t}" for t in collection_tools_unpub)
|
|
message += "\n\n"
|
|
if dependency_tools_unpub:
|
|
message += "Dependencies:\n"
|
|
message += "\n".join(f" - {t}" for t in dependency_tools_unpub)
|
|
message += "\n\n"
|
|
if dep_result and dep_result.cycles:
|
|
message += "Warning: Circular dependencies detected:\n"
|
|
for cycle in dep_result.cycles:
|
|
message += f" {' -> '.join(cycle)}\n"
|
|
message += "(Cyclic deps excluded from auto-publish)\n\n"
|
|
if dep_result and dep_result.skipped:
|
|
message += f"Note: Could not check {len(dep_result.skipped)} dep(s) due to errors.\n\n"
|
|
message += "Choose how to proceed:"
|
|
|
|
dialog = QMessageBox(self)
|
|
dialog.setIcon(QMessageBox.Warning)
|
|
dialog.setWindowTitle("Unpublished Tools")
|
|
dialog.setText(message)
|
|
|
|
btn_publish = dialog.addButton("Publish Tools First", QMessageBox.AcceptRole)
|
|
btn_skip = dialog.addButton("Skip Unpublished", QMessageBox.DestructiveRole)
|
|
btn_cancel = dialog.addButton("Cancel", QMessageBox.RejectRole)
|
|
dialog.setDefaultButton(btn_publish)
|
|
|
|
# Ensure buttons are wide enough for their text
|
|
for btn in [btn_publish, btn_skip, btn_cancel]:
|
|
btn.setMinimumWidth(btn.fontMetrics().horizontalAdvance(btn.text()) + 30)
|
|
|
|
dialog.exec()
|
|
|
|
clicked = dialog.clickedButton()
|
|
if clicked == btn_publish:
|
|
# Use topological order if available
|
|
if dep_result and dep_result.publish_order:
|
|
# Filter to only unpublished tools
|
|
publish_list = [t for t in dep_result.publish_order if t in all_unpublished]
|
|
else:
|
|
publish_list = all_unpublished
|
|
elif clicked == btn_skip:
|
|
skip_list = all_unpublished
|
|
else:
|
|
return
|
|
|
|
# Confirm publish
|
|
remaining = len(resolution.registry_refs) - len(skip_list)
|
|
if remaining == 0:
|
|
QMessageBox.warning(self, "Error", "No tools remaining after filtering")
|
|
return
|
|
|
|
reply = QMessageBox.question(
|
|
self, "Confirm Publish",
|
|
f"Publish '{coll.display_name}' with {remaining} tools?",
|
|
QMessageBox.Yes | QMessageBox.No
|
|
)
|
|
|
|
if reply != QMessageBox.Yes:
|
|
return
|
|
|
|
# Start actual publish
|
|
self.btn_publish.setEnabled(False)
|
|
self.btn_publish.setText("Publishing...")
|
|
|
|
self._publish_worker = PublishWorker(
|
|
coll, user_slug,
|
|
resolution.registry_refs,
|
|
resolution.transformed_pinned,
|
|
skip_unpublished=skip_list,
|
|
publish_unpublished=publish_list
|
|
)
|
|
self._publish_worker.progress.connect(self._on_publish_progress)
|
|
self._publish_worker.finished.connect(self._on_publish_finished)
|
|
self._publish_worker.start()
|
|
|
|
def _on_publish_progress(self, message: str):
|
|
"""Handle publish progress updates."""
|
|
self.main_window.show_status(message)
|
|
|
|
def _on_publish_finished(self, success: bool, message: str):
|
|
"""Handle publish completion."""
|
|
self.btn_publish.setText("Publish")
|
|
|
|
if success:
|
|
QMessageBox.information(self, "Published", message)
|
|
self._load_local_collections()
|
|
self._load_registry_collections() # Also refresh registry tab
|
|
else:
|
|
QMessageBox.warning(self, "Publish Failed", message)
|
|
self.btn_publish.setEnabled(True)
|
|
|
|
self.main_window.show_status(message)
|
|
|
|
def _delete_collection(self):
|
|
"""Delete the selected collection."""
|
|
current = self.collection_list.currentItem()
|
|
if not current:
|
|
return
|
|
|
|
name = current.data(Qt.UserRole)
|
|
coll = get_collection(name)
|
|
if not coll:
|
|
return
|
|
|
|
reply = QMessageBox.question(
|
|
self, "Delete Collection",
|
|
f"Delete collection '{coll.display_name}'?\n\n"
|
|
"This only removes the local collection file.",
|
|
QMessageBox.Yes | QMessageBox.No
|
|
)
|
|
|
|
if reply != QMessageBox.Yes:
|
|
return
|
|
|
|
coll.delete()
|
|
self._load_local_collections()
|
|
self._clear_details()
|
|
self.main_window.show_status(f"Deleted collection: {name}")
|
|
|
|
def _install_registry_collection(self, item: QListWidgetItem):
|
|
"""Install a registry collection."""
|
|
name = item.data(Qt.UserRole)
|
|
if not name:
|
|
return
|
|
|
|
reply = QMessageBox.question(
|
|
self, "Install Collection",
|
|
f"Install all tools from collection '{name}'?",
|
|
QMessageBox.Yes | QMessageBox.No
|
|
)
|
|
|
|
if reply != QMessageBox.Yes:
|
|
return
|
|
|
|
# Disable double-click during install
|
|
self.registry_list.setEnabled(False)
|
|
self.main_window.show_status(f"Installing collection '{name}'...")
|
|
|
|
self._install_worker = InstallWorker(name)
|
|
self._install_worker.progress.connect(self._on_install_progress)
|
|
self._install_worker.finished.connect(self._on_install_finished)
|
|
self._install_worker.start()
|
|
|
|
def _on_install_progress(self, message: str):
|
|
"""Handle install progress updates."""
|
|
self.main_window.show_status(message)
|
|
|
|
def _on_install_finished(self, success: bool, message: str, installed: int, failed: int):
|
|
"""Handle install completion."""
|
|
self.registry_list.setEnabled(True)
|
|
|
|
if success:
|
|
if installed == 0 and failed == 0:
|
|
QMessageBox.information(self, "Info", message)
|
|
else:
|
|
QMessageBox.information(self, "Success", message)
|
|
else:
|
|
if installed > 0:
|
|
QMessageBox.warning(self, "Partial Success", message)
|
|
else:
|
|
QMessageBox.warning(self, "Error", message)
|
|
|
|
self.main_window.show_status(message)
|