diff --git a/src/toolong/eliot_view.py b/src/toolong/eliot_view.py new file mode 100644 index 0000000..9aab2a7 --- /dev/null +++ b/src/toolong/eliot_view.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from textual.widgets import Tree +from textual.widgets.tree import TreeNode +from rich.text import Text +from datetime import datetime +import json +from typing import Dict, Any +from textual.binding import Binding +from textual import on +from textual.app import ComposeResult +from textual.containers import Container +from eliot.parse import Parser, Task, WrittenAction, WrittenMessage +from pathlib import Path + +class EliotTree(Tree): + """A tree widget for displaying Eliot logs with folding support.""" + + DEFAULT_CSS = """ + EliotTree { + padding: 0; + } + """ + + BINDINGS = [ + Binding("right", "expand", "Expand", show=False), + Binding("left", "collapse", "Collapse", show=False), + Binding("space", "select", "Select", show=False), + ] + + def __init__(self, file_name: str | None = None) -> None: + super().__init__(file_name or "Eliot Log") + self._parser = Parser() + self._task_nodes: Dict[str, TreeNode] = {} + self.loading = False + + def _format_node_label(self, eliot_node: Task | WrittenAction | WrittenMessage | tuple) -> Text: + """Format a node's label based on its type.""" + label = Text() + + if isinstance(eliot_node, Task): + return Text(eliot_node.root().task_uuid) + + if isinstance(eliot_node, (WrittenAction, WrittenMessage)): + # Get action/message type and task level + message = eliot_node.start_message if isinstance(eliot_node, WrittenAction) else eliot_node + action_type = message.contents.get("action_type") or message.contents.get("message_type") + task_level = "/".join(str(n) for n in message.task_level.level) + + # Format the label with single slash + label.append(f"{action_type}/{task_level}", style="cyan") + + # Add status and timestamps for actions + if isinstance(eliot_node, WrittenAction): + # Add status + status = "started" + if eliot_node.end_message: + status = eliot_node.end_message.contents.get("action_status", "started") + status_style = { + "succeeded": "green", + "failed": "red", + "started": "yellow", + }.get(status, "white") + label.append(" ⇒ ", style="bright_black") + label.append(status, style=status_style) + + # Add timestamp + start_time = datetime.fromtimestamp(eliot_node.start_message.timestamp) + label.append(f" {start_time:%Y-%m-%d %H:%M:%S}Z", style="blue") + + # Add duration if action is completed + if eliot_node.end_message: + duration = eliot_node.end_message.timestamp - eliot_node.start_message.timestamp + label.append(f" ⧖ {duration:.3f}s", style="blue") + else: + # For regular messages, just add timestamp + msg_time = datetime.fromtimestamp(message.timestamp) + label.append(f" {msg_time:%Y-%m-%d %H:%M:%S}Z", style="blue") + + return label + + if isinstance(eliot_node, tuple): + # Field nodes + key, value = eliot_node + if key not in ("task_uuid", "task_level", "action_type", "action_status", "message_type", "timestamp"): + label.append(f"{key}: ", style="bright_black") + if key in ("exception", "reason", "error", "failure"): + label.append(str(value), style="bright_red") + else: + label.append(str(value), style="white") + return label + + return Text(str(eliot_node)) + + def _get_children(self, eliot_node: Task | WrittenAction | WrittenMessage | tuple) -> list: + """Get children for a node based on its type.""" + if isinstance(eliot_node, Task): + return [eliot_node.root()] + + if isinstance(eliot_node, WrittenAction): + children = [] + # Add fields from start message + for key, value in eliot_node.start_message.contents.items(): + if key not in ("task_uuid", "task_level", "action_type", "action_status", "timestamp"): + children.append((key, value)) + # Add child actions/messages + children.extend(eliot_node.children) + # Add end message fields if present + if eliot_node.end_message: + for key, value in eliot_node.end_message.contents.items(): + if key not in ("task_uuid", "task_level", "action_type", "action_status", "timestamp"): + children.append((key, value)) + return children + + if isinstance(eliot_node, WrittenMessage): + # For message nodes, include all fields except the standard ones + return [(key, value) for key, value in eliot_node.contents.items() + if key not in ("task_uuid", "task_level", "message_type", "timestamp")] + + if isinstance(eliot_node, tuple): + key, value = eliot_node + if isinstance(value, dict): + return list(value.items()) + if isinstance(value, list): + return list(enumerate(value)) + # For message nodes in the tree, treat them as having their own fields + if isinstance(key, str) and key.endswith("_details") and isinstance(value, str): + try: + data = json.loads(value) + if isinstance(data, dict): + return list(data.items()) + except (json.JSONDecodeError, AttributeError): + pass + + return [] + + def _add_node_to_tree(self, eliot_node: Task | WrittenAction | WrittenMessage | tuple, parent: TreeNode | None = None) -> TreeNode: + """Add an Eliot node to the tree with proper structure.""" + label = self._format_node_label(eliot_node) + node = (parent or self.root).add(label) + + # Get children first to check if node should be expandable + children = self._get_children(eliot_node) + + # Set expansion properties + node.allow_expand = ( + isinstance(eliot_node, (Task, WrittenAction)) or + isinstance(eliot_node, WrittenMessage) or + (isinstance(eliot_node, tuple) and ( + isinstance(eliot_node[1], (dict, list)) or + (isinstance(eliot_node[1], str) and isinstance(eliot_node[0], str) and eliot_node[0].endswith("_details")) + )) + ) and bool(children) # Only allow expand if there are children + + # Auto-expand failed actions + if isinstance(eliot_node, WrittenAction) and eliot_node.end_message: + if eliot_node.end_message.contents.get("action_status") == "failed": + node.expand() + self._expand_failure_path(node) + + # Add children + for child in children: + self._add_node_to_tree(child, node) + + return node + + def add_log_entry(self, line: str) -> None: + """Add a log entry to the tree using Eliot's parser.""" + try: + data = json.loads(line) + completed_tasks, self._parser = self._parser.add(data) + + # Add completed tasks to the tree + for task in completed_tasks: + if task.root().task_uuid not in self._task_nodes: + node = self._add_node_to_tree(task) + self._task_nodes[task.root().task_uuid] = node + + except (json.JSONDecodeError, KeyError) as e: + print(f"Error processing log entry: {e}") + + def _expand_failure_path(self, node: TreeNode) -> None: + """Expand all nodes in the path to a failure.""" + current = node + while current and current != self.root: + current.expand() + current = current.parent + self.root.expand() + + def render_node(self, node: TreeNode) -> Text: + """Render a node with proper formatting.""" + return node.label if isinstance(node.label, Text) else Text(str(node.label)) + + def action_expand(self) -> None: + """Expand the current node.""" + if self.cursor_node and self.cursor_node.allow_expand: + self.cursor_node.expand() + + def action_collapse(self) -> None: + """Collapse the current node.""" + if self.cursor_node and self.cursor_node.allow_expand: + self.cursor_node.collapse() + + def action_select(self) -> None: + """Override default select action.""" + pass + +class EliotView(Container): + """Container for EliotTree with basic viewing functionality.""" + + DEFAULT_CSS = """ + EliotView { + width: 1fr; + height: 1fr; + } + """ + + def __init__(self) -> None: + super().__init__() + self.file_paths: list[str] = [] + self._tree = None + + @property + def tree(self) -> EliotTree: + """Get the EliotTree instance.""" + return self._tree + + def compose(self) -> ComposeResult: + """Create child widgets.""" + # Create tree with file name if we have exactly one file + file_name = Path(self.file_paths[0]).name if len(self.file_paths) == 1 else None + self._tree = EliotTree(file_name=file_name) + yield self._tree + + async def on_mount(self) -> None: + """Handle widget mount.""" + # Process existing log entries + for path in self.file_paths: + with open(path) as f: + for line in f: + line = line.strip() + self._tree.add_log_entry(line) + + # Focus the tree + self._tree.focus() \ No newline at end of file diff --git a/src/toolong/find_dialog.py b/src/toolong/find_dialog.py index 2189363..89c3ee0 100644 --- a/src/toolong/find_dialog.py +++ b/src/toolong/find_dialog.py @@ -26,8 +26,8 @@ class FindDialog(Widget, can_focus_children=True): DEFAULT_CSS = """ FindDialog { layout: horizontal; - dock: top; - padding-top: 1; + dock: top; + padding-top: 1; width: 1fr; height: auto; max-height: 70%; @@ -55,8 +55,9 @@ class FindDialog(Widget, can_focus_children=True): display: none; } } - } + } """ + BINDINGS = [ Binding("escape", "dismiss_find", "Dismiss", key_display="esc", show=False), Binding("down,j", "pointer_down", "Next", key_display="↓"), @@ -83,7 +84,7 @@ class MovePointer(Message): class SelectLine(Message): pass - def __init__(self, suggester: Suggester) -> None: + def __init__(self, suggester: Suggester | None = None) -> None: self.suggester = suggester super().__init__() diff --git a/src/toolong/format_parser.py b/src/toolong/format_parser.py index 50ff30a..b140274 100644 --- a/src/toolong/format_parser.py +++ b/src/toolong/format_parser.py @@ -10,7 +10,7 @@ from toolong.highlighter import LogHighlighter from toolong import timestamps -from typing import Optional +from typing import Optional, Dict, Any ParseResult: TypeAlias = "tuple[Optional[datetime], str, Text]" @@ -103,7 +103,55 @@ def parse(self, line: str) -> ParseResult | None: return timestamp, line, text +class EliotLogFormat(LogFormat): + """Parser for Eliot log format.""" + + def __init__(self): + self._task_cache: Dict[str, Dict[str, Any]] = {} + + def parse(self, line: str) -> ParseResult | None: + try: + data = json.loads(line) + + # Check if this is an Eliot log by looking for required fields + if not all(key in data for key in ("task_uuid", "task_level", "action_type")): + return None + + timestamp = datetime.fromtimestamp(data["timestamp"]) if "timestamp" in data else None + + # Create tree-like structure + task_uuid = data["task_uuid"] + task_level = data["task_level"] + action_type = data["action_type"] + action_status = data.get("action_status", "unknown") + + # Format the line for display + prefix = " " * (len(task_level) - 1) + if len(task_level) == 1: + display = f"{prefix}└── {action_type} ⇒ {action_status}" + else: + display = f"{prefix}├── {action_type} ⇒ {action_status}" + + # Create styled text + text = Text() + text.append(prefix, style="dim") + text.append("└── " if len(task_level) == 1 else "├── ", style="bright_black") + text.append(action_type, style="cyan") + text.append(" ⇒ ", style="bright_black") + text.append(action_status, style="green" if action_status == "succeeded" else "yellow") + + # Add duration if available + if "duration" in data: + text.append(f" ⧖ {data['duration']:.3f}s", style="blue") + + return timestamp, display, text + + except (json.JSONDecodeError, KeyError): + return None + + FORMATS = [ + EliotLogFormat(), JSONLogFormat(), CommonLogFormat(), CombinedLogFormat(), diff --git a/src/toolong/log_view.py b/src/toolong/log_view.py index e8ec302..0b374a3 100644 --- a/src/toolong/log_view.py +++ b/src/toolong/log_view.py @@ -1,8 +1,7 @@ from __future__ import annotations -from asyncio import Lock +from pathlib import Path from datetime import datetime - from textual import on from textual.app import ComposeResult from textual.binding import Binding @@ -12,9 +11,8 @@ from textual.reactive import reactive from textual.widget import Widget from textual.widgets import Label - - -from toolong.scan_progress_bar import ScanProgressBar +from asyncio import Lock +import json from toolong.messages import ( DismissOverlay, @@ -22,20 +20,18 @@ PendingLines, PointerMoved, ScanComplete, - ScanProgress, TailFile, ) from toolong.find_dialog import FindDialog from toolong.line_panel import LinePanel from toolong.watcher import WatcherBase from toolong.log_lines import LogLines - +from toolong.eliot_view import EliotView +from toolong.scan_progress_bar import ScanProgressBar SPLIT_REGEX = r"[\s/\[\]]" - MAX_DETAIL_LINE_LENGTH = 100_000 - class InfoOverlay(Widget): """Displays text under the lines widget when there are new lines.""" @@ -91,7 +87,6 @@ def watch_tail(self, tail: bool) -> None: def on_click(self) -> None: self.post_message(TailFile()) - class FooterKey(Label): """Displays a clickable label for a key.""" @@ -121,8 +116,8 @@ def render(self) -> str: async def on_click(self) -> None: await self.app.check_bindings(self.key) - class MetaLabel(Label): + """Label for metadata that can be clicked to goto.""" DEFAULT_CSS = """ MetaLabel { @@ -136,7 +131,6 @@ class MetaLabel(Label): def on_click(self) -> None: self.post_message(Goto()) - class LogFooter(Widget): """Shows a footer with information about the file and keys.""" @@ -252,7 +246,6 @@ def watch_line_no(self, line_no: int | None) -> None: def watch_timestamp(self, timestamp: datetime | None) -> None: self.update_meta() - class LogView(Horizontal): """Widget that contains log lines and associated widgets.""" @@ -281,169 +274,194 @@ class LogView(Horizontal): Binding("ctrl+g", "goto", "Go to", key_display="^g"), ] + show_line_numbers: reactive[bool] = reactive(False) show_find: reactive[bool] = reactive(False) show_panel: reactive[bool] = reactive(False) - show_line_numbers: reactive[bool] = reactive(False) tail: reactive[bool] = reactive(False) can_tail: reactive[bool] = reactive(True) - def __init__( - self, file_paths: list[str], watcher: WatcherBase, can_tail: bool = True - ) -> None: + def __init__(self, file_paths: list[str], watcher: WatcherBase, can_tail: bool = True) -> None: + super().__init__() self.file_paths = file_paths self.watcher = watcher - super().__init__() + self.eliot_view: EliotView | None = None self.can_tail = can_tail + def _is_eliot_log(self, line: str) -> bool: + """Check if a line is an Eliot log entry.""" + try: + data = json.loads(line) + return all(key in data for key in ("task_uuid", "task_level", "action_type")) + except (json.JSONDecodeError, KeyError): + return False + def compose(self) -> ComposeResult: - yield ( - log_lines := LogLines(self.watcher, self.file_paths).data_bind( + """Create child widgets.""" + yield ScanProgressBar() + + # Check first line of each file to determine if it's an Eliot log + is_eliot = False + for path in self.file_paths: + try: + with open(path) as f: + first_line = f.readline().strip() + if first_line and self._is_eliot_log(first_line): + is_eliot = True + break + except (IOError, OSError): + continue + + if is_eliot: + # For Eliot logs, use only the tree view + self.eliot_view = EliotView() + self.eliot_view.file_paths = self.file_paths + yield self.eliot_view + else: + # For regular logs, use the full log view functionality + log_lines = LogLines(self.watcher, self.file_paths) + yield log_lines.data_bind( LogView.tail, LogView.show_line_numbers, LogView.show_find, LogView.can_tail, ) - ) - yield LinePanel() - yield FindDialog(log_lines._suggester) - yield InfoOverlay().data_bind(LogView.tail) - yield LogFooter().data_bind(LogView.tail, LogView.can_tail) + yield LinePanel() + yield FindDialog(log_lines._suggester) + yield InfoOverlay().data_bind(LogView.tail) + yield LogFooter().data_bind(LogView.tail, LogView.can_tail) @on(FindDialog.Update) def filter_dialog_update(self, event: FindDialog.Update) -> None: - log_lines = self.query_one(LogLines) - log_lines.find = event.find - log_lines.regex = event.regex - log_lines.case_sensitive = event.case_sensitive + if not self.eliot_view: + log_lines = self.query_one(LogLines) + log_lines.find = event.find + log_lines.regex = event.regex + log_lines.case_sensitive = event.case_sensitive async def watch_show_find(self, show_find: bool) -> None: - if not self.is_mounted: + if not self.is_mounted or self.eliot_view: return filter_dialog = self.query_one(FindDialog) - filter_dialog.set_class(show_find, "visible") + filter_dialog.display = show_find if show_find: filter_dialog.focus_input() else: self.query_one(LogLines).focus() async def watch_show_panel(self, show_panel: bool) -> None: - self.set_class(show_panel, "show-panel") - await self.update_panel() + if not self.eliot_view: + self.set_class(show_panel, "show-panel") + await self.update_panel() @on(FindDialog.Dismiss) def dismiss_filter_dialog(self, event: FindDialog.Dismiss) -> None: - event.stop() - self.show_find = False + if not self.eliot_view: + self.show_find = False + event.stop() @on(FindDialog.MovePointer) def move_pointer(self, event: FindDialog.MovePointer) -> None: - event.stop() - log_lines = self.query_one(LogLines) - log_lines.advance_search(event.direction) + if not self.eliot_view: + event.stop() + self.query_one(LogLines).advance_search(event.direction) @on(FindDialog.SelectLine) def select_line(self) -> None: - self.show_panel = not self.show_panel + if not self.eliot_view: + self.show_panel = not self.show_panel @on(DismissOverlay) def dismiss_overlay(self) -> None: - if self.show_find: - self.show_find = False - elif self.show_panel: - self.show_panel = False - else: - self.query_one(LogLines).pointer_line = None + if not self.eliot_view: + if self.show_find: + self.show_find = False + elif self.show_panel: + self.show_panel = False + else: + setattr(self.query_one(LogLines), 'pointer_line', None) @on(TailFile) def on_tail_file(self, event: TailFile) -> None: - self.tail = event.tail - event.stop() + if not self.eliot_view: + self.tail = True + event.stop() async def update_panel(self) -> None: - if not self.show_panel: + if not self.show_panel or self.eliot_view is not None: return pointer_line = self.query_one(LogLines).pointer_line if pointer_line is not None: - line, text, timestamp = self.query_one(LogLines).get_text( + panel = self.query_one(LinePanel) + log_lines = self.query_one(LogLines) + line, text, timestamp = log_lines.get_text( pointer_line, block=True, abbreviate=True, max_line_length=MAX_DETAIL_LINE_LENGTH, ) - await self.query_one(LinePanel).update(line, text, timestamp) + await panel.update(line, text, timestamp) @on(PointerMoved) async def pointer_moved(self, event: PointerMoved): - if event.pointer_line is None: - self.show_panel = False - if self.show_panel: - await self.update_panel() - - log_lines = self.query_one(LogLines) - pointer_line = ( - log_lines.scroll_offset.y - if event.pointer_line is None - else event.pointer_line - ) - log_file, _, _ = log_lines.index_to_span(pointer_line) - log_footer = self.query_one(LogFooter) - log_footer.line_no = pointer_line - if len(log_lines.log_files) > 1: - log_footer.filename = log_file.name - - timestamp = log_lines.get_timestamp(pointer_line) - log_footer.timestamp = timestamp + if not self.eliot_view: + if event.pointer_line is None: + self.show_panel = False + if self.show_panel: + await self.update_panel() + + log_lines = self.query_one(LogLines) + pointer_line = log_lines.scroll_offset.y if event.pointer_line is None else event.pointer_line + log_file, _, _ = log_lines.index_to_span(pointer_line) + log_footer = self.query_one(LogFooter) + log_footer.line_no = pointer_line + if len(log_lines.log_files) > 1: + log_footer.filename = log_file.name + log_footer.timestamp = log_lines.get_timestamp(pointer_line) @on(PendingLines) def on_pending_lines(self, event: PendingLines) -> None: - if self.app._exit: - return - event.stop() - self.query_one(InfoOverlay).message = f"+{event.count:,} lines" - - @on(ScanProgress) - def on_scan_progress(self, event: ScanProgress): - event.stop() - scan_progress_bar = self.query_one(ScanProgressBar) - scan_progress_bar.message = event.message - scan_progress_bar.complete = event.complete + if not self.eliot_view and not self.tail: + info = self.query_one(InfoOverlay) + info.message = f"{event.count} new line{'s' if event.count > 1 else ''}" @on(ScanComplete) async def on_scan_complete(self, event: ScanComplete) -> None: - self.query_one(ScanProgressBar).remove() - log_lines = self.query_one(LogLines) - log_lines.loading = False - self.query_one("LogLines").remove_class("-scanning") - self.post_message(PointerMoved(log_lines.pointer_line)) - self.tail = True - - footer = self.query_one(LogFooter) - footer.call_after_refresh(footer.mount_keys) + try: + progress_bar = self.query_one(ScanProgressBar) + if progress_bar is not None: + progress_bar.remove() + except Exception: + pass # Progress bar might already be removed + + if self.eliot_view is not None: + self.eliot_view.tree.loading = False + self.eliot_view.tree.remove_class("-scanning") + else: + log_lines = self.query_one(LogLines) + log_lines.loading = False + log_lines.remove_class("-scanning") + self.post_message(PointerMoved(log_lines.pointer_line)) + self.tail = True + self.query_one(LogFooter).can_tail = True @on(events.DescendantFocus) @on(events.DescendantBlur) def on_descendant_focus(self, event: events.DescendantBlur) -> None: - self.set_class(isinstance(self.screen.focused, LogLines), "lines-view") + focused = self.screen.focused + self.set_class( + isinstance(focused, (LogLines if not self.eliot_view else EliotView)), + "lines-view" if not self.eliot_view else "tree-view" + ) def action_toggle_tail(self) -> None: - if not self.can_tail: - self.notify("Can't tail merged files", title="Tail", severity="error") - else: + if not self.eliot_view and self.can_tail: self.tail = not self.tail def action_show_find_dialog(self) -> None: - find_dialog = self.query_one(FindDialog) - if not self.show_find or not any( - input.has_focus for input in find_dialog.query("Input") - ): + if not self.eliot_view: self.show_find = True - find_dialog.focus_input() - - @on(Goto) - def on_goto(self) -> None: - self.action_goto() def action_goto(self) -> None: - from toolong.goto_screen import GotoScreen - - self.app.push_screen(GotoScreen(self.query_one(LogLines))) + if not self.eliot_view: + from toolong.goto_screen import GotoScreen + self.app.push_screen(GotoScreen(self.query_one(LogLines))) \ No newline at end of file diff --git a/src/toolong/ui.py b/src/toolong/ui.py index c14c33e..97967cd 100644 --- a/src/toolong/ui.py +++ b/src/toolong/ui.py @@ -1,7 +1,6 @@ from __future__ import annotations import locale - from pathlib import Path from rich import terminal_theme @@ -10,6 +9,7 @@ from textual.lazy import Lazy from textual.screen import Screen from textual.widgets import TabbedContent, TabPane +from textual.css.query import NoMatches from toolong.log_view import LogView from toolong.watcher import get_watcher @@ -20,6 +20,7 @@ class LogScreen(Screen): + """Shows log files.""" BINDINGS = [ Binding("f1", "help", "Help"), @@ -41,35 +42,35 @@ class LogScreen(Screen): """ def compose(self) -> ComposeResult: + """Create child widgets.""" assert isinstance(self.app, UI) with TabbedContent(): - if self.app.merge and len(self.app.file_paths) > 1: - tab_name = " + ".join(Path(path).name for path in self.app.file_paths) - with TabPane(tab_name): - yield Lazy( - LogView( - self.app.file_paths, - self.app.watcher, - can_tail=False, - ) + if len(self.app.file_paths) > 1: + with TabPane("All"): + yield LogView( + self.app.file_paths, + self.app.watcher, + ) + for path in self.app.file_paths: + with TabPane(path): + yield LogView( + [path], + self.app.watcher, ) - else: - for path in self.app.file_paths: - with TabPane(path): - yield Lazy( - LogView( - [path], - self.app.watcher, - can_tail=True, - ) - ) - - def on_mount(self) -> None: + + async def on_mount(self) -> None: + """Handle mount.""" assert isinstance(self.app, UI) self.query("TabbedContent Tabs").set(display=len(self.query(TabPane)) > 1) active_pane = self.query_one(TabbedContent).active_pane if active_pane is not None: - active_pane.query("LogView > LogLines").focus() + try: + active_pane.query_one("LogView > LogLines").focus() + except NoMatches: + try: + active_pane.query_one("LogView > EliotTree").focus() + except NoMatches: + pass def action_help(self) -> None: self.app.push_screen(HelpScreen()) @@ -121,7 +122,13 @@ def __init__( async def on_mount(self) -> None: self.ansi_theme_dark = terminal_theme.DIMMED_MONOKAI await self.push_screen(LogScreen()) - self.screen.query("LogLines").focus() + try: + self.screen.query_one("LogLines").focus() + except NoMatches: + try: + self.screen.query_one("EliotTree").focus() + except NoMatches: + pass self.watcher.start() def on_unmount(self) -> None: