Add local collections support with GUI, CLI, and publishing workflow
Features: - Local collection definitions stored in ~/.cmdforge/collections/ - CLI commands: create, show, add, remove, delete, publish, status - GUI Collections page with local and registry tabs - Collection publishing with tool resolution and visibility checks - New API endpoints: GET /api/v1/me, GET /api/v1/tools/.../approved, POST /api/v1/collections - RegistryClient methods: get_me(), has_approved_public_tool(), publish_collection() Implementation: - collection.py: Collection dataclass, resolve_tool_references(), classify_tool_reference(), ToolResolutionResult - collections_page.py: GUI with background workers for install/publish - collections_commands.py: Full CLI command implementations - registry/app.py: New authenticated endpoints with validation Tests: - test_collection.py: 27 unit tests for collection module - test_collection_api.py: 20 tests (8 client, 12 API with Flask skip) Documentation updated: README, CHANGELOG, CLAUDE.md, tests/README Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,932 @@
|
||||
"""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
|
||||
)
|
||||
from ...tool import list_tools
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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._load_local_collections()
|
||||
self._load_registry_collections()
|
||||
|
||||
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)")
|
||||
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 _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
|
||||
|
||||
# Handle unpublished local tools
|
||||
publish_list = []
|
||||
skip_list = []
|
||||
if resolution.local_unpublished:
|
||||
tools_list = "\n".join(f" - {t}" for t in resolution.local_unpublished)
|
||||
|
||||
dialog = QMessageBox(self)
|
||||
dialog.setIcon(QMessageBox.Warning)
|
||||
dialog.setWindowTitle("Unpublished Tools")
|
||||
dialog.setText(
|
||||
"Some local tools are not published yet.\n\n"
|
||||
f"{tools_list}\n\n"
|
||||
"Choose how to proceed:"
|
||||
)
|
||||
|
||||
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)
|
||||
dialog.exec()
|
||||
|
||||
clicked = dialog.clickedButton()
|
||||
if clicked == btn_publish:
|
||||
publish_list = resolution.local_unpublished
|
||||
elif clicked == btn_skip:
|
||||
skip_list = resolution.local_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()
|
||||
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)
|
||||
Reference in New Issue
Block a user