Coverage for openhcs/pyqt_gui/widgets/log_viewer.py: 0.0%

564 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1""" 

2PyQt6 Log Viewer Window 

3 

4Provides comprehensive log viewing capabilities with real-time tailing, search functionality, 

5and integration with OpenHCS subprocess execution. Reimplements log viewing using Qt widgets 

6for native desktop integration. 

7""" 

8 

9import logging 

10from typing import Optional, List, Set, Tuple 

11from pathlib import Path 

12 

13from PyQt6.QtWidgets import ( 

14 QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QComboBox, 

15 QTextEdit, QToolBar, QLineEdit, QCheckBox, QPushButton, QDialog 

16) 

17from PyQt6.QtGui import QSyntaxHighlighter, QTextDocument 

18from PyQt6.QtCore import QObject, QTimer, QFileSystemWatcher, pyqtSignal, Qt, QRegularExpression 

19from PyQt6.QtGui import QTextCharFormat, QColor, QAction, QFont, QTextCursor 

20 

21from openhcs.io.filemanager import FileManager 

22from openhcs.core.log_utils import LogFileInfo 

23from openhcs.pyqt_gui.utils.log_detection_utils import ( 

24 get_current_tui_log_path, discover_logs, discover_all_logs 

25) 

26from openhcs.core.log_utils import ( 

27 classify_log_file, is_openhcs_log_file, infer_base_log_path 

28) 

29 

30# Import Pygments for advanced syntax highlighting 

31from pygments import highlight 

32from pygments.lexers import PythonLexer, get_lexer_by_name 

33from pygments.formatters import get_formatter_by_name 

34from pygments.token import Token 

35from pygments.style import Style 

36from pygments.styles import get_style_by_name 

37from dataclasses import dataclass 

38from typing import Dict, Tuple 

39 

40logger = logging.getLogger(__name__) 

41 

42 

43@dataclass 

44class LogColorScheme: 

45 """ 

46 Centralized color scheme for log highlighting with semantic color names. 

47 

48 Supports light/dark theme variants and ensures WCAG accessibility compliance. 

49 All colors meet minimum 4.5:1 contrast ratio for normal text readability. 

50 """ 

51 

52 # Log level colors with semantic meaning (WCAG 4.5:1 compliant) 

53 log_critical_fg: Tuple[int, int, int] = (255, 255, 255) # White text 

54 log_critical_bg: Tuple[int, int, int] = (139, 0, 0) # Dark red background 

55 log_error_color: Tuple[int, int, int] = (255, 85, 85) # Brighter red - WCAG compliant 

56 log_warning_color: Tuple[int, int, int] = (255, 140, 0) # Dark orange - attention grabbing 

57 log_info_color: Tuple[int, int, int] = (100, 160, 210) # Brighter steel blue - WCAG compliant 

58 log_debug_color: Tuple[int, int, int] = (160, 160, 160) # Lighter gray - better contrast 

59 

60 # Metadata and structural colors 

61 timestamp_color: Tuple[int, int, int] = (105, 105, 105) # Dim gray - unobtrusive 

62 logger_name_color: Tuple[int, int, int] = (147, 112, 219) # Medium slate blue - distinctive 

63 memory_address_color: Tuple[int, int, int] = (255, 182, 193) # Light pink - technical data 

64 file_path_color: Tuple[int, int, int] = (34, 139, 34) # Forest green - file system 

65 

66 # Python syntax colors (following VS Code dark theme conventions) 

67 python_keyword_color: Tuple[int, int, int] = (86, 156, 214) # Blue - language keywords 

68 python_string_color: Tuple[int, int, int] = (206, 145, 120) # Orange - string literals 

69 python_number_color: Tuple[int, int, int] = (181, 206, 168) # Light green - numeric values 

70 python_operator_color: Tuple[int, int, int] = (212, 212, 212) # Light gray - operators/punctuation 

71 python_name_color: Tuple[int, int, int] = (156, 220, 254) # Light blue - identifiers 

72 python_function_color: Tuple[int, int, int] = (220, 220, 170) # Yellow - function names 

73 python_class_color: Tuple[int, int, int] = (78, 201, 176) # Teal - class names 

74 python_builtin_color: Tuple[int, int, int] = (86, 156, 214) # Blue - built-in functions 

75 python_comment_color: Tuple[int, int, int] = (106, 153, 85) # Green - comments 

76 

77 # Special highlighting colors 

78 exception_color: Tuple[int, int, int] = (255, 69, 0) # Red orange - error types 

79 function_call_color: Tuple[int, int, int] = (255, 215, 0) # Gold - function invocations 

80 boolean_color: Tuple[int, int, int] = (86, 156, 214) # Blue - True/False/None 

81 

82 # Enhanced syntax colors (Phase 1 additions) 

83 tuple_parentheses_color: Tuple[int, int, int] = (255, 215, 0) # Gold - tuple delimiters 

84 set_braces_color: Tuple[int, int, int] = (255, 140, 0) # Dark orange - set delimiters 

85 class_representation_color: Tuple[int, int, int] = (78, 201, 176) # Teal - <class 'name'> 

86 function_representation_color: Tuple[int, int, int] = (220, 220, 170) # Yellow - <function name> 

87 module_path_color: Tuple[int, int, int] = (147, 112, 219) # Medium slate blue - module.path 

88 hex_number_color: Tuple[int, int, int] = (181, 206, 168) # Light green - 0xFF 

89 scientific_notation_color: Tuple[int, int, int] = (181, 206, 168) # Light green - 1.23e-4 

90 binary_number_color: Tuple[int, int, int] = (181, 206, 168) # Light green - 0b1010 

91 octal_number_color: Tuple[int, int, int] = (181, 206, 168) # Light green - 0o755 

92 python_special_color: Tuple[int, int, int] = (255, 20, 147) # Deep pink - __name__ 

93 single_quoted_string_color: Tuple[int, int, int] = (206, 145, 120) # Orange - 'string' 

94 list_comprehension_color: Tuple[int, int, int] = (156, 220, 254) # Light blue - [x for x in y] 

95 generator_expression_color: Tuple[int, int, int] = (156, 220, 254) # Light blue - (x for x in y) 

96 

97 @classmethod 

98 def create_dark_theme(cls) -> 'LogColorScheme': 

99 """ 

100 Create a dark theme variant with adjusted colors for dark backgrounds. 

101 

102 Returns: 

103 LogColorScheme: Dark theme color scheme with higher contrast 

104 """ 

