""" In-app log viewer. Opens as a non-modal window (or a workspace tab) and shows the contents of ~/.dbclient/logs/dbclient.log with: • Level filter buttons (ALL / DEBUG / INFO / WARNING / ERROR / CRITICAL) • Auto-tail mode (follows the file like `tail -f`) • Colour coding per level • Search / highlight • "Open folder" button — opens the logs directory in Explorer/Finder • "Copy" and "Clear view" (does NOT delete the log file) """ from __future__ import annotations import os import re from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QPlainTextEdit, QLineEdit, QCheckBox, QButtonGroup, QAbstractButton, QFileDialog, QApplication, QSizePolicy, ) from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtGui import ( QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QTextDocument, QTextCursor, ) from app.utils.logger import get_log_path # ── Level colours (Catppuccin Frappé palette) ───────────────────────────────── _LEVEL_COLOURS: dict[str, str] = { "DEBUG": "#737994", # overlay0 / dimmed "INFO": "#c6d0f5", # text (default) "WARNING": "#e5c890", # yellow "ERROR": "#e78284", # red "CRITICAL": "#f4b8e4", # pink / bright } _LEVEL_ORDER = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] _LEVEL_RANK = {lv: i for i, lv in enumerate(_LEVEL_ORDER)} # ── Syntax highlighter ──────────────────────────────────────────────────────── class _LogHighlighter(QSyntaxHighlighter): """Colour-code log lines by severity level.""" def __init__(self, document: QTextDocument, min_level: str = "DEBUG"): super().__init__(document) self._min_rank = _LEVEL_RANK.get(min_level, 0) self._search: str = "" def set_min_level(self, level: str): self._min_rank = _LEVEL_RANK.get(level, 0) self.rehighlight() def set_search(self, text: str): self._search = text.lower() self.rehighlight() def highlightBlock(self, text: str): # Detect level from the bracketed token e.g. [WARNING ] level_colour = _LEVEL_COLOURS["INFO"] matched_level = "INFO" for level in _LEVEL_ORDER: if f"[{level}" in text: matched_level = level level_colour = _LEVEL_COLOURS[level] break # Apply base colour for the whole line fmt = QTextCharFormat() fmt.setForeground(QColor(level_colour)) if matched_level in ("ERROR", "CRITICAL"): # Bold for high-severity lines f = QFont() f.setBold(True) fmt.setFont(f) self.setFormat(0, len(text), fmt) # Highlight search matches in bright yellow if self._search: search_fmt = QTextCharFormat() search_fmt.setBackground(QColor("#e5c890")) search_fmt.setForeground(QColor("#303446")) idx = text.lower().find(self._search) while idx != -1: self.setFormat(idx, len(self._search), search_fmt) idx = text.lower().find(self._search, idx + 1) # ── Level filter button ─────────────────────────────────────────────────────── class _LevelBtn(QPushButton): def __init__(self, level: str, colour: str, parent=None): label = "ALL" if level == "DEBUG" else level super().__init__(label, parent) self.setCheckable(True) self.setFixedHeight(26) self._colour = colour self._apply_style(False) def _apply_style(self, checked: bool): if checked: self.setStyleSheet( f"QPushButton {{ background: {self._colour}; color: #303446; " f"border: none; border-radius: 3px; font-weight: bold; }}" ) else: self.setStyleSheet( f"QPushButton {{ background: transparent; color: {self._colour}; " f"border: 1px solid {self._colour}; border-radius: 3px; }}" f"QPushButton:hover {{ background: {self._colour}22; }}" ) # Override to auto-apply style on toggle def setChecked(self, v: bool): super().setChecked(v) self._apply_style(v) # ── Main Log Viewer widget ──────────────────────────────────────────────────── class LogViewer(QWidget): """ Non-modal log viewer window. Can be used stand-alone (as a top-level window) or embedded as a workspace tab — it's just a QWidget. """ def __init__(self, parent=None): super().__init__(parent) self._log_path = get_log_path() self._file_pos = 0 # byte offset for tail mode self._min_level = "DEBUG" # currently selected filter self._auto_tail = True self._build_ui() self._load_full() # Auto-tail timer (500 ms poll) self._timer = QTimer(self) self._timer.setInterval(500) self._timer.timeout.connect(self._tail) self._timer.start() # ── UI ──────────────────────────────────────────────────────────────────── def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(6, 6, 6, 6) root.setSpacing(6) # ── Top bar ─────────────────────────────────────────────────────────── top = QHBoxLayout() top.setSpacing(6) title = QLabel("📋 Application Log") f = title.font() f.setBold(True) f.setPointSize(11) title.setFont(f) title.setObjectName("structureTitle") top.addWidget(title) top.addStretch() self._path_lbl = QLabel(str(self._log_path)) self._path_lbl.setObjectName("rowCountLbl") self._path_lbl.setWordWrap(False) top.addWidget(self._path_lbl) open_dir_btn = QPushButton("📁 Open Folder") open_dir_btn.setFixedHeight(26) open_dir_btn.clicked.connect(self._open_log_folder) top.addWidget(open_dir_btn) root.addLayout(top) # ── Toolbar row ─────────────────────────────────────────────────────── tb = QHBoxLayout() tb.setSpacing(6) # Level filter buttons tb.addWidget(QLabel("Filter:")) self._level_btns: dict[str, _LevelBtn] = {} self._btn_group = QButtonGroup(self) self._btn_group.setExclusive(True) for level in _LEVEL_ORDER: colour = _LEVEL_COLOURS[level] btn = _LevelBtn(level, colour, self) btn.setFixedWidth(76 if level == "DEBUG" else 82) self._btn_group.addButton(btn) self._level_btns[level] = btn tb.addWidget(btn) btn.clicked.connect(lambda checked, lv=level: self._set_level(lv)) self._level_btns["DEBUG"].setChecked(True) # "ALL" starts checked tb.addSpacing(10) # Search tb.addWidget(QLabel("Search:")) self._search_box = QLineEdit() self._search_box.setPlaceholderText("Highlight text…") self._search_box.setFixedWidth(180) self._search_box.setFixedHeight(26) self._search_box.textChanged.connect(self._on_search) tb.addWidget(self._search_box) tb.addStretch() # Auto-tail toggle self._tail_cb = QCheckBox("Auto-scroll") self._tail_cb.setChecked(True) self._tail_cb.toggled.connect(self._on_tail_toggle) tb.addWidget(self._tail_cb) # Action buttons copy_btn = QPushButton("📋 Copy All") copy_btn.setFixedHeight(26) copy_btn.clicked.connect(self._copy_all) tb.addWidget(copy_btn) clear_btn = QPushButton("🗑 Clear View") clear_btn.setFixedHeight(26) clear_btn.setToolTip("Clears the viewer only — does not delete the log file") clear_btn.clicked.connect(self._clear_view) tb.addWidget(clear_btn) refresh_btn = QPushButton("🔄 Refresh") refresh_btn.setFixedHeight(26) refresh_btn.clicked.connect(self._load_full) tb.addWidget(refresh_btn) root.addLayout(tb) # ── Log text area ───────────────────────────────────────────────────── self._text = QPlainTextEdit() self._text.setReadOnly(True) mono = QFont("Consolas", 10) mono.setFixedPitch(True) self._text.setFont(mono) self._text.setMaximumBlockCount(20_000) # cap at 20k lines in view root.addWidget(self._text, 1) self._highlighter = _LogHighlighter(self._text.document()) # ── Status bar ──────────────────────────────────────────────────────── self._status_lbl = QLabel("Ready") self._status_lbl.setObjectName("rowCountLbl") root.addWidget(self._status_lbl) # ── Data loading ────────────────────────────────────────────────────────── def _load_full(self): """Read the entire log file and populate the viewer.""" self._text.clear() if not self._log_path.exists(): self._text.setPlainText("No log file found yet.\n" f"Expected location: {self._log_path}") self._file_pos = 0 return try: with open(self._log_path, "r", encoding="utf-8", errors="replace") as f: content = f.read() self._file_pos = f.tell() lines = self._filter_lines(content.splitlines()) self._text.setPlainText("\n".join(lines)) self._status_lbl.setText( f"{len(lines)} lines | {self._log_path}" ) if self._auto_tail: self._scroll_to_bottom() except OSError as e: self._text.setPlainText(f"Cannot read log file:\n{e}") def _tail(self): """Append any new lines written since last poll.""" if not self._log_path.exists(): return try: size = self._log_path.stat().st_size if size < self._file_pos: # File was rotated / truncated — reload from scratch self._load_full() return if size == self._file_pos: return with open(self._log_path, "r", encoding="utf-8", errors="replace") as f: f.seek(self._file_pos) new_text = f.read() self._file_pos = f.tell() if not new_text: return lines = self._filter_lines(new_text.splitlines()) if not lines: return cursor = self._text.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) cursor.insertText(("\n" if self._text.toPlainText() else "") + "\n".join(lines)) self._text.setTextCursor(cursor) if self._auto_tail: self._scroll_to_bottom() except OSError: pass def _filter_lines(self, lines: list[str]) -> list[str]: """Keep only lines at or above self._min_level, plus continuation lines.""" if self._min_level == "DEBUG": return lines min_rank = _LEVEL_RANK[self._min_level] kept = [] include_next = False for line in lines: # Detect the level tag in the [LEVEL ] bracket matched = False for level in _LEVEL_ORDER: if f"[{level}" in line: include_next = (_LEVEL_RANK[level] >= min_rank) matched = True break if not matched: # Continuation line (traceback etc.) — follow parent's decision pass if include_next: kept.append(line) return kept # ── Actions ─────────────────────────────────────────────────────────────── def _set_level(self, level: str): self._min_level = level self._highlighter.set_min_level(level) self._load_full() def _on_search(self, text: str): self._highlighter.set_search(text) def _on_tail_toggle(self, checked: bool): self._auto_tail = checked if checked: self._scroll_to_bottom() def _scroll_to_bottom(self): sb = self._text.verticalScrollBar() sb.setValue(sb.maximum()) def _copy_all(self): QApplication.clipboard().setText(self._text.toPlainText()) self._status_lbl.setText("Copied to clipboard.") def _clear_view(self): self._text.clear() self._status_lbl.setText("View cleared (log file is untouched).") def _open_log_folder(self): folder = str(self._log_path.parent) try: import subprocess, platform system = platform.system() if system == "Windows": subprocess.Popen(["explorer", folder]) elif system == "Darwin": subprocess.Popen(["open", folder]) else: subprocess.Popen(["xdg-open", folder]) except Exception as e: self._status_lbl.setText(f"Could not open folder: {e}") def closeEvent(self, event): self._timer.stop() super().closeEvent(event)