823 lines
27 KiB
Python
Executable File
823 lines
27 KiB
Python
Executable File
#!/usr/bin/env -S uv run --quiet --script
|
|
# /// script
|
|
# dependencies = ["textual>=0.87.0", "pyperclip"]
|
|
# ///
|
|
"""
|
|
WARNING: entirely vibe coded. use as a throwaway tool
|
|
|
|
|
|
Diagnostics Viewer - Browse and inspect Goose diagnostics bundles.
|
|
|
|
Scans for diagnostics zip files, displays their sessions, and provides
|
|
an interactive viewer for examining session data, logs, and other files.
|
|
"""
|
|
import json
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Optional, Any
|
|
|
|
import pyperclip
|
|
|
|
from textual.app import App, ComposeResult
|
|
from textual.widgets import Header, Footer, Static, Tree, ListView, ListItem, Label, Input
|
|
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
|
|
from textual.binding import Binding
|
|
from textual.message import Message
|
|
from textual.screen import ModalScreen
|
|
|
|
|
|
def truncate_string(s: str, max_len: int = 100, edge_len: int = 35) -> str:
|
|
"""Truncate a string if it's longer than max_len."""
|
|
if len(s) <= max_len:
|
|
return s
|
|
|
|
omitted = len(s) - (2 * edge_len)
|
|
return f"{s[:edge_len]}[{omitted} more]{s[-edge_len:]}"
|
|
|
|
|
|
class JsonTreeView(Tree):
|
|
"""A tree widget for displaying collapsible JSON."""
|
|
|
|
BINDINGS = [
|
|
Binding("ctrl+o", "toggle_all", "Toggle All", show=True),
|
|
]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.json_data = None
|
|
self.show_root = False
|
|
self.all_expanded = False
|
|
|
|
def load_json(self, data: Any, label: str = "JSON"):
|
|
"""Load JSON data into the tree."""
|
|
self.json_data = data
|
|
self.clear()
|
|
self.root.label = label
|
|
self._build_tree(self.root, data)
|
|
# Expand all nodes by default
|
|
self.root.expand_all()
|
|
|
|
def action_toggle_all(self):
|
|
"""Toggle expansion of all nodes."""
|
|
self.all_expanded = not self.all_expanded
|
|
if self.all_expanded:
|
|
self.root.expand_all()
|
|
else:
|
|
self.root.collapse_all()
|
|
self.root.expand() # Keep root expanded
|
|
|
|
def on_tree_node_selected(self, event: Tree.NodeSelected):
|
|
"""Handle node selection - show modal for truncated strings."""
|
|
node = event.node
|
|
|
|
# Check if this is a truncated string node
|
|
if node.data and isinstance(node.data, dict) and node.data.get("truncated"):
|
|
key = node.data["key"]
|
|
value = node.data["value"]
|
|
|
|
# Show the full string in a modal
|
|
title = f"Full String Value for '{key}'"
|
|
self.app.push_screen(TextViewerModal(title, value))
|
|
|
|
# Prevent default tree expansion behavior
|
|
event.stop()
|
|
|
|
def _build_tree(self, node, data, max_depth=10, current_depth=0):
|
|
"""Recursively build the tree from JSON data."""
|
|
if current_depth > max_depth:
|
|
node.add_leaf("[dim]...[/dim]")
|
|
return
|
|
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
if isinstance(value, (dict, list)) and value:
|
|
# Expand first level by default
|
|
child = node.add(f"[cyan]{key}[/cyan]: {{...}}" if isinstance(value, dict) else f"[cyan]{key}[/cyan]: [...]", expand=(current_depth == 0))
|
|
child.data = {"key": key, "value": value, "type": type(value).__name__, "expandable": False}
|
|
self._build_tree(child, value, max_depth, current_depth + 1)
|
|
elif isinstance(value, str):
|
|
truncated = truncate_string(value)
|
|
if truncated != value:
|
|
# Make truncated strings expandable
|
|
child = node.add(f"[cyan]{key}[/cyan]: [green]\"{truncated}\"[/green]", expand=False)
|
|
child.data = {"key": key, "value": value, "type": "str", "truncated": True, "expandable": True}
|
|
child.allow_expand = False # Don't show expand icon initially
|
|
else:
|
|
node.add_leaf(f"[cyan]{key}[/cyan]: [green]\"{value}\"[/green]")
|
|
elif isinstance(value, bool):
|
|
# Check bool before int/float since bool is a subclass of int
|
|
node.add_leaf(f"[cyan]{key}[/cyan]: [magenta]{str(value).lower()}[/magenta]")
|
|
elif isinstance(value, (int, float)):
|
|
node.add_leaf(f"[cyan]{key}[/cyan]: [yellow]{value}[/yellow]")
|
|
elif value is None:
|
|
node.add_leaf(f"[cyan]{key}[/cyan]: [dim]null[/dim]")
|
|
else:
|
|
node.add_leaf(f"[cyan]{key}[/cyan]: {value}")
|
|
|
|
elif isinstance(data, list):
|
|
for i, item in enumerate(data):
|
|
if isinstance(item, (dict, list)) and item:
|
|
# Expand first level by default
|
|
child = node.add(f"[yellow]{i}[/yellow]: {{...}}" if isinstance(item, dict) else f"[yellow]{i}[/yellow]: [...]", expand=(current_depth == 0))
|
|
child.data = {"key": i, "value": item, "type": type(item).__name__, "expandable": False}
|
|
self._build_tree(child, item, max_depth, current_depth + 1)
|
|
elif isinstance(item, str):
|
|
truncated = truncate_string(item)
|
|
if truncated != item:
|
|
# Make truncated strings expandable
|
|
child = node.add(f"[yellow]{i}[/yellow]: [green]\"{truncated}\"[/green]", expand=False)
|
|
child.data = {"key": i, "value": item, "type": "str", "truncated": True, "expandable": True}
|
|
child.allow_expand = False # Don't show expand icon initially
|
|
else:
|
|
node.add_leaf(f"[yellow]{i}[/yellow]: [green]\"{item}\"[/green]")
|
|
elif isinstance(item, bool):
|
|
# Check bool before int/float since bool is a subclass of int
|
|
node.add_leaf(f"[yellow]{i}[/yellow]: [magenta]{str(item).lower()}[/magenta]")
|
|
elif isinstance(item, (int, float)):
|
|
node.add_leaf(f"[yellow]{i}[/yellow]: [yellow]{item}[/yellow]")
|
|
elif item is None:
|
|
node.add_leaf(f"[yellow]{i}[/yellow]: [dim]null[/dim]")
|
|
else:
|
|
node.add_leaf(f"[yellow]{i}[/yellow]: {item}")
|
|
|
|
|
|
class TextViewerModal(ModalScreen):
|
|
"""Modal screen for viewing long text strings."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape,q,enter", "dismiss", "Close", show=True),
|
|
Binding("c", "copy", "Copy", show=True),
|
|
]
|
|
|
|
def __init__(self, title: str, text: str):
|
|
super().__init__()
|
|
self.title = title
|
|
self.text = text
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the modal content."""
|
|
with Vertical(id="modal-container"):
|
|
yield Static(f"[bold]{self.title}[/bold]", id="modal-title")
|
|
with VerticalScroll(id="modal-scroll"):
|
|
yield Static(self.text, id="modal-text")
|
|
yield Static("[dim]Press C to copy, Escape/Q/Enter to close[/dim]", id="modal-footer")
|
|
|
|
def action_dismiss(self):
|
|
"""Dismiss the modal."""
|
|
self.app.pop_screen()
|
|
|
|
def action_copy(self):
|
|
"""Copy the text to clipboard."""
|
|
pyperclip.copy(self.text)
|
|
self.notify("Copied to clipboard")
|
|
|
|
|
|
class SearchOverlay(Container):
|
|
"""Search overlay widget."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.display = False
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Horizontal(id="search-container"):
|
|
yield Static("Search: ", id="search-label")
|
|
yield Input(placeholder="Type to search...", id="search-input")
|
|
yield Static("", id="search-results")
|
|
|
|
|
|
class DiagnosticsSession:
|
|
"""Represents a diagnostics bundle."""
|
|
|
|
def __init__(self, zip_path: Path):
|
|
self.zip_path = zip_path
|
|
self.name = "Unknown Session"
|
|
self.session_id = zip_path.stem
|
|
self.created_at = zip_path.stat().st_mtime
|
|
self._load_session_name()
|
|
|
|
def _load_session_name(self):
|
|
"""Extract session name from session.json."""
|
|
try:
|
|
with zipfile.ZipFile(self.zip_path, 'r') as zf:
|
|
# Find session.json
|
|
for name in zf.namelist():
|
|
if name.endswith('session.json'):
|
|
with zf.open(name) as f:
|
|
data = json.load(f)
|
|
self.name = data.get('name', 'Unknown Session')
|
|
self.session_id = data.get('id', self.zip_path.stem)
|
|
break
|
|
except Exception as e:
|
|
self.name = f"Error loading: {e}"
|
|
|
|
def get_file_list(self) -> list[str]:
|
|
"""Get list of files in the zip, sorted with system.txt first."""
|
|
try:
|
|
with zipfile.ZipFile(self.zip_path, 'r') as zf:
|
|
files = zf.namelist()
|
|
|
|
# Sort: system.txt first, then session.json, then alphabetically
|
|
def sort_key(f):
|
|
if f.endswith('system.txt'):
|
|
return (0, f)
|
|
elif f.endswith('session.json'):
|
|
return (1, f)
|
|
elif f.endswith('config.yaml'):
|
|
return (2, f)
|
|
else:
|
|
return (3, f)
|
|
|
|
return sorted(files, key=sort_key)
|
|
except Exception:
|
|
return []
|
|
|
|
def read_file(self, filename: str) -> Optional[str]:
|
|
"""Read a file from the zip.
|
|
|
|
Returns:
|
|
File content as string, or None if file cannot be read.
|
|
"""
|
|
try:
|
|
with zipfile.ZipFile(self.zip_path, 'r') as zf:
|
|
with zf.open(filename) as f:
|
|
return f.read().decode('utf-8', errors='replace')
|
|
except Exception:
|
|
# File not found or cannot be read
|
|
return None
|
|
|
|
|
|
class FileContentPane(Vertical):
|
|
"""A pane that shows either JSON tree or plain text."""
|
|
|
|
def __init__(self, title: str):
|
|
super().__init__()
|
|
self.title = title
|
|
self.content_type = "empty"
|
|
self.json_data = None
|
|
self.text_content = ""
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the pane content."""
|
|
if self.content_type == "json":
|
|
tree = JsonTreeView(self.title)
|
|
if self.json_data is not None:
|
|
tree.load_json(self.json_data, self.title)
|
|
yield tree
|
|
elif self.content_type == "text":
|
|
with VerticalScroll():
|
|
yield Static(self.text_content)
|
|
else:
|
|
yield Static("[dim]No content[/dim]")
|
|
|
|
def set_json(self, data: Any):
|
|
"""Set JSON content."""
|
|
self.content_type = "json"
|
|
self.json_data = data
|
|
|
|
def set_text(self, text: str):
|
|
"""Set text content."""
|
|
self.content_type = "text"
|
|
self.text_content = text
|
|
|
|
|
|
class FileViewer(Vertical):
|
|
"""Widget for viewing file contents."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.current_session = None
|
|
self.current_filename = None
|
|
self.current_part = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
with Vertical(id="content-area"):
|
|
yield Static("[dim]Select a file to view[/dim]")
|
|
|
|
yield SearchOverlay()
|
|
|
|
def update_content(self, session: DiagnosticsSession, filename: str, part: str = None):
|
|
"""Update the viewer with new file content.
|
|
|
|
Args:
|
|
session: The diagnostics session
|
|
filename: The file to display
|
|
part: For JSONL files, either "request" or "responses"
|
|
"""
|
|
self.current_session = session
|
|
self.current_filename = filename
|
|
self.current_part = part
|
|
|
|
content = session.read_file(filename)
|
|
if content is None:
|
|
self._show_plain(filename, f"[red]Error: Could not read file '{filename}'[/red]")
|
|
return
|
|
|
|
# Check if this is a JSONL file
|
|
if filename.endswith('.jsonl') and part:
|
|
self._show_jsonl(filename, content, part)
|
|
elif filename.endswith('.json'):
|
|
self._show_json(filename, content)
|
|
else:
|
|
self._show_plain(filename, content)
|
|
|
|
# Auto-focus the content
|
|
self.post_message(self.ContentReady())
|
|
|
|
def _show_jsonl(self, filename: str, content: str, part: str):
|
|
"""Show JSONL file - either request or responses part."""
|
|
lines = [line.strip() for line in content.strip().split('\n') if line.strip()]
|
|
|
|
# Parse lines
|
|
request_data = None
|
|
responses = []
|
|
|
|
if len(lines) > 0:
|
|
try:
|
|
request_data = json.loads(lines[0])
|
|
except json.JSONDecodeError:
|
|
# Skip malformed request line; diagnostics may be truncated or corrupted
|
|
pass
|
|
|
|
for i in range(1, len(lines)):
|
|
try:
|
|
responses.append(json.loads(lines[i]))
|
|
except json.JSONDecodeError:
|
|
# Skip individual malformed response lines; show only valid JSON entries
|
|
pass
|
|
|
|
# Show content
|
|
content_area = self.query_one("#content-area", Vertical)
|
|
content_area.remove_children()
|
|
|
|
if part == "request" and request_data:
|
|
tree = JsonTreeView(f"{filename} - request")
|
|
tree.load_json(request_data, f"{filename} - request")
|
|
content_area.mount(tree)
|
|
elif part == "responses" and responses:
|
|
tree = JsonTreeView(f"{filename} - responses")
|
|
if len(responses) == 1:
|
|
tree.load_json(responses[0], f"{filename} - response")
|
|
else:
|
|
tree.load_json(responses, f"{filename} - responses")
|
|
content_area.mount(tree)
|
|
else:
|
|
content_area.mount(Static("[red]No data available for this part[/red]"))
|
|
|
|
def _show_json(self, filename: str, content: str):
|
|
"""Show JSON file with collapsible tree."""
|
|
# Show content
|
|
content_area = self.query_one("#content-area", Vertical)
|
|
content_area.remove_children()
|
|
|
|
tree = JsonTreeView(filename)
|
|
try:
|
|
data = json.loads(content)
|
|
tree.load_json(data, filename)
|
|
except json.JSONDecodeError as e:
|
|
tree.root.add_leaf(f"[red]Error parsing JSON: {e}[/red]")
|
|
|
|
content_area.mount(tree)
|
|
|
|
def _show_plain(self, filename: str, content: str):
|
|
"""Show plain text content."""
|
|
# Show content
|
|
content_area = self.query_one("#content-area", Vertical)
|
|
content_area.remove_children()
|
|
|
|
# Create and mount the scroll container with the content
|
|
scroll = VerticalScroll()
|
|
content_area.mount(scroll)
|
|
scroll.mount(Static(content))
|
|
|
|
def focus_content(self):
|
|
"""Focus the content area."""
|
|
try:
|
|
# Try to focus a tree if present
|
|
tree = self.query_one(JsonTreeView)
|
|
tree.focus()
|
|
except Exception:
|
|
# No JsonTreeView present (e.g., showing plain text), which is fine
|
|
pass
|
|
|
|
def action_search(self):
|
|
"""Show search overlay.
|
|
|
|
TODO: Implement actual search functionality - currently just shows UI.
|
|
"""
|
|
overlay = self.query_one(SearchOverlay)
|
|
overlay.display = not overlay.display
|
|
if overlay.display:
|
|
search_input = overlay.query_one("#search-input", Input)
|
|
search_input.focus()
|
|
|
|
class ContentReady(Message):
|
|
"""Message sent when content is ready to be focused."""
|
|
pass
|
|
|
|
|
|
class SessionViewer(Vertical):
|
|
"""Widget for viewing a diagnostics session."""
|
|
|
|
BINDINGS = [
|
|
Binding("ctrl+f,cmd+f", "search", "Search", show=True),
|
|
Binding("c", "copy_file", "Copy file", show=True),
|
|
]
|
|
|
|
def __init__(self, session: DiagnosticsSession):
|
|
super().__init__()
|
|
self.session = session
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Static(f"[bold yellow]Session: {self.session.name}[/bold yellow]", id="session-title")
|
|
|
|
with Horizontal(id="main-content"):
|
|
# Left side: File browser
|
|
with Vertical(id="file-browser"):
|
|
yield Static("[bold]Files:[/bold]")
|
|
tree = Tree("Files", id="file-tree")
|
|
tree.show_root = False
|
|
|
|
# Build file tree
|
|
files = self.session.get_file_list()
|
|
|
|
# Group by directory
|
|
dirs = {}
|
|
for file in files:
|
|
parts = file.split('/')
|
|
is_jsonl = file.endswith('.jsonl')
|
|
|
|
if len(parts) == 1:
|
|
# Root file
|
|
if is_jsonl:
|
|
# Add two entries for JSONL files
|
|
tree.root.add_leaf(f"{file} - request", data={"file": file, "part": "request"})
|
|
tree.root.add_leaf(f"{file} - responses", data={"file": file, "part": "responses"})
|
|
else:
|
|
tree.root.add_leaf(file, data={"file": file, "part": None})
|
|
else:
|
|
# File in directory
|
|
dir_name = parts[0]
|
|
if dir_name not in dirs:
|
|
dirs[dir_name] = tree.root.add(dir_name, expand=True)
|
|
|
|
file_name = '/'.join(parts[1:])
|
|
if is_jsonl:
|
|
# Add two entries for JSONL files
|
|
dirs[dir_name].add_leaf(f"{file_name} - request", data={"file": file, "part": "request"})
|
|
dirs[dir_name].add_leaf(f"{file_name} - responses", data={"file": file, "part": "responses"})
|
|
else:
|
|
dirs[dir_name].add_leaf(file_name, data={"file": file, "part": None})
|
|
|
|
yield tree
|
|
|
|
# Right side: File viewer
|
|
yield FileViewer()
|
|
|
|
def on_mount(self):
|
|
"""Handle mount event."""
|
|
# Show system.txt by default and select it in tree
|
|
files = self.session.get_file_list()
|
|
system_file = next((f for f in files if f.endswith('system.txt')), None)
|
|
if system_file:
|
|
viewer = self.query_one(FileViewer)
|
|
viewer.update_content(self.session, system_file)
|
|
|
|
# Select the first node in the tree
|
|
tree = self.query_one("#file-tree", Tree)
|
|
if tree.root.children:
|
|
tree.select_node(tree.root.children[0])
|
|
|
|
# Focus the tree initially
|
|
tree = self.query_one("#file-tree", Tree)
|
|
tree.focus()
|
|
|
|
def on_tree_node_selected(self, event: Tree.NodeSelected):
|
|
"""Handle file selection."""
|
|
# Only handle selections from the file tree, not the JSON tree
|
|
if event.control.id != "file-tree":
|
|
return
|
|
|
|
# Make sure it's a file (has dict data), not a directory
|
|
if event.node.data and isinstance(event.node.data, dict) and event.node.parent:
|
|
viewer = self.query_one(FileViewer)
|
|
file_path = event.node.data["file"]
|
|
part = event.node.data["part"]
|
|
viewer.update_content(self.session, file_path, part)
|
|
|
|
def on_file_viewer_content_ready(self, event: FileViewer.ContentReady):
|
|
"""Handle content ready event by focusing the viewer."""
|
|
viewer = self.query_one(FileViewer)
|
|
viewer.focus_content()
|
|
|
|
def action_search(self):
|
|
"""Toggle search in the file viewer."""
|
|
viewer = self.query_one(FileViewer)
|
|
viewer.action_search()
|
|
|
|
def action_copy_file(self):
|
|
"""Copy the current file content to clipboard."""
|
|
viewer = self.query_one(FileViewer)
|
|
if not viewer.current_session or not viewer.current_filename:
|
|
self.app.notify("No file selected")
|
|
return
|
|
|
|
content = viewer.current_session.read_file(viewer.current_filename)
|
|
if content is None:
|
|
self.app.notify("Could not read file")
|
|
return
|
|
|
|
# For JSONL files with a part, extract just that part and pretty-format
|
|
if viewer.current_filename.endswith('.jsonl') and viewer.current_part:
|
|
lines = [line.strip() for line in content.strip().split('\n') if line.strip()]
|
|
if viewer.current_part == "request" and lines:
|
|
try:
|
|
data = json.loads(lines[0])
|
|
content = json.dumps(data, indent=2)
|
|
except json.JSONDecodeError:
|
|
content = lines[0]
|
|
elif viewer.current_part == "responses" and len(lines) > 1:
|
|
try:
|
|
responses = [json.loads(line) for line in lines[1:]]
|
|
if len(responses) == 1:
|
|
content = json.dumps(responses[0], indent=2)
|
|
else:
|
|
content = json.dumps(responses, indent=2)
|
|
except json.JSONDecodeError:
|
|
content = '\n'.join(lines[1:])
|
|
# Pretty-format regular JSON files too
|
|
elif viewer.current_filename.endswith('.json'):
|
|
try:
|
|
data = json.loads(content)
|
|
content = json.dumps(data, indent=2)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
pyperclip.copy(content)
|
|
self.app.notify("Copied to clipboard")
|
|
|
|
def on_key(self, event):
|
|
"""Handle left/right navigation between panels."""
|
|
if event.key == "left":
|
|
tree = self.query_one("#file-tree", Tree)
|
|
tree.focus()
|
|
elif event.key == "right":
|
|
viewer = self.query_one(FileViewer)
|
|
viewer.focus_content()
|
|
|
|
|
|
class SessionList(Vertical):
|
|
"""Widget for listing available sessions."""
|
|
|
|
def __init__(self, sessions: list[DiagnosticsSession]):
|
|
super().__init__()
|
|
self.sessions = sessions
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Static("[bold yellow]Available Diagnostics Sessions[/bold yellow]\n")
|
|
|
|
if not self.sessions:
|
|
yield Static("[red]No diagnostics files found[/red]")
|
|
else:
|
|
yield Static(f"[dim]Found {len(self.sessions)} session(s)[/dim]\n")
|
|
yield ListView(id="session-list")
|
|
|
|
def on_mount(self):
|
|
"""Populate the list after mounting."""
|
|
list_view = self.query_one(ListView)
|
|
for session in self.sessions:
|
|
item = ListItem(
|
|
Label(f"{session.name}\n[dim]{session.zip_path.name}[/dim]"),
|
|
name=session.zip_path.name
|
|
)
|
|
list_view.append(item)
|
|
|
|
|
|
class DiagnosticsApp(App):
|
|
"""Diagnostics viewer application."""
|
|
|
|
# Disable command palette (Ctrl+\)
|
|
ENABLE_COMMAND_PALETTE = False
|
|
|
|
CSS = """
|
|
Screen {
|
|
background: $surface;
|
|
}
|
|
|
|
/* Modal styles */
|
|
TextViewerModal {
|
|
align: center middle;
|
|
}
|
|
|
|
#modal-container {
|
|
width: 80%;
|
|
height: 80%;
|
|
background: $surface;
|
|
border: thick $primary;
|
|
padding: 1;
|
|
}
|
|
|
|
#modal-title {
|
|
background: $primary;
|
|
color: $text;
|
|
padding: 1;
|
|
text-align: center;
|
|
dock: top;
|
|
}
|
|
|
|
#modal-scroll {
|
|
height: 1fr;
|
|
border: solid $accent;
|
|
padding: 1;
|
|
margin: 1 0;
|
|
}
|
|
|
|
#modal-text {
|
|
width: 100%;
|
|
}
|
|
|
|
#modal-footer {
|
|
text-align: center;
|
|
dock: bottom;
|
|
}
|
|
|
|
#session-title {
|
|
padding: 1;
|
|
background: $primary;
|
|
color: $text;
|
|
text-align: center;
|
|
height: 3;
|
|
}
|
|
|
|
#main-content {
|
|
height: 100%;
|
|
}
|
|
|
|
#file-browser {
|
|
width: 30%;
|
|
border-right: solid $primary;
|
|
padding: 1;
|
|
}
|
|
|
|
FileViewer {
|
|
width: 70%;
|
|
height: 100%;
|
|
}
|
|
|
|
#content-area {
|
|
height: 100%;
|
|
padding: 1;
|
|
}
|
|
|
|
JsonTreeView {
|
|
height: 100%;
|
|
scrollbar-gutter: stable;
|
|
}
|
|
|
|
#search-container {
|
|
height: 3;
|
|
background: $panel;
|
|
padding: 1;
|
|
border-top: solid $primary;
|
|
}
|
|
|
|
#search-label {
|
|
width: auto;
|
|
margin-right: 1;
|
|
}
|
|
|
|
#search-input {
|
|
width: 1fr;
|
|
margin-right: 1;
|
|
}
|
|
|
|
#search-results {
|
|
width: auto;
|
|
}
|
|
|
|
SearchOverlay {
|
|
height: auto;
|
|
}
|
|
|
|
#session-list {
|
|
height: 100%;
|
|
}
|
|
|
|
ListView {
|
|
background: $surface;
|
|
}
|
|
|
|
ListItem {
|
|
padding: 1;
|
|
}
|
|
|
|
ListItem:hover {
|
|
background: $primary 30%;
|
|
}
|
|
|
|
Tree {
|
|
height: 100%;
|
|
}
|
|
|
|
Tree:focus {
|
|
border: solid $accent;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("q", "quit", "Quit"),
|
|
Binding("escape", "back", "Back to list"),
|
|
Binding("ctrl+f,cmd+f", "search", "Search", show=False),
|
|
]
|
|
|
|
def __init__(self, diagnostics_dir: Path):
|
|
super().__init__()
|
|
self.diagnostics_dir = diagnostics_dir
|
|
self.sessions = []
|
|
self.current_view = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets."""
|
|
yield Header()
|
|
yield Footer()
|
|
|
|
def on_mount(self):
|
|
"""Handle mount event."""
|
|
self.title = "Goose Diagnostics Viewer"
|
|
self.scan_diagnostics()
|
|
self.show_session_list()
|
|
|
|
def scan_diagnostics(self):
|
|
"""Scan for diagnostics zip files."""
|
|
self.sessions = []
|
|
|
|
# Find all diagnostics zip files
|
|
for zip_path in self.diagnostics_dir.glob("diagnostics*.zip"):
|
|
session = DiagnosticsSession(zip_path)
|
|
self.sessions.append(session)
|
|
|
|
# Sort by creation time (newest first)
|
|
self.sessions.sort(key=lambda s: s.created_at, reverse=True)
|
|
|
|
def show_session_list(self):
|
|
"""Show the session list view."""
|
|
if self.current_view:
|
|
self.current_view.remove()
|
|
|
|
self.current_view = SessionList(self.sessions)
|
|
self.mount(self.current_view)
|
|
|
|
def show_session_viewer(self, session: DiagnosticsSession):
|
|
"""Show the session viewer."""
|
|
if self.current_view:
|
|
self.current_view.remove()
|
|
|
|
self.current_view = SessionViewer(session)
|
|
self.mount(self.current_view)
|
|
|
|
def on_list_view_selected(self, event: ListView.Selected):
|
|
"""Handle session selection."""
|
|
# Find the session by zip name
|
|
session_name = event.item.name
|
|
session = next((s for s in self.sessions if s.zip_path.name == session_name), None)
|
|
if session:
|
|
self.show_session_viewer(session)
|
|
|
|
def action_back(self):
|
|
"""Go back to session list."""
|
|
if isinstance(self.current_view, SessionViewer):
|
|
self.show_session_list()
|
|
|
|
def action_quit(self):
|
|
"""Quit the application."""
|
|
self.exit()
|
|
|
|
def action_search(self):
|
|
"""Toggle search."""
|
|
if isinstance(self.current_view, SessionViewer):
|
|
self.current_view.action_search()
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
# Get diagnostics directory from args or use default
|
|
if len(sys.argv) > 1:
|
|
diagnostics_dir = Path(sys.argv[1]).expanduser()
|
|
else:
|
|
diagnostics_dir = Path.home() / "Downloads"
|
|
|
|
if not diagnostics_dir.exists():
|
|
print(f"Error: Directory '{diagnostics_dir}' not found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
app = DiagnosticsApp(diagnostics_dir)
|
|
app.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|