105 return cls( 

106 # Enhanced colors for dark backgrounds with better contrast 

107 log_error_color=(255, 100, 100), # Brighter red 

108 log_info_color=(120, 180, 230), # Brighter steel blue 

109 timestamp_color=(160, 160, 160), # Lighter gray 

110 python_string_color=(236, 175, 150), # Brighter orange 

111 python_number_color=(200, 230, 190), # Brighter green 

112 # Other colors remain the same as they work well on dark backgrounds 

113 ) 

114 

115 @classmethod 

116 def create_light_theme(cls) -> 'LogColorScheme': 

117 """ 

118 Create a light theme variant with adjusted colors for light backgrounds. 

119 

120 Returns: 

121 LogColorScheme: Light theme color scheme with appropriate contrast 

122 """ 

123 return cls( 

124 # Darker colors for light backgrounds with WCAG compliance 

125 log_error_color=(180, 20, 40), # Darker red 

126 log_info_color=(30, 80, 130), # Darker steel blue 

127 log_warning_color=(200, 100, 0), # Darker orange 

128 timestamp_color=(60, 60, 60), # Darker gray 

129 logger_name_color=(100, 60, 160), # Darker slate blue 

130 python_string_color=(150, 80, 60), # Darker orange 

131 python_number_color=(120, 140, 100), # Darker green 

132 memory_address_color=(200, 120, 140), # Darker pink 

133 file_path_color=(20, 100, 20), # Darker forest green 

134 exception_color=(200, 40, 0), # Darker red orange 

135 # Adjust other colors for light background contrast 

136 ) 

137 

138 def to_qcolor(self, color_tuple: Tuple[int, int, int]) -> QColor: 

139 """ 

140 Convert RGB tuple to QColor object. 

141 

142 Args: 

143 color_tuple: RGB color tuple (r, g, b) 

144 

145 Returns: 

146 QColor: Qt color object 

147 """ 

148 return QColor(*color_tuple) 

149 

150 

151class LogFileDetector(QObject): 

152 """ 

153 Detects new log files in directory using efficient file monitoring. 

154  

155 Uses QFileSystemWatcher to monitor directory changes and set operations 

156 for efficient new file detection. Handles base_log_path as file prefix 

157 and watches the parent directory. 

158 """ 

159 

160 # Signals 

161 new_log_detected = pyqtSignal(object) # LogFileInfo object 

162 

163 def __init__(self, base_log_path: Optional[str] = None): 

164 """ 

165 Initialize LogFileDetector. 

166  

167 Args: 

168 base_log_path: Base path for subprocess log files (file prefix, not directory) 

169 """ 

170 super().__init__() 

171 self._base_log_path = base_log_path 

172 self._previous_files: Set[Path] = set() 

173 self._watcher = QFileSystemWatcher() 

174 self._watcher.directoryChanged.connect(self._on_directory_changed) 

175 self._watching_directory: Optional[Path] = None 

176 

177 logger.debug(f"LogFileDetector initialized with base_log_path: {base_log_path}") 

178 

179 def start_watching(self, directory: Path) -> None: 

180 """ 

181 Start watching directory for new log files. 

182  

183 Args: 

184 directory: Directory to watch for new log files 

185 """ 

186 if not directory.exists(): 

187 logger.warning(f"Cannot watch non-existent directory: {directory}") 

188 return 

189 

190 # Stop any existing watching 

191 self.stop_watching() 

192 

193 # Add directory to watcher 

194 success = self._watcher.addPath(str(directory)) 

195 if success: 

196 self._watching_directory = directory 

197 # Initialize previous files set 

198 self._previous_files = self.scan_directory(directory) 

199 logger.debug(f"Started watching directory: {directory}") 

200 logger.debug(f"Initial file count: {len(self._previous_files)}") 

201 else: 

202 logger.error(f"Failed to add directory to watcher: {directory}") 

203 

204 def stop_watching(self) -> None: 

205 """Stop file watching and cleanup.""" 

206 if self._watching_directory: 

207 self._watcher.removePath(str(self._watching_directory)) 

208 self._watching_directory = None 

209 self._previous_files.clear() 

210 logger.debug("Stopped file watching") 

211 

212 def scan_directory(self, directory: Path) -> Set[Path]: 

213 """ 

214 Scan directory for .log files. 

215  

216 Args: 

217 directory: Directory to scan 

218  

219 Returns: 

220 Set[Path]: Set of Path objects for .log files found 

221 """ 

222 try: 

223 log_files = set(directory.glob("*.log")) 

224 logger.debug(f"Scanned directory {directory}: found {len(log_files)} .log files") 

225 return log_files 

226 except (FileNotFoundError, PermissionError) as e: 

227 logger.warning(f"Error scanning directory {directory}: {e}") 

228 return set() 

229 

230 def detect_new_files(self, current_files: Set[Path]) -> Set[Path]: 

231 """ 

232 Use set.difference() to find new files efficiently. 

233  

234 Args: 

235 current_files: Current set of files in directory 

236  

237 Returns: 

238 Set[Path]: Set of newly discovered files 

239 """ 

240 new_files = current_files.difference(self._previous_files) 

241 if new_files: 

242 logger.debug(f"Detected {len(new_files)} new files: {[f.name for f in new_files]}") 

243 

244 # Update previous files set 

245 self._previous_files = current_files 

246 return new_files 

247 

248 

249 

250 def _on_directory_changed(self, directory_path: str) -> None: 

251 """ 

252 Handle QFileSystemWatcher directory change signal. 

253  

254 Args: 

255 directory_path: Path of directory that changed 

256 """ 

257 directory = Path(directory_path) 

258 logger.debug(f"Directory changed: {directory}") 

259 

260 # Scan directory for current files 

261 current_files = self.scan_directory(directory) 

262 

263 # Detect new files 

264 new_files = self.detect_new_files(current_files) 

265 

266 # Process new files 

267 for file_path in new_files: 

268 if file_path.exists() and is_openhcs_log_file(file_path): 

269 try: 

270 # For general watching, try to infer base_log_path from the file name 

271 effective_base_log_path = self._base_log_path 

272 if not effective_base_log_path and 'subprocess_' in file_path.name: 

273 effective_base_log_path = infer_base_log_path(file_path) 

274 

275 log_info = classify_log_file(file_path, effective_base_log_path, 

276 include_tui_log=False) 

277 

278 logger.info(f"New relevant log file detected: {file_path} (type: {log_info.log_type})") 

279 self.new_log_detected.emit(log_info) 

280 except Exception as e: 

281 logger.error(f"Error classifying new log file {file_path}: {e}") 

282 

283 

284class LogHighlighter(QSyntaxHighlighter): 

