497 lines
15 KiB
Python
497 lines
15 KiB
Python
"""TUI for browsing the CmdForge Registry using urwid.
|
|
|
|
Uses threading for non-blocking network operations.
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Optional, List, Dict, Any, Callable
|
|
|
|
import urwid
|
|
|
|
from .registry_client import (
|
|
RegistryClient, RegistryError, ToolInfo,
|
|
get_client, PaginatedResponse
|
|
)
|
|
from .resolver import install_from_registry
|
|
|
|
|
|
# Color palette - matching the main UI style
|
|
PALETTE = [
|
|
('body', 'white', 'dark blue'),
|
|
('header', 'white', 'dark red', 'bold'),
|
|
('footer', 'black', 'light gray'),
|
|
('button', 'black', 'light gray'),
|
|
('button_focus', 'white', 'dark red', 'bold'),
|
|
('edit', 'black', 'light gray'),
|
|
('edit_focus', 'black', 'yellow'),
|
|
('listbox', 'black', 'light gray'),
|
|
('listbox_focus', 'white', 'dark red'),
|
|
('dialog', 'black', 'light gray'),
|
|
('label', 'yellow', 'dark blue', 'bold'),
|
|
('error', 'white', 'dark red', 'bold'),
|
|
('success', 'light green', 'dark blue', 'bold'),
|
|
('info', 'light cyan', 'dark blue'),
|
|
('downloads', 'light green', 'light gray'),
|
|
('category', 'dark cyan', 'light gray'),
|
|
('version', 'brown', 'light gray'),
|
|
('loading', 'yellow', 'dark blue'),
|
|
]
|
|
|
|
|
|
class ToolListItem(urwid.WidgetWrap):
|
|
"""A selectable tool item in the browse list."""
|
|
|
|
def __init__(self, tool_data: Dict[str, Any], on_select=None, on_install=None):
|
|
self.tool_data = tool_data
|
|
self.on_select = on_select
|
|
self.on_install = on_install
|
|
|
|
owner = tool_data.get("owner", "")
|
|
name = tool_data.get("name", "")
|
|
version = tool_data.get("version", "")
|
|
description = tool_data.get("description", "")[:50]
|
|
downloads = tool_data.get("downloads", 0)
|
|
category = tool_data.get("category", "")
|
|
|
|
# Format: owner/name v1.0.0 [category] ↓123
|
|
main_line = urwid.Text([
|
|
('listbox', f" {owner}/"),
|
|
('listbox', f"{name} "),
|
|
('version', f"v{version}"),
|
|
])
|
|
|
|
desc_line = urwid.Text([
|
|
('listbox', f" {description}{'...' if len(tool_data.get('description', '')) > 50 else ''}"),
|
|
])
|
|
|
|
meta_line = urwid.Text([
|
|
('category', f" [{category}]" if category else ""),
|
|
('downloads', f" ↓{downloads}"),
|
|
])
|
|
|
|
pile = urwid.Pile([main_line, desc_line, meta_line])
|
|
self.attr_map = urwid.AttrMap(pile, 'listbox', 'listbox_focus')
|
|
super().__init__(self.attr_map)
|
|
|
|
def selectable(self):
|
|
return True
|
|
|
|
def keypress(self, size, key):
|
|
if key == 'enter' and self.on_select:
|
|
self.on_select(self.tool_data)
|
|
return None
|
|
if key == 'i' and self.on_install:
|
|
self.on_install(self.tool_data)
|
|
return None
|
|
return key
|
|
|
|
def mouse_event(self, size, event, button, col, row, focus):
|
|
if event == 'mouse press' and button == 1 and self.on_select:
|
|
self.on_select(self.tool_data)
|
|
return True
|
|
return False
|
|
|
|
|
|
class SearchEdit(urwid.Edit):
|
|
"""Search box that triggers callback on enter."""
|
|
|
|
def __init__(self, on_search=None):
|
|
self.on_search = on_search
|
|
super().__init__(caption="Search: ", edit_text="")
|
|
|
|
def keypress(self, size, key):
|
|
if key == 'enter' and self.on_search:
|
|
self.on_search(self.edit_text)
|
|
return None
|
|
return super().keypress(size, key)
|
|
|
|
|
|
class AsyncOperation:
|
|
"""Manages async operations with UI callbacks."""
|
|
|
|
def __init__(self, executor: ThreadPoolExecutor):
|
|
self.executor = executor
|
|
self._write_fd: Optional[int] = None
|
|
self._read_fd: Optional[int] = None
|
|
self._pending_callbacks: List[Callable] = []
|
|
self._lock = threading.Lock()
|
|
|
|
def setup_pipe(self, loop: urwid.MainLoop):
|
|
"""Setup a pipe for thread-safe UI updates."""
|
|
self._read_fd, self._write_fd = os.pipe()
|
|
loop.watch_file(self._read_fd, self._handle_callback)
|
|
|
|
def cleanup(self):
|
|
"""Cleanup pipe file descriptors."""
|
|
if self._read_fd is not None:
|
|
os.close(self._read_fd)
|
|
if self._write_fd is not None:
|
|
os.close(self._write_fd)
|
|
|
|
def _handle_callback(self):
|
|
"""Handle pending callbacks from worker threads."""
|
|
# Read and discard the notification byte
|
|
os.read(self._read_fd, 1)
|
|
|
|
# Process pending callbacks
|
|
with self._lock:
|
|
callbacks = self._pending_callbacks[:]
|
|
self._pending_callbacks.clear()
|
|
|
|
for callback in callbacks:
|
|
callback()
|
|
|
|
def _schedule_callback(self, callback: Callable):
|
|
"""Schedule a callback to run on the main thread."""
|
|
with self._lock:
|
|
self._pending_callbacks.append(callback)
|
|
|
|
# Wake up the main loop
|
|
if self._write_fd is not None:
|
|
os.write(self._write_fd, b'x')
|
|
|
|
def run_async(
|
|
self,
|
|
operation: Callable,
|
|
on_success: Callable[[Any], None],
|
|
on_error: Callable[[Exception], None]
|
|
):
|
|
"""Run an operation asynchronously."""
|
|
def worker():
|
|
try:
|
|
result = operation()
|
|
self._schedule_callback(lambda: on_success(result))
|
|
except Exception as e:
|
|
self._schedule_callback(lambda: on_error(e))
|
|
|
|
self.executor.submit(worker)
|
|
|
|
|
|
class RegistryBrowser:
|
|
"""TUI browser for the CmdForge Registry."""
|
|
|
|
def __init__(self):
|
|
self.client = get_client()
|
|
self.tools: List[Dict] = []
|
|
self.categories: List[Dict] = []
|
|
self.current_category: Optional[str] = None
|
|
self.current_query: str = ""
|
|
self.current_page: int = 1
|
|
self.total_pages: int = 1
|
|
self.status_message: str = ""
|
|
self.loop: Optional[urwid.MainLoop] = None
|
|
self.loading: bool = False
|
|
|
|
# Thread pool for async operations
|
|
self.executor = ThreadPoolExecutor(max_workers=2)
|
|
self.async_ops = AsyncOperation(self.executor)
|
|
|
|
# Build UI
|
|
self._build_ui()
|
|
|
|
def _build_ui(self):
|
|
"""Build the main UI layout."""
|
|
# Header
|
|
self.header = urwid.AttrMap(
|
|
urwid.Text(" CmdForge Registry Browser ", align='center'),
|
|
'header'
|
|
)
|
|
|
|
# Search box
|
|
self.search_edit = SearchEdit(on_search=self._do_search)
|
|
search_widget = urwid.AttrMap(self.search_edit, 'edit', 'edit_focus')
|
|
|
|
# Category selector
|
|
self.category_text = urwid.Text("Category: All")
|
|
category_widget = urwid.AttrMap(self.category_text, 'info')
|
|
|
|
# Top bar with search and category
|
|
top_bar = urwid.Columns([
|
|
('weight', 2, search_widget),
|
|
('weight', 1, category_widget),
|
|
], dividechars=2)
|
|
|
|
# Tools list
|
|
self.list_walker = urwid.SimpleFocusListWalker([])
|
|
self.listbox = urwid.ListBox(self.list_walker)
|
|
list_frame = urwid.LineBox(self.listbox, title="Tools")
|
|
|
|
# Detail panel (right side)
|
|
self.detail_text = urwid.Text("Select a tool to view details\n\nPress 'i' to install")
|
|
self.detail_box = urwid.LineBox(
|
|
urwid.Filler(self.detail_text, valign='top'),
|
|
title="Details"
|
|
)
|
|
|
|
# Main content area with list and details
|
|
self.main_columns = urwid.Columns([
|
|
('weight', 2, list_frame),
|
|
('weight', 1, self.detail_box),
|
|
], dividechars=1)
|
|
|
|
# Status bar
|
|
self.status_text = urwid.Text(" Loading...")
|
|
self.footer = urwid.AttrMap(
|
|
urwid.Columns([
|
|
self.status_text,
|
|
urwid.Text("↑↓:Navigate Enter:Details i:Install /:Search c:Category q:Quit", align='right'),
|
|
]),
|
|
'footer'
|
|
)
|
|
|
|
# Main frame
|
|
body = urwid.Pile([
|
|
('pack', urwid.AttrMap(top_bar, 'body')),
|
|
('pack', urwid.Divider()),
|
|
self.main_columns,
|
|
])
|
|
|
|
self.frame = urwid.Frame(
|
|
urwid.AttrMap(body, 'body'),
|
|
header=self.header,
|
|
footer=self.footer
|
|
)
|
|
|
|
def _load_tools(self, query: str = "", category: str = None, page: int = 1):
|
|
"""Load tools from the registry asynchronously."""
|
|
if self.loading:
|
|
return
|
|
|
|
self.loading = True
|
|
self._set_status("Loading...", loading=True)
|
|
|
|
def fetch():
|
|
if query:
|
|
return self.client.search_tools(
|
|
query=query,
|
|
category=category,
|
|
page=page,
|
|
per_page=20
|
|
)
|
|
else:
|
|
return self.client.list_tools(
|
|
category=category,
|
|
page=page,
|
|
per_page=20
|
|
)
|
|
|
|
def on_success(result: PaginatedResponse):
|
|
self.loading = False
|
|
self.tools = result.data
|
|
self.current_page = result.page
|
|
self.total_pages = result.total_pages
|
|
self._update_list()
|
|
self._set_status(f"Found {result.total} tools (page {result.page}/{result.total_pages})")
|
|
|
|
def on_error(e: Exception):
|
|
self.loading = False
|
|
if isinstance(e, RegistryError):
|
|
self._set_status(f"Error: {e.message}")
|
|
else:
|
|
self._set_status(f"Error: {e}")
|
|
|
|
self.async_ops.run_async(fetch, on_success, on_error)
|
|
|
|
def _load_categories(self):
|
|
"""Load categories from the registry asynchronously."""
|
|
def fetch():
|
|
return self.client.get_categories()
|
|
|
|
def on_success(categories):
|
|
self.categories = categories
|
|
|
|
def on_error(e):
|
|
self.categories = []
|
|
|
|
self.async_ops.run_async(fetch, on_success, on_error)
|
|
|
|
def _update_list(self):
|
|
"""Update the tool list display."""
|
|
self.list_walker.clear()
|
|
|
|
if not self.tools:
|
|
self.list_walker.append(urwid.Text(" No tools found"))
|
|
return
|
|
|
|
for tool in self.tools:
|
|
item = ToolListItem(
|
|
tool,
|
|
on_select=self._show_detail,
|
|
on_install=self._install_tool
|
|
)
|
|
self.list_walker.append(item)
|
|
self.list_walker.append(urwid.Divider('─'))
|
|
|
|
def _show_detail(self, tool_data: Dict):
|
|
"""Show tool details in the detail panel."""
|
|
owner = tool_data.get("owner", "")
|
|
name = tool_data.get("name", "")
|
|
version = tool_data.get("version", "")
|
|
description = tool_data.get("description", "No description")
|
|
category = tool_data.get("category", "")
|
|
tags = tool_data.get("tags", [])
|
|
downloads = tool_data.get("downloads", 0)
|
|
|
|
detail = f"""{owner}/{name}
|
|
Version: {version}
|
|
Category: {category}
|
|
Downloads: {downloads}
|
|
|
|
{description}
|
|
|
|
Tags: {', '.join(tags) if tags else 'None'}
|
|
|
|
Install command:
|
|
cmdforge registry install {owner}/{name}
|
|
|
|
Press 'i' to install this tool
|
|
"""
|
|
self.detail_text.set_text(detail)
|
|
|
|
def _install_tool(self, tool_data: Dict):
|
|
"""Install the selected tool asynchronously."""
|
|
if self.loading:
|
|
return
|
|
|
|
owner = tool_data.get("owner", "")
|
|
name = tool_data.get("name", "")
|
|
|
|
self.loading = True
|
|
self._set_status(f"Installing {owner}/{name}...", loading=True)
|
|
|
|
def install():
|
|
return install_from_registry(f"{owner}/{name}")
|
|
|
|
def on_success(resolved):
|
|
self.loading = False
|
|
self._set_status(f"Installed: {resolved.full_name}@{resolved.version}")
|
|
|
|
def on_error(e):
|
|
self.loading = False
|
|
self._set_status(f"Install failed: {e}")
|
|
|
|
self.async_ops.run_async(install, on_success, on_error)
|
|
|
|
def _do_search(self, query: str):
|
|
"""Perform search."""
|
|
self.current_query = query
|
|
self.current_page = 1
|
|
self._load_tools(query=query, category=self.current_category)
|
|
|
|
def _cycle_category(self):
|
|
"""Cycle through categories."""
|
|
if not self.categories:
|
|
self._load_categories()
|
|
# Schedule the cycle after categories load
|
|
return
|
|
|
|
if not self.categories:
|
|
return
|
|
|
|
cat_names = [None] + [c.get("name") for c in self.categories]
|
|
try:
|
|
idx = cat_names.index(self.current_category)
|
|
idx = (idx + 1) % len(cat_names)
|
|
except ValueError:
|
|
idx = 0
|
|
|
|
self.current_category = cat_names[idx]
|
|
cat_display = self.current_category or "All"
|
|
self.category_text.set_text(f"Category: {cat_display}")
|
|
self._load_tools(query=self.current_query, category=self.current_category)
|
|
|
|
def _next_page(self):
|
|
"""Go to next page."""
|
|
if self.current_page < self.total_pages:
|
|
self.current_page += 1
|
|
self._load_tools(
|
|
query=self.current_query,
|
|
category=self.current_category,
|
|
page=self.current_page
|
|
)
|
|
|
|
def _prev_page(self):
|
|
"""Go to previous page."""
|
|
if self.current_page > 1:
|
|
self.current_page -= 1
|
|
self._load_tools(
|
|
query=self.current_query,
|
|
category=self.current_category,
|
|
page=self.current_page
|
|
)
|
|
|
|
def _set_status(self, message: str, loading: bool = False):
|
|
"""Update status bar message."""
|
|
if loading:
|
|
self.status_text.set_text(('loading', f" ⟳ {message}"))
|
|
else:
|
|
self.status_text.set_text(f" {message}")
|
|
|
|
def _handle_input(self, key):
|
|
"""Handle global key input."""
|
|
if key in ('q', 'Q'):
|
|
raise urwid.ExitMainLoop()
|
|
elif key == '/':
|
|
# Focus search box
|
|
self.frame.body.base_widget.set_focus(0)
|
|
return None
|
|
elif key == 'c':
|
|
self._cycle_category()
|
|
return None
|
|
elif key == 'n':
|
|
self._next_page()
|
|
return None
|
|
elif key == 'p':
|
|
self._prev_page()
|
|
return None
|
|
elif key == 'r':
|
|
# Refresh current view
|
|
self._load_tools(
|
|
query=self.current_query,
|
|
category=self.current_category,
|
|
page=self.current_page
|
|
)
|
|
return None
|
|
return key
|
|
|
|
def run(self):
|
|
"""Run the TUI browser."""
|
|
# Create main loop
|
|
self.loop = urwid.MainLoop(
|
|
self.frame,
|
|
palette=PALETTE,
|
|
unhandled_input=self._handle_input,
|
|
handle_mouse=True
|
|
)
|
|
|
|
# Setup async pipe for thread-safe callbacks
|
|
self.async_ops.setup_pipe(self.loop)
|
|
|
|
try:
|
|
# Initial load (async)
|
|
self._load_categories()
|
|
self._load_tools()
|
|
|
|
# Run main loop
|
|
self.loop.run()
|
|
finally:
|
|
# Cleanup
|
|
self.async_ops.cleanup()
|
|
self.executor.shutdown(wait=False)
|
|
|
|
|
|
def run_registry_browser():
|
|
"""Entry point for the registry browser TUI."""
|
|
try:
|
|
browser = RegistryBrowser()
|
|
browser.run()
|
|
except RegistryError as e:
|
|
print(f"Error connecting to registry: {e.message}")
|
|
return 1
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
return 1
|
|
return 0
|