312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""Main TUI application with enhanced keyboard navigation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from queue import Empty, Queue
|
|
from typing import TYPE_CHECKING, ClassVar, Literal
|
|
|
|
from textual import events
|
|
from textual.app import App
|
|
from textual.binding import Binding, BindingType
|
|
from textual.timer import Timer
|
|
|
|
from ...storage.base import BaseStorage
|
|
from ...storage.openwebui import OpenWebUIStorage
|
|
from ...storage.weaviate import WeaviateStorage
|
|
from .screens import CollectionOverviewScreen, HelpScreen
|
|
from .styles import TUI_CSS
|
|
from .utils.storage_manager import StorageManager
|
|
|
|
if TYPE_CHECKING:
|
|
from logging import Formatter, LogRecord
|
|
|
|
from ...storage.r2r.storage import R2RStorage
|
|
from .screens.dialogs import LogViewerScreen
|
|
else: # pragma: no cover - optional dependency fallback
|
|
R2RStorage = BaseStorage
|
|
|
|
|
|
|
|
class CollectionManagementApp(App[None]):
|
|
"""Enhanced modern Textual application with comprehensive keyboard navigation."""
|
|
|
|
CSS: ClassVar[str] = TUI_CSS
|
|
|
|
def safe_notify(
|
|
self,
|
|
message: str,
|
|
*,
|
|
severity: Literal["information", "warning", "error"] = "information",
|
|
) -> None:
|
|
"""Safely notify with markup disabled to prevent parsing errors."""
|
|
self.notify(message, severity=severity, markup=False)
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("q", "quit", "Quit"),
|
|
Binding("ctrl+c", "quit", "Quit"),
|
|
Binding("ctrl+q", "quit", "Quit"),
|
|
Binding("f1", "help", "Help"),
|
|
Binding("ctrl+h", "help", "Help"),
|
|
Binding("?", "help", "Quick Help"),
|
|
# Global navigation shortcuts
|
|
Binding("ctrl+r", "refresh_current", "Refresh Current Screen"),
|
|
Binding("ctrl+w", "close_current", "Close Current Screen"),
|
|
Binding("ctrl+l", "toggle_logs", "Logs"),
|
|
# Tab navigation shortcuts
|
|
Binding("ctrl+1", "dashboard_tab", "Dashboard", show=False),
|
|
Binding("ctrl+2", "collections_tab", "Collections", show=False),
|
|
Binding("ctrl+3", "analytics_tab", "Analytics", show=False),
|
|
]
|
|
|
|
storage_manager: StorageManager
|
|
weaviate: WeaviateStorage | None
|
|
openwebui: OpenWebUIStorage | None
|
|
r2r: R2RStorage | BaseStorage | None
|
|
log_queue: Queue[LogRecord] | None
|
|
_log_formatter: Formatter
|
|
_log_buffer: deque[str]
|
|
_log_viewer: LogViewerScreen | None
|
|
_log_file: Path | None
|
|
_log_timer: Timer | None
|
|
|
|
def __init__(
|
|
self,
|
|
storage_manager: StorageManager,
|
|
weaviate: WeaviateStorage | None = None,
|
|
openwebui: OpenWebUIStorage | None = None,
|
|
r2r: R2RStorage | BaseStorage | None = None,
|
|
*,
|
|
log_queue: Queue[LogRecord] | None = None,
|
|
log_formatter: Formatter | None = None,
|
|
log_file: Path | None = None,
|
|
) -> None:
|
|
super().__init__()
|
|
self.storage_manager = storage_manager
|
|
self.weaviate = weaviate
|
|
self.openwebui = openwebui
|
|
self.r2r = r2r
|
|
self.title: str = ""
|
|
self.sub_title: str = ""
|
|
self.log_queue = log_queue
|
|
self._log_formatter = log_formatter or logging.Formatter(
|
|
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
self._log_buffer = deque(maxlen=500)
|
|
self._log_viewer = None
|
|
self._log_file = log_file
|
|
self._log_timer = None
|
|
|
|
def on_mount(self) -> None:
|
|
"""Initialize the enhanced app with better branding."""
|
|
self.title = "🚀 Enhanced Collection Management System"
|
|
self.sub_title = (
|
|
"Advanced Document Ingestion & Management Platform with Keyboard Navigation"
|
|
)
|
|
reduced_motion_env = os.getenv("TEXTUAL_REDUCED_MOTION") or os.getenv(
|
|
"PREFER_REDUCED_MOTION"
|
|
)
|
|
if reduced_motion_env is not None:
|
|
normalized = reduced_motion_env.strip().lower()
|
|
reduced_motion_enabled = normalized in {"1", "true", "yes", "on"}
|
|
else:
|
|
reduced_motion_enabled = False
|
|
_ = self.set_class(reduced_motion_enabled, "reduced-motion")
|
|
_ = self.push_screen(
|
|
CollectionOverviewScreen(
|
|
self.storage_manager,
|
|
self.weaviate,
|
|
self.openwebui,
|
|
self.r2r,
|
|
)
|
|
)
|
|
if self.log_queue is not None and self._log_timer is None:
|
|
# Poll the queue so log output is captured without blocking the UI loop
|
|
self._log_timer = self.set_interval(0.25, self._drain_log_queue)
|
|
|
|
def _drain_log_queue(self) -> None:
|
|
"""Drain queued log records and route them to the active log viewer."""
|
|
if self.log_queue is None:
|
|
return
|
|
|
|
drained: list[str] = []
|
|
while True:
|
|
try:
|
|
record = self.log_queue.get_nowait()
|
|
except Empty:
|
|
break
|
|
message = self._log_formatter.format(record)
|
|
self._log_buffer.append(message)
|
|
drained.append(message)
|
|
|
|
if drained and self._log_viewer is not None:
|
|
self._log_viewer.append_logs(drained)
|
|
|
|
def attach_log_viewer(self, viewer: "LogViewerScreen") -> None:
|
|
"""Register an active log viewer and hydrate it with existing entries."""
|
|
self._log_viewer = viewer
|
|
viewer.replace_logs(list(self._log_buffer))
|
|
viewer.update_log_file(self._log_file)
|
|
# Drain once more to deliver any entries gathered between instantiation and mount
|
|
self._drain_log_queue()
|
|
|
|
def detach_log_viewer(self, viewer: "LogViewerScreen") -> None:
|
|
"""Remove the current log viewer when it is dismissed."""
|
|
if self._log_viewer is viewer:
|
|
self._log_viewer = None
|
|
|
|
def get_log_file_path(self) -> Path | None:
|
|
"""Return the active log file path if configured."""
|
|
return self._log_file
|
|
|
|
def action_toggle_logs(self) -> None:
|
|
"""Toggle the log viewer modal screen."""
|
|
if self._log_viewer is not None:
|
|
_ = self.pop_screen()
|
|
return
|
|
|
|
from .screens.dialogs import LogViewerScreen # Local import to avoid cycle
|
|
|
|
_ = self.push_screen(LogViewerScreen())
|
|
|
|
def action_help(self) -> None:
|
|
"""Show comprehensive help information with all keyboard shortcuts."""
|
|
help_md = """
|
|
# 🚀 Enhanced Collection Management System
|
|
|
|
## 🎯 Global Navigation
|
|
- **F1** / **Ctrl+H** / **?**: Show this help
|
|
- **Q** / **Ctrl+C** / **Ctrl+Q**: Quit application
|
|
- **Ctrl+R**: Refresh current screen
|
|
- **Ctrl+W**: Close current screen/dialog
|
|
- **Escape**: Go back/cancel current action
|
|
|
|
## 📑 Tab Navigation
|
|
- **Tab** / **Shift+Tab**: Switch between tabs
|
|
- **Ctrl+1**: Jump to Dashboard tab
|
|
- **Ctrl+2**: Jump to Collections tab
|
|
- **Ctrl+3**: Jump to Analytics tab
|
|
|
|
## 📚 Collections Management
|
|
- **R**: Refresh collections list
|
|
- **I**: Start new ingestion
|
|
- **M**: Manage documents in selected collection
|
|
- **S**: Search within selected collection
|
|
- **Ctrl+D**: Delete selected collection
|
|
|
|
## 🗂️ Table Navigation
|
|
- **Arrow Keys** / **J/K/H/L**: Navigate table cells (Vi-style)
|
|
- **Home** / **End**: Jump to first/last row
|
|
- **Page Up** / **Page Down**: Scroll by page
|
|
- **Enter**: Select/activate current row
|
|
- **Space**: Toggle row selection
|
|
- **Ctrl+A**: Select all items
|
|
- **Ctrl+Shift+A**: Clear all selections
|
|
|
|
## 📄 Document Management
|
|
- **Space**: Toggle document selection
|
|
- **Delete** / **Ctrl+D**: Delete selected documents
|
|
- **A**: Select all documents on page
|
|
- **N**: Clear selection
|
|
- **Page Up/Down**: Navigate between pages
|
|
- **Home/End**: Go to first/last page
|
|
|
|
## 🔍 Search Features
|
|
- **/** : Quick search (focus search field)
|
|
- **Ctrl+F**: Focus search input
|
|
- **Enter**: Perform search
|
|
- **F3**: Repeat last search
|
|
- **Ctrl+R**: Clear search results
|
|
- **Escape**: Clear search/exit search mode
|
|
|
|
## 📥 Ingestion Interface
|
|
- **1/2/3**: Select ingestion type (Web/Repository/Documentation)
|
|
- **Tab/Shift+Tab**: Navigate between fields
|
|
- **Enter**: Start ingestion process
|
|
- **Ctrl+I**: Quick start ingestion
|
|
- **Escape**: Cancel ingestion
|
|
|
|
## 🎨 Visual Features
|
|
- Enhanced focus indicators with colored borders
|
|
- Smooth keyboard navigation with visual feedback
|
|
- Status indicators with real-time updates
|
|
- Progress bars with detailed status messages
|
|
- Responsive design with accessibility features
|
|
|
|
## 💡 Pro Tips
|
|
- Use **Vi-style** navigation (J/K/H/L) for efficient movement
|
|
- **Tab** through interactive elements for keyboard-only operation
|
|
- Hold **Shift** with arrow keys for range selection (where supported)
|
|
- Use **Ctrl+** shortcuts for power user efficiency
|
|
- **Escape** is your friend - it cancels most operations safely
|
|
|
|
## 🚀 Performance Features
|
|
- Lazy loading for large collections
|
|
- Paginated document views
|
|
- Background refresh operations
|
|
- Efficient memory management
|
|
- Responsive UI updates
|
|
|
|
---
|
|
|
|
**Enjoy the enhanced keyboard-driven interface!** 🎉
|
|
|
|
*Press Escape, Enter, or Q to close this help.*
|
|
"""
|
|
_ = self.push_screen(HelpScreen(help_md))
|
|
|
|
def action_refresh_current(self) -> None:
|
|
"""Refresh the current screen if it supports it."""
|
|
current_screen = self.screen
|
|
handler = getattr(current_screen, "action_refresh", None)
|
|
if callable(handler):
|
|
_ = handler()
|
|
return
|
|
self.notify("Current screen doesn't support refresh", severity="information")
|
|
|
|
def action_close_current(self) -> None:
|
|
"""Close current screen/dialog."""
|
|
if len(self.screen_stack) > 1: # Don't close the main screen
|
|
_ = self.pop_screen()
|
|
else:
|
|
_ = self.notify("Cannot close main screen. Use Q to quit.", severity="warning")
|
|
|
|
def action_dashboard_tab(self) -> None:
|
|
"""Switch to dashboard tab in current screen."""
|
|
current_screen = self.screen
|
|
handler = getattr(current_screen, "action_tab_dashboard", None)
|
|
if callable(handler):
|
|
_ = handler()
|
|
|
|
def action_collections_tab(self) -> None:
|
|
"""Switch to collections tab in current screen."""
|
|
current_screen = self.screen
|
|
handler = getattr(current_screen, "action_tab_collections", None)
|
|
if callable(handler):
|
|
_ = handler()
|
|
|
|
def action_analytics_tab(self) -> None:
|
|
"""Switch to analytics tab in current screen."""
|
|
current_screen = self.screen
|
|
handler = getattr(current_screen, "action_tab_analytics", None)
|
|
if callable(handler):
|
|
_ = handler()
|
|
|
|
def on_key(self, event: events.Key) -> None:
|
|
"""Handle global keyboard shortcuts."""
|
|
# Handle global shortcuts that might not be bound to specific actions
|
|
if event.key == "ctrl+shift+?":
|
|
# Alternative help shortcut
|
|
self.action_help()
|
|
_ = event.prevent_default()
|
|
elif event.key == "ctrl+alt+r":
|
|
# Force refresh all connections
|
|
_ = self.notify("🔄 Refreshing all connections...", severity="information")
|
|
# This could trigger a full reinit if needed
|
|
_ = event.prevent_default()
|
|
# No else clause needed - just handle our events
|