285 """ 

286 Advanced syntax highlighter for log files using Pygments. 

287 

288 Provides sophisticated highlighting for OpenHCS log format with support for: 

289 - Log levels and timestamps 

290 - Python code snippets and data structures 

291 - Memory addresses and function signatures 

292 - Complex nested dictionaries and lists 

293 - Exception tracebacks and file paths 

294 """ 

295 

296 def __init__(self, parent: QTextDocument, color_scheme: LogColorScheme = None): 

297 """ 

298 Initialize the log highlighter with optional color scheme. 

299 

300 Args: 

301 parent: QTextDocument to apply highlighting to 

302 color_scheme: Color scheme to use (defaults to dark theme) 

303 """ 

304 super().__init__(parent) 

305 self.color_scheme = color_scheme or LogColorScheme() 

306 self.setup_pygments_styles() 

307 self.setup_highlighting_rules() 

308 

309 def setup_pygments_styles(self) -> None: 

310 """Setup Pygments token to QTextCharFormat mapping using color scheme.""" 

311 cs = self.color_scheme # Shorthand for readability 

312 

313 # Create a mapping from Pygments tokens to Qt text formats 

314 self.token_formats = { 

315 # Log levels with distinct colors and backgrounds 

316 'log_critical': self._create_format( 

317 cs.to_qcolor(cs.log_critical_fg), 

318 cs.to_qcolor(cs.log_critical_bg), 

319 bold=True 

320 ), 

321 'log_error': self._create_format(cs.to_qcolor(cs.log_error_color), bold=True), 

322 'log_warning': self._create_format(cs.to_qcolor(cs.log_warning_color), bold=True), 

323 'log_info': self._create_format(cs.to_qcolor(cs.log_info_color), bold=True), 

324 'log_debug': self._create_format(cs.to_qcolor(cs.log_debug_color)), 

325 

326 # Timestamps and metadata 

327 'timestamp': self._create_format(cs.to_qcolor(cs.timestamp_color)), 

328 'logger_name': self._create_format(cs.to_qcolor(cs.logger_name_color), bold=True), 

329 

330 # Python syntax highlighting (for complex data structures) 

331 Token.Keyword: self._create_format(cs.to_qcolor(cs.python_keyword_color), bold=True), 

332 Token.String: self._create_format(cs.to_qcolor(cs.python_string_color)), 

333 Token.String.Single: self._create_format(cs.to_qcolor(cs.python_string_color)), 

334 Token.String.Double: self._create_format(cs.to_qcolor(cs.python_string_color)), 

335 Token.Number: self._create_format(cs.to_qcolor(cs.python_number_color)), 

336 Token.Number.Integer: self._create_format(cs.to_qcolor(cs.python_number_color)), 

337 Token.Number.Float: self._create_format(cs.to_qcolor(cs.python_number_color)), 

338 Token.Number.Hex: self._create_format(cs.to_qcolor(cs.python_number_color)), 

339 Token.Number.Oct: self._create_format(cs.to_qcolor(cs.python_number_color)), 

340 Token.Number.Bin: self._create_format(cs.to_qcolor(cs.python_number_color)), 

341 Token.Operator: self._create_format(cs.to_qcolor(cs.python_operator_color)), 

342 Token.Punctuation: self._create_format(cs.to_qcolor(cs.python_operator_color)), 

343 Token.Name: self._create_format(cs.to_qcolor(cs.python_name_color)), 

344 Token.Name.Function: self._create_format(cs.to_qcolor(cs.python_function_color), bold=True), 

345 Token.Name.Class: self._create_format(cs.to_qcolor(cs.python_class_color), bold=True), 

346 Token.Name.Builtin: self._create_format(cs.to_qcolor(cs.python_builtin_color)), 

347 Token.Comment: self._create_format(cs.to_qcolor(cs.python_comment_color)), 

348 Token.Literal: self._create_format(cs.to_qcolor(cs.python_number_color)), 

349 

350 # Special patterns for log content 

351 'memory_address': self._create_format(cs.to_qcolor(cs.memory_address_color)), 

352 'file_path': self._create_format(cs.to_qcolor(cs.file_path_color)), 

353 'exception': self._create_format(cs.to_qcolor(cs.exception_color), bold=True), 

354 'function_call': self._create_format(cs.to_qcolor(cs.function_call_color)), 

355 'dict_key': self._create_format(cs.to_qcolor(cs.python_name_color)), 

356 'boolean': self._create_format(cs.to_qcolor(cs.boolean_color), bold=True), 

357 

358 # Enhanced Python syntax elements (Phase 1) 

359 'tuple_parentheses': self._create_format(cs.to_qcolor(cs.tuple_parentheses_color)), 

360 'set_braces': self._create_format(cs.to_qcolor(cs.set_braces_color)), 

361 'class_representation': self._create_format(cs.to_qcolor(cs.class_representation_color), bold=True), 

362 'function_representation': self._create_format(cs.to_qcolor(cs.function_representation_color), bold=True), 

363 'module_path': self._create_format(cs.to_qcolor(cs.module_path_color)), 

364 'hex_number': self._create_format(cs.to_qcolor(cs.hex_number_color)), 

365 'scientific_notation': self._create_format(cs.to_qcolor(cs.scientific_notation_color)), 

366 'binary_number': self._create_format(cs.to_qcolor(cs.binary_number_color)), 

367 'octal_number': self._create_format(cs.to_qcolor(cs.octal_number_color)), 

368 'python_special': self._create_format(cs.to_qcolor(cs.python_special_color), bold=True), 

369 'single_quoted_string': self._create_format(cs.to_qcolor(cs.single_quoted_string_color)), 

370 'list_comprehension': self._create_format(cs.to_qcolor(cs.list_comprehension_color)), 

371 'generator_expression': self._create_format(cs.to_qcolor(cs.generator_expression_color)), 

372 } 

373 

374 def _create_format(self, fg_color: QColor, bg_color: QColor = None, bold: bool = False) -> QTextCharFormat: 

375 """Create a QTextCharFormat with specified properties.""" 

376 format = QTextCharFormat() 

377 format.setForeground(fg_color) 

378 if bg_color: 

379 format.setBackground(bg_color) 

380 if bold: 

381 format.setFontWeight(QFont.Weight.Bold) 

382 return format 

383 

384 def setup_highlighting_rules(self) -> None: 

385 """Setup regex patterns for log-specific highlighting.""" 

386 self.highlighting_rules = [] 

387 

388 # Log level patterns (highest priority) 

389 log_levels = [ 

390 ("CRITICAL", self.token_formats['log_critical']), 

391 ("ERROR", self.token_formats['log_error']), 

392 ("WARNING", self.token_formats['log_warning']), 

393 ("INFO", self.token_formats['log_info']), 

394 ("DEBUG", self.token_formats['log_debug']), 

395 ] 

396 

397 for level, format in log_levels: 

398 pattern = QRegularExpression(rf"\b{level}\b") 

399 self.highlighting_rules.append((pattern, format)) 

400 

401 # Timestamp pattern: YYYY-MM-DD HH:MM:SS,mmm 

402 timestamp_pattern = QRegularExpression(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}") 

403 self.highlighting_rules.append((timestamp_pattern, self.token_formats['timestamp'])) 

404 

405 # Logger names (e.g., openhcs.core.orchestrator) 

406 logger_pattern = QRegularExpression(r"openhcs\.[a-zA-Z0-9_.]+") 

407 self.highlighting_rules.append((logger_pattern, self.token_formats['logger_name'])) 

408 

409 # Memory addresses (e.g., 0x7f1640dd8e00) 

410 memory_pattern = QRegularExpression(r"0x[0-9a-fA-F]+") 

411 self.highlighting_rules.append((memory_pattern, self.token_formats['memory_address'])) 

412 

413 # File paths in tracebacks 

414 filepath_pattern = QRegularExpression(r'["\']?/[^"\'\s]+\.py["\']?') 

415 self.highlighting_rules.append((filepath_pattern, self.token_formats['file_path'])) 

416 

417 # Exception names 

418 exception_pattern = QRegularExpression(r'\b[A-Z][a-zA-Z]*Error\b|\b[A-Z][a-zA-Z]*Exception\b') 

419 self.highlighting_rules.append((exception_pattern, self.token_formats['exception'])) 

420 

421 # Function calls with parentheses 

422 function_pattern = QRegularExpression(r'\b[a-zA-Z_][a-zA-Z0-9_]*\(\)') 

423 self.highlighting_rules.append((function_pattern, self.token_formats['function_call'])) 

424 

425 # Boolean values 

426 boolean_pattern = QRegularExpression(r'\b(True|False|None)\b') 

427 self.highlighting_rules.append((boolean_pattern, self.token_formats['boolean'])) 

428 

429 # Enhanced Python syntax elements 

430 

431 # Single-quoted strings (complement to double-quoted) 

432 single_quote_pattern = QRegularExpression(r"'[^']*'") 

433 self.highlighting_rules.append((single_quote_pattern, self.token_formats['single_quoted_string'])) 

434 

435 # Class representations: <class 'module.ClassName'> 

436 class_repr_pattern = QRegularExpression(r"<class '[^']*'>") 

437 self.highlighting_rules.append((class_repr_pattern, self.token_formats['class_representation'])) 

438 

439 # Function representations: <function name at 0xaddress> 

440 function_repr_pattern = QRegularExpression(r"<function [^>]+ at 0x[0-9a-fA-F]+>") 

441 self.highlighting_rules.append((function_repr_pattern, self.token_formats['function_representation'])) 

442 

443 # Extended module paths (beyond just openhcs) 

444 module_path_pattern = QRegularExpression(r"\b[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*){2,}") 

445 self.highlighting_rules.append((module_path_pattern, self.token_formats['module_path'])) 

446 

447 # Hexadecimal numbers (beyond memory addresses): 0xFF, 0x1A2B 

448 hex_number_pattern = QRegularExpression(r"\b0[xX][0-9a-fA-F]+\b") 

449 self.highlighting_rules.append((hex_number_pattern, self.token_formats['hex_number'])) 

450 

451 # Scientific notation: 1.23e-4, 5.67E+10 

452 scientific_pattern = QRegularExpression(r"\b\d+\.?\d*[eE][+-]?\d+\b") 

453 self.highlighting_rules.append((scientific_pattern, self.token_formats['scientific_notation'])) 

454 

455 # Binary literals: 0b1010 

456 binary_pattern = QRegularExpression(r"\b0[bB][01]+\b") 

457 self.highlighting_rules.append((binary_pattern, self.token_formats['binary_number'])) 

458 

459 # Octal literals: 0o755 

460 octal_pattern = QRegularExpression(r"\b0[oO][0-7]+\b") 

461 self.highlighting_rules.append((octal_pattern, self.token_formats['octal_number'])) 

462 

463 # Python special constants: __name__, __main__, __file__, etc. 

464 python_special_pattern = QRegularExpression(r"\b__[a-zA-Z_][a-zA-Z0-9_]*__\b") 

465 self.highlighting_rules.append((python_special_pattern, self.token_formats['python_special'])) 

466 

467 logger.debug(f"Setup {len(self.highlighting_rules)} highlighting rules") 

468 

469 def set_color_scheme(self, color_scheme: LogColorScheme) -> None: 

470 """ 

471 Update the color scheme and refresh highlighting. 

472 

473 Args: 

474 color_scheme: New color scheme to apply 

475 """ 

476 self.color_scheme = color_scheme 

477 self.setup_pygments_styles() 

478 self.setup_highlighting_rules() 

479 # Trigger re-highlighting of the entire document 

480 self.rehighlight() 

481 logger.debug(f"Applied new color scheme with {len(self.token_formats)} token formats") 

482 

483 def switch_to_dark_theme(self) -> None: 

484 """Switch to dark theme color scheme.""" 

485 self.set_color_scheme(LogColorScheme.create_dark_theme()) 

486 

487 def switch_to_light_theme(self) -> None: 

488 """Switch to light theme color scheme.""" 

489 self.set_color_scheme(LogColorScheme.create_light_theme()) 

490 

491 @classmethod 

492 def load_color_scheme_from_config(cls, config_path: str = None) -> LogColorScheme: 

493 """ 

494 Load color scheme from external configuration file. 

495 

496 Args: 

497 config_path: Path to JSON/YAML config file (optional) 

498 

499 Returns: 

500 LogColorScheme: Loaded color scheme or default if file not found 

501 """ 

502 if config_path and Path(config_path).exists(): 

503 try: 

504 import json 

505 with open(config_path, 'r') as f: 

506 config = json.load(f) 

507 

508 # Create color scheme from config 

509 scheme_kwargs = {} 

510 for key, value in config.items(): 

511 if key.endswith('_color') or key.endswith('_fg') or key.endswith('_bg'): 

512 if isinstance(value, list) and len(value) == 3: 

513 scheme_kwargs[key] = tuple(value) 

514 

515 return LogColorScheme(**scheme_kwargs) 

516 

517 except Exception as e: 

518 logger.warning(f"Failed to load color scheme from {config_path}: {e}") 

519 

520 return LogColorScheme() # Return default scheme 

521 

522 def highlightBlock(self, text: str) -> None: 

523 """ 

524 Apply advanced highlighting to text block. 

525 

526 Uses both regex patterns for log-specific content and Pygments 

527 for Python syntax highlighting of complex data structures. 

528 """ 

529 # First apply log-specific patterns 

530 for pattern, format in self.highlighting_rules: 

531 iterator = pattern.globalMatch(text) 

532 while iterator.hasNext(): 

533 match = iterator.next() 

534 start = match.capturedStart() 

535 length = match.capturedLength() 

536 self.setFormat(start, length, format) 

537 

538 # Then apply Pygments highlighting for Python-like content 

539 self._highlight_python_content(text) 

540 

541 def _highlight_python_content(self, text: str) -> None: 

542 """ 

543 Apply Pygments Python syntax highlighting to parts of the text that contain 

544 Python data structures (dictionaries, lists, function signatures, etc.). 

545 """ 

546 try: 

547 # Look for Python-like patterns in the log line 

548 python_patterns = [ 

549 # Dictionary patterns: {'key': 'value', ...} 

550 r'\{[^{}]*:[^{}]*\}', 

551 # List patterns: [item1, item2, ...] 

552 r'\[[^\[\]]*,.*?\]', 

553 # Function signatures: function_name(arg1=value, arg2=value) 

554 r'\b[a-zA-Z_][a-zA-Z0-9_]*\([^)]*=.*?\)', 

555 # Complex nested structures 

556 r'\{.*?:\s*\[.*?\].*?\}', 

557 

558 # Enhanced patterns for Phase 1 

559 

560 # Tuple patterns: (item1, item2, item3) 

561 r'\([^()]*,.*?\)', 

562 # Set patterns: {item1, item2, item3} (no colons, distinguishes from dict) 

563 r'\{[^{}:]*,.*?\}', 

564 # List comprehensions: [x for x in items] 

565 r'\[[^\[\]]*\s+for\s+[^\[\]]*\s+in\s+[^\[\]]*\]', 

566 # Generator expressions: (x for x in items) 

567 r'\([^()]*\s+for\s+[^()]*\s+in\s+[^()]*\)', 

568 # Class representations: <class 'module.ClassName'> 

569 r"<class '[^']*'>", 

570 # Function representations: <function name at 0xaddress> 

571 r"<function [^>]+ at 0x[0-9a-fA-F]+>", 

572 # Complex function calls with keyword arguments 

573 r'\b[a-zA-Z_][a-zA-Z0-9_]*\([^)]*[a-zA-Z_][a-zA-Z0-9_]*\s*=.*?\)', 

574 # Multi-line dictionary/list structures (single line representation) 

575 r'\{[^{}]*:\s*[^{}]*,\s*[^{}]*:\s*[^{}]*\}', 

576 # Nested collections: [{...}, {...}] or [(...), (...)] 

577 r'\[[\{\(][^[\]]*[\}\)],\s*[\{\(][^[\]]*[\}\)]\]', 

578 ] 

579 

580 for pattern in python_patterns: 

581 regex = QRegularExpression(pattern) 

582 iterator = regex.globalMatch(text) 

583 

584 while iterator.hasNext(): 

585 match = iterator.next() 

586 start = match.capturedStart() 

587 length = match.capturedLength() 

588 python_text = match.captured(0) 

589 

590 # Use Pygments to highlight this Python-like content 

591 self._apply_pygments_highlighting(python_text, start) 

592 

593 except Exception as e: 

594 # Don't let highlighting errors break the log viewer 

595 logger.debug(f"Error in Python content highlighting: {e}") 

596 

597 def _apply_pygments_highlighting(self, python_text: str, start_offset: int) -> None: 

598 """ 

599 Apply Pygments highlighting to a specific piece of Python-like text. 

600 """ 

601 try: 

602 from pygments.lexers import PythonLexer 

603 

604 lexer = PythonLexer() 

605 tokens = list(lexer.get_tokens(python_text)) 

606 

607 current_pos = 0 

608 for token_type, token_value in tokens: 

609 if token_value.strip(): # Skip whitespace-only tokens 

610 token_start = start_offset + current_pos 

611 token_length = len(token_value) 

612 

613 # Apply format if we have a mapping for this token type 

614 if token_type in self.token_formats: 

615 format = self.token_formats[token_type] 

616 self.setFormat(token_start, token_length, format) 

617 

618 current_pos += len(token_value) 

619 

620 except Exception as e: 

621 # Don't let Pygments errors break the highlighting 

622 logger.debug(f"Error in Pygments highlighting: {e}") 

623 

624 

625class LogViewerWindow(QMainWindow): 

626 """Main log viewer window with dropdown, search, and real-time tailing.""" 

627 

628 window_closed = pyqtSignal() 

629 

630 def __init__(self, file_manager: FileManager, service_adapter, parent=None): 

631 super().__init__(parent) 

632 self.file_manager = file_manager 

633 self.service_adapter = service_adapter 

634 

635 # State 

636 self.current_log_path: Optional[Path] = None 

637 self.current_file_position: int = 0 

638 self.auto_scroll_enabled: bool = True 

639 self.tailing_paused: bool = False 

640 

641 # Search state 

642 self.current_search_text: str = "" 

643 self.search_highlights: List[QTextCursor] = [] 

644 

645 # Components 

646 self.log_selector: QComboBox = None 

647 self.search_toolbar: QToolBar = None 

648 self.log_display: QTextEdit = None 

649 self.file_detector: LogFileDetector = None 

650 self.tail_timer: QTimer = None 

651 self.highlighter: LogHighlighter = None 

652 

653 self.setup_ui() 

654 self.setup_connections() 

655 self.initialize_logs() 

656 

657 def setup_ui(self) -> None: 

658 """Setup complete UI layout with exact widget hierarchy.""" 

659 self.setWindowTitle("Log Viewer") 

660 self.setMinimumSize(800, 600) 

661 

662 # Central widget with main layout 

663 central_widget = QWidget() 

664 self.setCentralWidget(central_widget) 

665 main_layout = QVBoxLayout(central_widget) 

666 

667 # Log selector dropdown 

668 self.log_selector = QComboBox() 

669 self.log_selector.setMinimumHeight(30) 

670 main_layout.addWidget(self.log_selector) 

671 

672 # Search toolbar (initially hidden) 

673 self.search_toolbar = QToolBar("Search") 

674 self.search_toolbar.setVisible(False) 

675 

676 # Search input 

677 self.search_input = QLineEdit() 

678 self.search_input.setPlaceholderText("Search logs...") 

679 self.search_toolbar.addWidget(self.search_input) 

680 

681 # Search options 

682 self.case_sensitive_cb = QCheckBox("Case sensitive") 

683 self.search_toolbar.addWidget(self.case_sensitive_cb) 

684 

685 self.regex_cb = QCheckBox("Regex") 

686 self.search_toolbar.addWidget(self.regex_cb) 

687 

688 # Search navigation buttons 

689 self.prev_button = QPushButton("Previous") 

690 self.next_button = QPushButton("Next") 

691 self.close_search_button = QPushButton("Close") 

692 

693 self.search_toolbar.addWidget(self.prev_button) 

694 self.search_toolbar.addWidget(self.next_button) 

695 self.search_toolbar.addWidget(self.close_search_button) 

696 

697 main_layout.addWidget(self.search_toolbar) 

698 

699 # Log display area 

700 self.log_display = QTextEdit() 

701 self.log_display.setReadOnly(True) 

702 self.log_display.setFont(QFont("Consolas", 10)) # Monospace font for logs 

703 main_layout.addWidget(self.log_display) 

704 

705 # Control buttons layout 

706 control_layout = QHBoxLayout() 

707 

708 self.auto_scroll_btn = QPushButton("Auto-scroll") 

709 self.auto_scroll_btn.setCheckable(True) 

710 self.auto_scroll_btn.setChecked(True) 

711 

712 self.pause_btn = QPushButton("Pause") 

713 self.pause_btn.setCheckable(True) 

714 

715 self.clear_btn = QPushButton("Clear") 

716 self.bottom_btn = QPushButton("Bottom") 

717 

718 control_layout.addWidget(self.auto_scroll_btn) 

719 control_layout.addWidget(self.pause_btn) 

720 control_layout.addWidget(self.clear_btn) 

721 control_layout.addWidget(self.bottom_btn) 

722 control_layout.addStretch() # Push buttons to left 

723 

724 main_layout.addLayout(control_layout) 

725 

726 # Setup syntax highlighting 

727 self.highlighter = LogHighlighter(self.log_display.document()) 

728 

729 # Setup window-local Ctrl+F shortcut 

730 search_action = QAction("Search", self) 

731 search_action.setShortcut("Ctrl+F") 

732 search_action.triggered.connect(self.toggle_search_toolbar) 

733 self.addAction(search_action) 

734 

735 logger.debug("LogViewerWindow UI setup complete") 

736 

737 def setup_connections(self) -> None: 

738 """Setup signal/slot connections.""" 

739 # Log selector 

740 self.log_selector.currentIndexChanged.connect(self.on_log_selection_changed) 

741 

742 # Search functionality 

743 self.search_input.returnPressed.connect(self.perform_search) 

744 self.prev_button.clicked.connect(self.find_previous) 

745 self.next_button.clicked.connect(self.find_next) 

746 self.close_search_button.clicked.connect(self.toggle_search_toolbar) 

747 

748 # Control buttons 

749 self.auto_scroll_btn.toggled.connect(self.toggle_auto_scroll) 

750 self.pause_btn.toggled.connect(self.toggle_pause_tailing) 

751 self.clear_btn.clicked.connect(self.clear_log_display) 

752 self.bottom_btn.clicked.connect(self.scroll_to_bottom) 

753 

754 logger.debug("LogViewerWindow connections setup complete") 

755 

756 def initialize_logs(self) -> None: 

757 """Initialize with main process log only and start monitoring.""" 

758 # Only discover the current main process log, not old logs 

759 try: 

760 from openhcs.core.log_utils import get_current_log_file_path, classify_log_file 

761 from pathlib import Path 

762 

763 main_log_path = get_current_log_file_path() 

764 main_log = Path(main_log_path) 

765 if main_log.exists(): 

766 log_info = classify_log_file(main_log, None, True) 

767 self.populate_log_dropdown([log_info]) 

768 self.switch_to_log(main_log) 

769 except Exception: 

770 # Main log not available, continue without it 

771 pass 

772 

773 # Start monitoring for new logs 

774 self.start_monitoring() 

775 

776 # Dropdown Management Methods 

777 def populate_log_dropdown(self, log_files: List[LogFileInfo]) -> None: 

778 """ 

779 Populate QComboBox with log files. Store LogFileInfo as item data. 

780 

781 Args: 

782 log_files: List of LogFileInfo objects to add to dropdown 

783 """ 

784 self.log_selector.clear() 

785 

786 # Sort logs: TUI first, main subprocess, then workers by timestamp 

787 sorted_logs = sorted(log_files, key=self._log_sort_key) 

788 

789 for log_info in sorted_logs: 

790 self.log_selector.addItem(log_info.display_name, log_info) 

791 

792 logger.debug(f"Populated dropdown with {len(log_files)} log files") 

793 

794 def _log_sort_key(self, log_info: LogFileInfo) -> tuple: 

795 """ 

796 Generate sort key for log files. 

797 

798 Args: 

799 log_info: LogFileInfo to generate sort key for 

800 

801 Returns: 

802 tuple: Sort key (priority, timestamp) 

803 """ 

804 # Priority: TUI=0, main=1, worker=2, unknown=3 

805 priority_map = {"tui": 0, "main": 1, "worker": 2, "unknown": 3} 

806 priority = priority_map.get(log_info.log_type, 3) 

807 

808 # Use file modification time as secondary sort 

809 try: 

810 timestamp = log_info.path.stat().st_mtime 

811 except (OSError, AttributeError): 

812 timestamp = 0 

813 

814 return (priority, -timestamp) # Negative timestamp for newest first 

815 

816 def clear_subprocess_logs(self) -> None: 

817 """Remove all non-TUI logs from dropdown and switch to TUI log.""" 

818 import traceback 

819 logger.error(f"🔥 DEBUG: clear_subprocess_logs called! Stack trace:") 

820 for line in traceback.format_stack(): 

821 logger.error(f"🔥 DEBUG: {line.strip()}") 

822 

823 current_logs = [] 

824 

825 # Collect TUI logs only 

826 for i in range(self.log_selector.count()): 

827 log_info = self.log_selector.itemData(i) 

828 if log_info and log_info.log_type == "tui": 

829 current_logs.append(log_info) 

830 

831 # Repopulate with TUI logs only 

832 self.populate_log_dropdown(current_logs) 

833 

834 # Auto-select TUI log if available 

835 if current_logs: 

836 self.switch_to_log(current_logs[0].path) 

837 

838 logger.info("Cleared subprocess logs, kept TUI logs") 

839 

840 def add_new_log(self, log_file_info: LogFileInfo) -> None: 

841 """ 

842 Add new log to dropdown maintaining sort order. 

843 

844 Args: 

845 log_file_info: New LogFileInfo to add 

846 """ 

847 # Get current logs 

848 current_logs = [] 

849 for i in range(self.log_selector.count()): 

850 log_info = self.log_selector.itemData(i) 

851 if log_info: 

852 current_logs.append(log_info) 

853 

854 # Add new log 

855 current_logs.append(log_file_info) 

856 

857 # Repopulate with updated list 

858 self.populate_log_dropdown(current_logs) 

859 

860 logger.info(f"Added new log to dropdown: {log_file_info.display_name}") 

861 

862 def on_log_selection_changed(self, index: int) -> None: 

863 """ 

864 Handle dropdown selection change - switch log display. 

865 

866 Args: 

867 index: Selected index in dropdown 

868 """ 

869 if index >= 0: 

870 log_info = self.log_selector.itemData(index) 

871 if log_info: 

872 self.switch_to_log(log_info.path) 

873 

874 def switch_to_log(self, log_path: Path) -> None: 

875 """ 

876 Switch log display to show specified log file. 

877 

878 Args: 

879 log_path: Path to log file to display 

880 """ 

881 try: 

882 # Stop current tailing 

883 if self.tail_timer and self.tail_timer.isActive(): 

884 self.tail_timer.stop() 

885 

886 # Validate file exists 

887 if not log_path.exists(): 

888 self.log_display.setText(f"Log file not found: {log_path}") 

889 return 

890 

891 # Load log file content 

892 with open(log_path, 'r', encoding='utf-8', errors='replace') as f: 

893 content = f.read() 

894 

895 self.log_display.setText(content) 

896 self.current_log_path = log_path 

897 self.current_file_position = len(content.encode('utf-8')) 

898 

899 # Start tailing if not paused 

900 if not self.tailing_paused: 

901 self.start_log_tailing(log_path) 

902 

903 # Scroll to bottom if auto-scroll enabled 

904 if self.auto_scroll_enabled: 

905 self.scroll_to_bottom() 

906 

907 logger.info(f"Switched to log file: {log_path}") 

908 

909 except Exception as e: 

910 logger.error(f"Error switching to log {log_path}: {e}") 

911 raise 

912 

913 # Search Functionality Methods 

914 def toggle_search_toolbar(self) -> None: 

915 """Show/hide search toolbar (Ctrl+F handler).""" 

916 if self.search_toolbar.isVisible(): 

917 # Hide toolbar and clear highlights 

918 self.search_toolbar.setVisible(False) 

919 self.clear_search_highlights() 

920 else: 

921 # Show toolbar and focus search input 

922 self.search_toolbar.setVisible(True) 

923 self.search_input.setFocus() 

924 self.search_input.selectAll() 

925 

926 def perform_search(self) -> None: 

927 """Search in log display using QTextEdit.find().""" 

928 search_text = self.search_input.text() 

929 if not search_text: 

930 self.clear_search_highlights() 

931 return 

932 

933 # Clear previous highlights if search text changed 

934 if search_text != self.current_search_text: 

935 self.clear_search_highlights() 

936 self.current_search_text = search_text 

937 self.highlight_all_matches(search_text) 

938 

939 # Find next occurrence 

940 flags = QTextDocument.FindFlag(0) 

941 if self.case_sensitive_cb.isChecked(): 

942 flags |= QTextDocument.FindFlag.FindCaseSensitively 

943 

944 found = self.log_display.find(search_text, flags) 

945 if not found: 

946 # Try from beginning 

947 cursor = self.log_display.textCursor() 

948 cursor.movePosition(cursor.MoveOperation.Start) 

949 self.log_display.setTextCursor(cursor) 

950 self.log_display.find(search_text, flags) 

951 

952 def highlight_all_matches(self, search_text: str) -> None: 

953 """ 

954 Highlight all matches of search text in the document. 

955 

956 Args: 

957 search_text: Text to search and highlight 

958 """ 

959 if not search_text: 

960 return 

961 

962 # Create highlight format 

963 highlight_format = QTextCharFormat() 

964 highlight_format.setBackground(QColor(255, 255, 0, 100)) # Yellow with transparency 

965 

966 # Search through entire document 

967 document = self.log_display.document() 

968 cursor = QTextCursor(document) 

969 

970 flags = QTextDocument.FindFlag(0) 

971 if self.case_sensitive_cb.isChecked(): 

972 flags |= QTextDocument.FindFlag.FindCaseSensitively 

973 

974 self.search_highlights.clear() 

975 

976 while True: 

977 cursor = document.find(search_text, cursor, flags) 

978 if cursor.isNull(): 

979 break 

980 

981 # Apply highlight 

982 cursor.mergeCharFormat(highlight_format) 

983 self.search_highlights.append(cursor) 

984 

985 logger.debug(f"Highlighted {len(self.search_highlights)} search matches") 

986 

987 def clear_search_highlights(self) -> None: 

988 """Clear all search highlights from the document.""" 

989 # Reset format for all highlighted text 

990 for cursor in self.search_highlights: 

991 if not cursor.isNull(): 

992 # Reset to default format 

993 default_format = QTextCharFormat() 

994 cursor.setCharFormat(default_format) 

995 

996 self.search_highlights.clear() 

997 self.current_search_text = "" 

998 

999 def find_next(self) -> None: 

1000 """Find next search result.""" 

1001 self.perform_search() 

1002 

1003 def find_previous(self) -> None: 

1004 """Find previous search result.""" 

1005 search_text = self.search_input.text() 

1006 if not search_text: 

1007 return 

1008 

1009 flags = QTextDocument.FindFlag.FindBackward 

1010 if self.case_sensitive_cb.isChecked(): 

1011 flags |= QTextDocument.FindFlag.FindCaseSensitively 

1012 

1013 found = self.log_display.find(search_text, flags) 

1014 if not found: 

1015 # Try from end 

1016 cursor = self.log_display.textCursor() 

1017 cursor.movePosition(cursor.MoveOperation.End) 

1018 self.log_display.setTextCursor(cursor) 

1019 self.log_display.find(search_text, flags) 

1020 

1021 # Control Button Methods 

1022 def toggle_auto_scroll(self, enabled: bool) -> None: 

1023 """Toggle auto-scroll to bottom.""" 

1024 self.auto_scroll_enabled = enabled 

1025 logger.debug(f"Auto-scroll {'enabled' if enabled else 'disabled'}") 

1026 

1027 def toggle_pause_tailing(self, paused: bool) -> None: 

1028 """Toggle pause/resume log tailing.""" 

1029 self.tailing_paused = paused 

1030 if paused and self.tail_timer: 

1031 self.tail_timer.stop() 

1032 elif not paused and self.current_log_path: 

1033 self.start_log_tailing(self.current_log_path) 

1034 logger.debug(f"Log tailing {'paused' if paused else 'resumed'}") 

1035 

1036 def clear_log_display(self) -> None: 

1037 """Clear current log display content.""" 

1038 self.log_display.clear() 

1039 logger.debug("Log display cleared") 

1040 

1041 def scroll_to_bottom(self) -> None: 

1042 """Scroll log display to bottom.""" 

1043 scrollbar = self.log_display.verticalScrollBar() 

1044 scrollbar.setValue(scrollbar.maximum()) 

1045 

1046 

1047 

1048 # Real-time Tailing Methods 

1049 def start_log_tailing(self, log_path: Path) -> None: 

1050 """ 

1051 Start tailing log file with QTimer (100ms interval). 

1052 

1053 Args: 

1054 log_path: Path to log file to tail 

1055 """ 

1056 # Stop any existing timer 

1057 if self.tail_timer: 

1058 self.tail_timer.stop() 

1059 

1060 # Create new timer 

1061 self.tail_timer = QTimer() 

1062 self.tail_timer.timeout.connect(self.read_log_incremental) 

1063 self.tail_timer.start(100) # 100ms interval 

1064 

1065 logger.debug(f"Started tailing log file: {log_path}") 

1066 

1067 def stop_log_tailing(self) -> None: 

1068 """Stop current log tailing.""" 

1069 if self.tail_timer: 

1070 self.tail_timer.stop() 

1071 self.tail_timer = None 

1072 logger.debug("Stopped log tailing") 

1073 

1074 def read_log_incremental(self) -> None: 

1075 """Read new content from current log file (track file position).""" 

1076 if not self.current_log_path or not self.current_log_path.exists(): 

1077 return 

1078 

1079 try: 

1080 # Get current file size 

1081 current_size = self.current_log_path.stat().st_size 

1082 

1083 # Handle log rotation (file size decreased) 

1084 if current_size < self.current_file_position: 

1085 logger.info(f"Log rotation detected for {self.current_log_path}") 

1086 self.current_file_position = 0 

1087 # Optionally clear display or add rotation marker 

1088 self.log_display.append("\n--- Log rotated ---\n") 

1089 

1090 # Read new content if file grew 

1091 if current_size > self.current_file_position: 

1092 with open(self.current_log_path, 'rb') as f: 

1093 f.seek(self.current_file_position) 

1094 new_data = f.read(current_size - self.current_file_position) 

1095 

1096 # Decode new content 

1097 try: 

1098 new_content = new_data.decode('utf-8', errors='replace') 

1099 except UnicodeDecodeError: 

1100 new_content = new_data.decode('latin-1', errors='replace') 

1101 

1102 if new_content: 

1103 # Check if user has scrolled up (disable auto-scroll) 

1104 scrollbar = self.log_display.verticalScrollBar() 

1105 was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 10 

1106 

1107 # Append new content 

1108 cursor = self.log_display.textCursor() 

1109 cursor.movePosition(cursor.MoveOperation.End) 

1110 cursor.insertText(new_content) 

1111 

1112 # Auto-scroll if enabled and user was at bottom 

1113 if self.auto_scroll_enabled and was_at_bottom: 

1114 self.scroll_to_bottom() 

1115 

1116 # Update file position 

1117 self.current_file_position = current_size 

1118 

1119 except (OSError, PermissionError) as e: 

1120 logger.warning(f"Error reading log file {self.current_log_path}: {e}") 

1121 # Handle file deletion/recreation 

1122 if not self.current_log_path.exists(): 

1123 logger.info(f"Log file deleted: {self.current_log_path}") 

1124 self.log_display.append(f"\n--- Log file deleted: {self.current_log_path} ---\n") 

1125 # Try to reconnect after a delay 

1126 QTimer.singleShot(1000, self._attempt_reconnection) 

1127 except Exception as e: 

1128 logger.error(f"Unexpected error in log tailing: {e}") 

1129 raise 

1130 

1131 def _attempt_reconnection(self) -> None: 

1132 """Attempt to reconnect to log file after deletion.""" 

1133 if self.current_log_path and self.current_log_path.exists(): 

1134 logger.info(f"Log file recreated, reconnecting: {self.current_log_path}") 

1135 self.current_file_position = 0 

1136 self.log_display.append(f"\n--- Reconnected to: {self.current_log_path} ---\n") 

1137 # File will be read on next timer tick 

1138 

1139 # External Integration Methods 

1140 def start_monitoring(self, base_log_path: Optional[str] = None) -> None: 

1141 """Start monitoring for new logs.""" 

1142 if self.file_detector: 

1143 self.file_detector.stop_watching() 

1144 

1145 # Get log directory 

1146 log_directory = Path(base_log_path).parent if base_log_path else Path.home() / ".local" / "share" / "openhcs" / "logs" 

1147 

1148 # Start file watching 

1149 self.file_detector = LogFileDetector(base_log_path) 

1150 self.file_detector.new_log_detected.connect(self.add_new_log) 

1151 self.file_detector.start_watching(log_directory) 

1152 

1153 def stop_monitoring(self) -> None: 

1154 """Stop monitoring for new logs.""" 

1155 if self.file_detector: 

1156 self.file_detector.stop_watching() 

1157 self.file_detector = None 

1158 logger.info("Stopped monitoring for new logs") 

1159 

1160 def cleanup(self) -> None: 

1161 """Cleanup all resources and background processes.""" 

1162 try: 

1163 # Stop tailing timer 

1164 if hasattr(self, 'tail_timer') and self.tail_timer and self.tail_timer.isActive(): 

1165 self.tail_timer.stop() 

1166 self.tail_timer.deleteLater() 

1167 self.tail_timer = None 

1168 

1169 # Stop file monitoring 

1170 self.stop_monitoring() 

1171 

1172 # Clean up file detector 

1173 if hasattr(self, 'file_detector') and self.file_detector: 

1174 self.file_detector.stop_watching() 

1175 self.file_detector = None 

1176 

1177 except Exception as e: 

1178 logger.warning(f"Error during log viewer cleanup: {e}") 

1179 

1180 def closeEvent(self, event) -> None: 

1181 """Handle window close event.""" 

1182 if self.file_detector: 

1183 self.file_detector.stop_watching() 

1184 if self.tail_timer: 

1185 self.tail_timer.stop() 

1186 self.window_closed.emit() 

1187 super().closeEvent(event)