Coverage for openhcs/textual_tui/widgets/openhcs_toolong_widget.py: 0.0%

615 statements  

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

1""" 

2OpenHCS Toolong Widget 

3 

4Consolidated toolong widget that combines the best of the external toolong library 

5with OpenHCS-specific dropdown selection logic and file management. 

6 

7This widget is moved from src/toolong/ui.py to be a native OpenHCS component 

8while still importing the core toolong functionality. 

9""" 

10 

11import logging 

12import os 

13import re 

14import threading 

15import time 

16from pathlib import Path 

17from typing import List, Optional 

18 

19from textual.app import ComposeResult 

20from textual.containers import Container, Horizontal 

21from textual.lazy import Lazy 

22from textual.reactive import reactive 

23from textual.widget import Widget 

24from textual.widgets import TabbedContent, TabPane, Select, Button 

25 

26# Import toolong core components 

27from toolong.log_view import LogView, LogLines 

28from toolong.messages import TailFile 

29from toolong.ui import UI 

30from toolong.watcher import get_watcher 

31 

32# Import file system watching 

33from watchdog.observers import Observer 

34from watchdog.events import FileSystemEventHandler, FileCreatedEvent 

35 

36logger = logging.getLogger(__name__) 

37 

38# Global shared watcher to prevent conflicts 

39_shared_watcher = None 

40 

41 

42 

43 

44 

45def get_shared_watcher(): 

46 """Get or create a shared watcher instance to prevent conflicts.""" 

47 global _shared_watcher 

48 if _shared_watcher is None: 

49 _shared_watcher = get_watcher() 

50 return _shared_watcher 

51 

52 

53def clear_all_subprocess_logs_from_ui(app): 

54 """Clear all subprocess logs from UI viewers, keeping only the main TUI log. Can be called from anywhere.""" 

55 try: 

56 # Try to find any existing toolong widgets and clear them 

57 from openhcs.textual_tui.widgets.openhcs_toolong_widget import OpenHCSToolongWidget 

58 toolong_widgets = app.query(OpenHCSToolongWidget) 

59 logger.info(f"Found {len(toolong_widgets)} toolong widgets") 

60 

61 for widget in toolong_widgets: 

62 logger.info("Calling clear logs on toolong widget") 

63 widget._clear_all_logs_except_tui() 

64 logger.info("Clear logs completed") 

65 

66 # Also try to find toolong windows 

67 from openhcs.textual_tui.windows.toolong_window import ToolongWindow 

68 toolong_windows = app.query(ToolongWindow) 

69 logger.info(f"Found {len(toolong_windows)} toolong windows") 

70 

71 for window in toolong_windows: 

72 # Find the widget inside the window 

73 widgets = window.query(OpenHCSToolongWidget) 

74 for widget in widgets: 

75 logger.info("Calling clear logs on toolong widget in window") 

76 widget._clear_all_logs_except_tui() 

77 logger.info("Clear logs completed") 

78 

79 except Exception as e: 

80 logger.error(f"Failed to clear logs from UI: {e}") 

81 import traceback 

82 logger.error(traceback.format_exc()) 

83 

84 

85 

86class LogFileHandler(FileSystemEventHandler): 

87 """File system event handler for detecting new log files.""" 

88 

89 def __init__(self, widget: 'OpenHCSToolongWidget'): 

90 self.widget = widget 

91 

92 def on_created(self, event): 

93 """Handle file creation events.""" 

94 if not event.is_directory and event.src_path.endswith('.log'): 

95 file_path = Path(event.src_path) 

96 if self.widget._is_relevant_log_file(file_path): 

97 logger.debug(f"New log file detected: {file_path}") 

98 self.widget._add_log_file(str(file_path)) 

99 

100 

101class HiddenTabsTabbedContent(TabbedContent): 

102 """TabbedContent that can force-hide tabs regardless of tab count.""" 

103 

104 def __init__(self, *args, force_hide_tabs=False, **kwargs): 

105 super().__init__(*args, **kwargs) 

106 self.force_hide_tabs = force_hide_tabs 

107 

108 def compose(self) -> ComposeResult: 

109 """Override compose to control tab visibility.""" 

110 result = super().compose() 

111 if self.force_hide_tabs: 

112 # Hide tabs immediately after composition 

113 self.call_after_refresh(self._force_hide_tabs) 

114 return result 

115 

116 def _force_hide_tabs(self): 

117 """Force hide tabs regardless of count.""" 

118 try: 

119 tabs = self.query("ContentTabs") 

120 for tab in tabs: 

121 tab.display = False 

122 tab.styles.display = "none" 

123 except Exception as e: 

124 logger.debug(f"Could not force hide tabs: {e}") 

125 

126 def _on_mount(self, event): 

127 """Override mount to prevent automatic tab showing.""" 

128 super()._on_mount(event) 

129 if self.force_hide_tabs: 

130 self._force_hide_tabs() 

131 

132 

133class PersistentTailLogLines(LogLines): 

134 """LogLines that doesn't automatically disable tailing on user interaction.""" 

135 

136 def __init__(self, watcher, file_paths): 

137 super().__init__(watcher, file_paths) 

138 self._persistent_tail = True 

139 

140 def post_message(self, message): 

141 """Override to block TailFile(False) messages when persistent tailing is enabled.""" 

142 # Handle FileError messages safely when app context is not available 

143 from toolong.messages import FileError 

144 

145 if (isinstance(message, TailFile) and 

146 not message.tail and 

147 self._persistent_tail): 

148 # Block the message - don't send TailFile(False) 

149 return 

150 

151 # Handle FileError messages safely when app context is not available 

152 if isinstance(message, FileError): 

153 try: 

154 super().post_message(message) 

155 except Exception as e: 

156 # Log the error instead of crashing if app context is not available 

157 logger.warning(f"Could not post FileError message (app context unavailable): {message.error}") 

158 return 

159 else: 

160 super().post_message(message) 

161 

162 def on_scan_complete(self, event) -> None: 

163 """Override to ensure start_tail is actually called after scan completes.""" 

164 # Call parent first 

165 super().on_scan_complete(event) 

166 

167 # Force start_tail if conditions are met 

168 if len(self.log_files) == 1 and self.can_tail: 

169 try: 

170 self.start_tail() 

171 except Exception as e: 

172 logger.error(f"❌ PersistentTailLogLines: start_tail() failed in on_scan_complete: {e}") 

173 import traceback 

174 traceback.print_exc() 

175 

176 def action_scroll_up(self) -> None: 

177 """Override scroll up to not disable tailing when persistent.""" 

178 if self.pointer_line is None: 

179 super().action_scroll_up() 

180 else: 

181 self.advance_search(-1) 

182 # Don't send TailFile(False) when persistent tailing is enabled 

183 if not self._persistent_tail: 

184 self.post_message(TailFile(False)) 

185 

186 def action_scroll_home(self) -> None: 

187 """Override scroll home to not disable tailing when persistent.""" 

188 if self.pointer_line is not None: 

189 self.pointer_line = 0 

190 self.scroll_to(y=0, duration=0) 

191 # Don't send TailFile(False) when persistent tailing is enabled 

192 if not self._persistent_tail: 

193 self.post_message(TailFile(False)) 

194 

195 def action_scroll_end(self) -> None: 

196 """Override scroll end to not disable tailing when persistent.""" 

197 if self.pointer_line is not None: 

198 self.pointer_line = self.line_count 

199 if self.scroll_offset.y == self.max_scroll_y: 

200 self.post_message(TailFile(True)) 

201 else: 

202 self.scroll_to(y=self.max_scroll_y, duration=0) 

203 # Don't send TailFile(False) when persistent tailing is enabled 

204 if not self._persistent_tail: 

205 self.post_message(TailFile(False)) 

206 

207 def action_page_down(self) -> None: 

208 """Override page down to not disable tailing when persistent.""" 

209 if self.pointer_line is None: 

210 super().action_page_down() 

211 else: 

212 self.pointer_line = ( 

213 self.pointer_line + self.scrollable_content_region.height 

214 ) 

215 self.scroll_pointer_to_center() 

216 # Don't send TailFile(False) when persistent tailing is enabled 

217 if not self._persistent_tail: 

218 self.post_message(TailFile(False)) 

219 

220 def action_page_up(self) -> None: 

221 """Override page up to not disable tailing when persistent.""" 

222 if self.pointer_line is None: 

223 super().action_page_up() 

224 else: 

225 self.pointer_line = max( 

226 0, self.pointer_line - self.scrollable_content_region.height 

227 ) 

228 self.scroll_pointer_to_center() 

229 # Don't send TailFile(False) when persistent tailing is enabled 

230 if not self._persistent_tail: 

231 self.post_message(TailFile(False)) 

232 

233 

234class PersistentTailLogView(LogView): 

235 """LogView that uses PersistentTailLogLines.""" 

236 

237 def on_mount(self) -> None: 

238 """Override to ensure tailing is enabled after mount.""" 

239 # Force enable tailing for persistent behavior 

240 self.tail = True 

241 logger.debug(f"PersistentTailLogView mounted with tail={self.tail}, can_tail={self.can_tail}") 

242 

243 async def watch_tail(self, old_value: bool, new_value: bool) -> None: 

244 """Watch for changes to the tail property.""" 

245 if hasattr(super(), 'watch_tail'): 

246 await super().watch_tail(old_value, new_value) 

247 

248 def compose(self): 

249 """Override to use our custom LogLines with proper data binding.""" 

250 # Create PersistentTailLogLines with proper data binding (this is critical!) 

251 yield ( 

252 log_lines := PersistentTailLogLines(self.watcher, self.file_paths).data_bind( 

253 LogView.tail, 

254 LogView.show_line_numbers, 

255 LogView.show_find, 

256 LogView.can_tail, 

257 ) 

258 ) 

259 

260 # Import the other components from toolong 

261 from toolong.line_panel import LinePanel 

262 from toolong.find_dialog import FindDialog 

263 from toolong.log_view import InfoOverlay, LogFooter 

264 

265 yield LinePanel() 

266 yield FindDialog(log_lines._suggester) 

267 yield InfoOverlay().data_bind(LogView.tail) 

268 

269 # Create LogFooter with error handling for mount_keys 

270 footer = LogFooter().data_bind(LogView.tail, LogView.can_tail) 

271 

272 # Monkey patch mount_keys to add error handling 

273 original_mount_keys = footer.mount_keys 

274 async def safe_mount_keys(): 

275 try: 

276 await original_mount_keys() 

277 except Exception as e: 

278 logger.error(f"LogFooter mount_keys failed: {e}") 

279 # Continue without crashing 

280 footer.mount_keys = safe_mount_keys 

281 

282 yield footer 

283 

284 def on_mount(self) -> None: 

285 """Override to ensure tailing is enabled after mount.""" 

286 # Force enable tailing for persistent behavior 

287 self.tail = True 

288 

289 async def on_scan_complete(self, event) -> None: 

290 """Override to ensure tailing remains enabled after scan.""" 

291 # Call parent method first 

292 await super().on_scan_complete(event) 

293 # Force enable tailing (this is critical!) 

294 self.tail = True 

295 

296 

297class OpenHCSToolongWidget(Widget): 

298 """ 

299 OpenHCS native toolong widget with dropdown selection and file management. 

300  

301 This widget provides professional log viewing with: 

302 - Dropdown selection for multiple log files 

303 - Automatic switching to latest log files 

304 - Tab-based viewing with optional tab hiding 

305 - Persistent tailing functionality 

306 - OpenHCS-specific file naming and organization 

307 """ 

308 

309 # CSS to control Select dropdown height 

310 DEFAULT_CSS = """ 

311 OpenHCSToolongWidget SelectOverlay { 

312 max-height: 20; 

313 } 

314 """ 

315 

316 # Reactive variable to track when tabs are ready 

317 tabs_ready = reactive(False) 

318 

319 def __init__( 

320 self, 

321 file_paths: List[str], 

322 merge: bool = False, 

323 save_merge: str | None = None, 

324 show_tabs: bool = True, 

325 show_dropdown: bool = False, 

326 show_controls: bool = True, 

327 base_log_path: Optional[str] = None, 

328 **kwargs 

329 ) -> None: 

330 logger.debug(f"OpenHCSToolongWidget.__init__ called with {len(file_paths)} files: {[Path(f).name for f in file_paths]}") 

331 super().__init__(**kwargs) 

332 self.file_paths = UI.sort_paths(file_paths) 

333 self.merge = merge 

334 self.save_merge = save_merge 

335 self.show_tabs = show_tabs 

336 self.show_dropdown = show_dropdown 

337 self.show_controls = show_controls 

338 self.watcher = get_shared_watcher() # Use shared watcher to prevent conflicts 

339 self._current_file_path = file_paths[0] if file_paths else None # Track currently viewed file 

340 

341 # Control states 

342 self.auto_tail = True # Whether to auto-scroll on new content 

343 self.manual_tail_enabled = True # Whether tailing is manually enabled 

344 

345 # Dynamic log detection 

346 self.base_log_path = base_log_path 

347 self._file_observer = None 

348 

349 # Timing protection 

350 self._last_tab_switch_time = 0 

351 

352 # Tab creation protection 

353 self._tab_creation_in_progress = False 

354 

355 # Thread-safe log addition system to prevent race conditions 

356 self._log_addition_lock = threading.Lock() 

357 self._pending_logs = set() # Track logs being processed 

358 self._debounce_timer = None 

359 self._debounce_delay = 0.1 # 100ms debounce delay 

360 

361 logger.debug(f"OpenHCSToolongWidget.__init__ completed with show_tabs={show_tabs}, show_dropdown={show_dropdown}, show_controls={show_controls}") 

362 

363 # Start file watcher if we have a base log path for dynamic detection 

364 if self.base_log_path: 

365 self._start_file_watcher() 

366 

367 def compose(self) -> ComposeResult: 

368 """Compose the Toolong widget using persistent tailing LogViews.""" 

369 logger.debug(f"OpenHCSToolongWidget compose() called with {len(self.file_paths)} files") 

370 

371 # Conditionally add control buttons 

372 if self.show_controls: 

373 with Horizontal(classes="toolong-controls"): 

374 yield Button("Auto-Scroll", id="toggle_auto_tail", compact=True) 

375 yield Button("Pause", id="toggle_manual_tail", compact=True) 

376 yield Button("Bottom", id="scroll_to_bottom", compact=True) 

377 yield Button("Clear All", id="clear_all_logs", compact=True) 

378 

379 # Conditionally add dropdown selector 

380 if self.show_dropdown: 

381 if self.file_paths: 

382 # Create initial options from file paths 

383 initial_options = [] 

384 current_index = 0 # Default to first file 

385 

386 for i, path in enumerate(self.file_paths): 

387 # Create friendly tab names 

388 tab_name = self._create_friendly_tab_name(path) 

389 initial_options.append((tab_name, i)) 

390 

391 # Check if this is the currently viewed file 

392 if self._current_file_path and path == self._current_file_path: 

393 current_index = i 

394 

395 yield Select(initial_options, id="log_selector", compact=True, allow_blank=False, value=current_index) 

396 logger.debug(f"Yielded Select widget with {len(initial_options)} initial options, selected index: {current_index}") 

397 else: 

398 # Create empty Select that will be populated later 

399 yield Select([("Loading...", -1)], id="log_selector", compact=True, allow_blank=False, value=-1) 

400 logger.debug("Yielded Select widget with placeholder option") 

401 else: 

402 logger.debug("Skipped Select widget (show_dropdown=False)") 

403 

404 # Always create tabs (needed for dropdown), but conditionally hide them 

405 with HiddenTabsTabbedContent(id="main_tabs", force_hide_tabs=not self.show_tabs): 

406 if self.merge and len(self.file_paths) > 1: 

407 tab_name = " + ".join(Path(path).name for path in self.file_paths) 

408 with TabPane(tab_name): 

409 # Create separate watcher for merged view (like original toolong) 

410 from toolong.watcher import get_watcher 

411 watcher = get_watcher() 

412 watcher.start() # CRITICAL: Start the watcher thread! 

413 yield Lazy( 

414 PersistentTailLogView( 

415 self.file_paths, 

416 watcher, # Separate watcher 

417 can_tail=False, 

418 ) 

419 ) 

420 else: 

421 for path in self.file_paths: 

422 # Create friendly tab names 

423 tab_name = self._create_friendly_tab_name(path) 

424 

425 with TabPane(tab_name): 

426 # Create separate watcher for each LogView (like original toolong) 

427 from toolong.watcher import get_watcher 

428 watcher = get_watcher() 

429 watcher.start() # CRITICAL: Start the watcher thread! 

430 yield Lazy( 

431 PersistentTailLogView( 

432 [path], 

433 watcher, # Separate watcher for each tab 

434 can_tail=True, 

435 ) 

436 ) 

437 

438 logger.debug(f"OpenHCSToolongWidget compose() completed") 

439 

440 def _create_friendly_tab_name(self, path: str) -> str: 

441 """Create a friendly display name for a log file path.""" 

442 tab_name = Path(path).name 

443 

444 # Check for most specific patterns first 

445 if "worker_" in tab_name: 

446 # Extract worker ID 

447 worker_match = re.search(r'worker_(\d+)', tab_name) 

448 tab_name = f"Worker {worker_match.group(1)}" if worker_match else "Worker" 

449 elif "subprocess" in tab_name: 

450 # Subprocess runner (plate manager spawned process) 

451 tab_name = "Subprocess" 

452 elif "unified" in tab_name: 

453 # Main TUI thread (only if no subprocess/worker indicators) 

454 tab_name = "TUI Main" 

455 else: 

456 # Fallback to filename 

457 tab_name = Path(path).stem 

458 

459 return tab_name 

460 

461 def _is_tui_main_log(self, path: str) -> bool: 

462 """Check if a log file is the main TUI log (not subprocess or worker).""" 

463 log_name = Path(path).name 

464 # TUI main logs don't contain "subprocess" or "worker" in the name 

465 return "_subprocess_" not in log_name and "_worker_" not in log_name 

466 

467 

468 

469 def on_mount(self) -> None: 

470 """Update dropdown when widget mounts and enable persistent tailing.""" 

471 try: 

472 logger.debug("OpenHCSToolongWidget on_mount called") 

473 

474 # Start the watcher (critical for real-time updates!) 

475 if not hasattr(self.watcher, '_thread') or self.watcher._thread is None: 

476 logger.debug("Starting watcher for real-time log updates") 

477 self.watcher.start() 

478 else: 

479 logger.debug("Watcher already running") 

480 

481 # Set tabs_ready to True after mounting to trigger dropdown update 

482 self.call_after_refresh(self._mark_tabs_ready) 

483 # Enable persistent tailing by default 

484 self.call_after_refresh(self._enable_persistent_tailing) 

485 logger.debug("OpenHCSToolongWidget on_mount completed successfully") 

486 except Exception as e: 

487 logger.error(f"OpenHCSToolongWidget on_mount failed: {e}") 

488 import traceback 

489 logger.error(f"OpenHCSToolongWidget on_mount traceback: {traceback.format_exc()}") 

490 

491 def _enable_persistent_tailing(self) -> None: 

492 """Enable persistent tailing on all LogViews and LogLines.""" 

493 try: 

494 logger.debug("Enabling persistent tailing by default") 

495 

496 # Enable tailing on LogView widgets (this is critical!) 

497 for log_view in self.query("PersistentTailLogView"): 

498 if hasattr(log_view, 'can_tail') and log_view.can_tail: 

499 log_view.tail = True 

500 logger.debug(f"Enabled tail=True on LogView: {log_view}") 

501 

502 # Enable persistent tailing on LogLines and start individual file tailing 

503 log_lines_widgets = self.query("PersistentTailLogLines") 

504 logger.debug(f"🔍 Found {len(log_lines_widgets)} PersistentTailLogLines widgets for tailing setup") 

505 

506 for log_lines in log_lines_widgets: 

507 logger.debug(f"🔍 Processing LogLines: {log_lines}, log_files={len(getattr(log_lines, 'log_files', []))}") 

508 

509 log_lines._persistent_tail = True 

510 log_lines.post_message(TailFile(True)) 

511 

512 # Check if file is opened before starting tailing 

513 if hasattr(log_lines, 'start_tail') and len(log_lines.log_files) == 1: 

514 log_file = log_lines.log_files[0] 

515 file_opened = hasattr(log_file, 'file') and log_file.file is not None 

516 logger.debug(f"🔍 File status: path={getattr(log_file, 'path', 'unknown')}, file_opened={file_opened}") 

517 

518 if file_opened: 

519 try: 

520 log_lines.start_tail() 

521 logger.debug(f"✅ Started file tailing on LogLines: {log_lines}") 

522 except Exception as e: 

523 logger.error(f"❌ Failed to start tailing on LogLines: {e}") 

524 import traceback 

525 traceback.print_exc() 

526 else: 

527 logger.debug(f"⏰ File not opened yet, tailing will start after scan completes") 

528 else: 

529 logger.warning(f"⚠️ Cannot start tailing: has_start_tail={hasattr(log_lines, 'start_tail')}, log_files={len(getattr(log_lines, 'log_files', []))}") 

530 

531 logger.debug(f"Enabled persistent tailing for {log_lines}") 

532 except Exception as e: 

533 logger.error(f"Failed to enable persistent tailing: {e}") 

534 

535 def _mark_tabs_ready(self) -> None: 

536 """Mark tabs as ready, which will trigger the watcher.""" 

537 try: 

538 logger.debug("Marking tabs as ready") 

539 

540 # Control tab visibility using the existing logic pattern 

541 # When show_tabs=False, pretend there's only 1 tab so tabs are hidden 

542 # When show_tabs=True, use actual tab count 

543 actual_tab_count = len(self.query(TabPane)) 

544 effective_tab_count = actual_tab_count if self.show_tabs else 1 

545 

546 self.query("#main_tabs Tabs").set(display=effective_tab_count > 1) 

547 logger.debug(f"Tab visibility: show_tabs={self.show_tabs}, actual_tabs={actual_tab_count}, effective_tabs={effective_tab_count}, display={effective_tab_count > 1}") 

548 

549 self.tabs_ready = True 

550 logger.debug("tabs_ready set to True") 

551 except Exception as e: 

552 logger.error(f"_mark_tabs_ready failed: {e}") 

553 import traceback 

554 logger.error(f"_mark_tabs_ready traceback: {traceback.format_exc()}") 

555 

556 def _force_hide_tabs_after_activation(self): 

557 """Force hide tabs after tab activation events.""" 

558 try: 

559 tabs_elements = self.query("#main_tabs Tabs") 

560 if tabs_elements: 

561 for tabs_element in tabs_elements: 

562 tabs_element.display = False 

563 tabs_element.styles.display = "none" 

564 except Exception as e: 

565 logger.error(f"_force_hide_tabs_after_activation failed: {e}") 

566 

567 def watch_tabs_ready(self, tabs_ready: bool) -> None: 

568 """Watcher that updates dropdown when tabs are ready.""" 

569 if tabs_ready: 

570 logger.debug("tabs_ready watcher triggered") 

571 self._update_dropdown_from_tabs() 

572 

573 def _update_dropdown_from_tabs(self) -> None: 

574 """Update dropdown options to match current tabs.""" 

575 logger.debug("_update_dropdown_from_tabs called") 

576 

577 # Check if dropdown exists 

578 try: 

579 select = self.query_one("#log_selector", Select) 

580 except: 

581 logger.debug("No dropdown selector found, skipping update") 

582 return 

583 

584 tabbed_content = self.query_one("#main_tabs", TabbedContent) 

585 tab_panes = tabbed_content.query(TabPane) 

586 

587 logger.debug(f"Found {len(tab_panes)} tab panes") 

588 

589 # Check if we need to update options (either placeholder or different count) 

590 current_value = select.value 

591 options_need_update = (current_value == -1 or # Placeholder value 

592 len(tab_panes) != len(getattr(select, '_options', []))) 

593 

594 # Check if selection needs to be updated to match current file 

595 selection_needs_update = False 

596 if self._current_file_path: 

597 try: 

598 current_file_index = self.file_paths.index(self._current_file_path) 

599 if current_value != current_file_index: 

600 selection_needs_update = True 

601 logger.debug(f"Selection needs update: current={current_value}, should be={current_file_index}") 

602 except ValueError: 

603 logger.warning(f"Current file {self._current_file_path} not found in file_paths") 

604 selection_needs_update = True # Force update if current file not found 

605 

606 if not options_need_update and not selection_needs_update: 

607 logger.debug("Dropdown already has correct options and selection, skipping update") 

608 return 

609 

610 # Only update options if needed 

611 if options_need_update: 

612 logger.debug(f"Found {len(tab_panes)} tab panes") 

613 logger.debug(f"Tab pane IDs: {[getattr(pane, 'id', 'no-id') for pane in tab_panes]}") 

614 

615 # Create dropdown options from tab labels 

616 options = [] 

617 logger.debug("Starting to process tab panes...") 

618 for i, tab_pane in enumerate(tab_panes): 

619 logger.debug(f"Processing tab_pane {i}: {tab_pane}") 

620 # Get the tab title from the TabPane - this is what shows in the tab 

621 tab_label = getattr(tab_pane, '_title', str(tab_pane)) 

622 logger.debug(f"Tab {i}: {tab_label}") 

623 options.append((tab_label, i)) 

624 

625 logger.debug(f"Created options: {options}") 

626 

627 # Update dropdown - let Textual handle sizing automatically 

628 logger.debug("About to call select.set_options...") 

629 select.set_options(options) 

630 logger.debug(f"Set dropdown options: {options}") 

631 else: 

632 logger.debug("Options don't need updating, only updating selection") 

633 

634 # Update selection - prioritize current file path over active tab 

635 if self._current_file_path: 

636 try: 

637 current_file_index = self.file_paths.index(self._current_file_path) 

638 select.value = current_file_index 

639 logger.debug(f"Set dropdown to current file index: {current_file_index} ({Path(self._current_file_path).name})") 

640 return 

641 except ValueError: 

642 logger.warning(f"Current file {self._current_file_path} not found in file_paths") 

643 

644 # Fallback to active tab if current file not found 

645 if len(tab_panes) > 0: 

646 try: 

647 active_tab = tabbed_content.active_pane 

648 if active_tab: 

649 try: 

650 active_index = list(tab_panes).index(active_tab) 

651 select.value = active_index 

652 logger.debug(f"Set dropdown to active tab index: {active_index}") 

653 except ValueError: 

654 # Active tab not found in list, default to first option 

655 select.value = 0 

656 logger.debug("Active tab not found, defaulting to first option") 

657 else: 

658 # No active tab, select first option 

659 select.value = 0 

660 logger.debug("No active tab, selecting first option") 

661 except Exception as e: 

662 # Tab access failed (probably due to tabs being removed), default to first option 

663 logger.debug(f"Tab access failed during update: {e}, defaulting to first option") 

664 select.value = 0 

665 else: 

666 logger.warning("No tab panes available for dropdown") 

667 

668 def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None: 

669 """Handle tab activation - update dropdown to match.""" 

670 logger.debug(f"Tab activated: {event.tab}") 

671 

672 # Update current file path tracking based on active tab 

673 try: 

674 tabbed_content = self.query_one("#main_tabs", TabbedContent) 

675 tab_panes = tabbed_content.query(TabPane) 

676 active_tab = tabbed_content.active_pane 

677 

678 if active_tab: 

679 active_index = list(tab_panes).index(active_tab) 

680 if 0 <= active_index < len(self.file_paths): 

681 self._current_file_path = self.file_paths[active_index] 

682 logger.debug(f"Updated current file path to: {Path(self._current_file_path).name}") 

683 except Exception as e: 

684 logger.error(f"Error updating current file path: {e}") 

685 

686 # Update dropdown when tabs change 

687 self._update_dropdown_from_tabs() 

688 

689 # Force hide tabs again after activation if show_tabs=False 

690 if not self.show_tabs: 

691 self.call_after_refresh(lambda: self._force_hide_tabs_after_activation()) 

692 

693 def on_select_changed(self, event: Select.Changed) -> None: 

694 """Handle dropdown selection - switch to corresponding tab.""" 

695 if (event.control.id == "log_selector" and 

696 event.value is not None and 

697 isinstance(event.value, int) and 

698 event.value >= 0): # Ignore placeholder value (-1) 

699 

700 try: 

701 tabbed_content = self.query_one("#main_tabs", TabbedContent) 

702 tab_panes = tabbed_content.query(TabPane) 

703 

704 if 0 <= event.value < len(tab_panes): 

705 target_tab = tab_panes[event.value] 

706 tabbed_content.active = target_tab.id 

707 

708 # Update current file path tracking 

709 if event.value < len(self.file_paths): 

710 self._current_file_path = self.file_paths[event.value] 

711 logger.debug(f"Switched to tab {event.value}, file: {Path(self._current_file_path).name}") 

712 else: 

713 logger.warning(f"Tab index {event.value} out of range for file_paths") 

714 else: 

715 logger.warning(f"Invalid tab index: {event.value}, available: {len(tab_panes)}") 

716 

717 except Exception as e: 

718 logger.error(f"Exception switching tab: {e}") 

719 

720 def on_button_pressed(self, event: Button.Pressed) -> None: 

721 """Handle button presses for tailing controls.""" 

722 if event.button.id == "toggle_auto_tail": 

723 self.auto_tail = not self.auto_tail 

724 event.button.label = f"Auto-Scroll {'On' if self.auto_tail else 'Off'}" 

725 logger.debug(f"Auto-scroll toggled: {self.auto_tail}") 

726 

727 elif event.button.id == "toggle_manual_tail": 

728 self.manual_tail_enabled = not self.manual_tail_enabled 

729 event.button.label = f"{'Resume' if not self.manual_tail_enabled else 'Pause'}" 

730 logger.debug(f"Manual tailing toggled: {self.manual_tail_enabled}") 

731 

732 # Control persistent tailing through OpenHCSToolongWidget 

733 self.toggle_persistent_tailing(self.manual_tail_enabled) 

734 

735 elif event.button.id == "scroll_to_bottom": 

736 # Use OpenHCSToolongWidget method to scroll to bottom and enable tailing 

737 self.scroll_to_bottom_and_tail() 

738 logger.debug("Scrolled to bottom and enabled tailing") 

739 

740 elif event.button.id == "clear_all_logs": 

741 self._show_clear_confirmation() 

742 logger.debug("Showing clear confirmation dialog") 

743 

744 

745 

746 def update_file_paths(self, new_file_paths: List[str], old_file_paths: List[str] = None) -> None: 

747 """Update tabs when new file paths are detected.""" 

748 logger.debug(f"OpenHCSToolongWidget.update_file_paths called with {len(new_file_paths)} files") 

749 

750 # Use provided old_file_paths or current self.file_paths 

751 if old_file_paths is None: 

752 old_file_paths = self.file_paths 

753 

754 old_file_paths_set = set(old_file_paths) 

755 self.file_paths = new_file_paths 

756 new_file_paths_set = set(new_file_paths) 

757 

758 # Find newly added files 

759 newly_added = new_file_paths_set - old_file_paths_set 

760 logger.debug(f"DEBUG: old_file_paths_set={len(old_file_paths_set)}, new_file_paths_set={len(new_file_paths_set)}, newly_added={len(newly_added)}") 

761 if newly_added: 

762 logger.debug(f"Found {len(newly_added)} newly added files: {[Path(p).name for p in newly_added]}") 

763 # Find the most recent file 

764 most_recent_file = max(newly_added, key=lambda p: os.path.getmtime(p)) 

765 logger.debug(f"Most recent file: {Path(most_recent_file).name}") 

766 

767 # ALWAYS switch to the most recent file when new files are added 

768 self._current_file_path = most_recent_file 

769 logger.debug(f"Switching to most recent file: {Path(most_recent_file).name}") 

770 else: 

771 logger.debug("No newly added files detected") 

772 

773 # If no current file is set, default to first file 

774 if not self._current_file_path and new_file_paths: 

775 self._current_file_path = new_file_paths[0] 

776 logger.debug(f"No current file set, defaulting to first: {Path(self._current_file_path).name}") 

777 

778 # If new files were added, trigger a full recompose 

779 if newly_added: 

780 logger.debug(f"New files detected, triggering recompose for {len(newly_added)} new files") 

781 self.refresh(recompose=True) 

782 else: 

783 # Just update dropdown if no new files 

784 self.call_after_refresh(self._update_dropdown_from_tabs) 

785 

786 def toggle_persistent_tailing(self, enabled: bool): 

787 """Enable or disable persistent tailing for all LogLines.""" 

788 for log_lines in self.query("PersistentTailLogLines"): 

789 log_lines._persistent_tail = enabled 

790 if enabled: 

791 log_lines.post_message(TailFile(True)) 

792 else: 

793 log_lines.post_message(TailFile(False)) 

794 

795 def scroll_to_bottom_and_tail(self): 

796 """Scroll all LogLines to bottom and ensure tailing is enabled.""" 

797 for log_lines in self.query("PersistentTailLogLines"): 

798 log_lines.scroll_to(y=log_lines.max_scroll_y, duration=0.3) 

799 log_lines._persistent_tail = True 

800 log_lines.post_message(TailFile(True)) 

801 

802 def on_unmount(self) -> None: 

803 """Clean up watcher and LogLines when widget is unmounted.""" 

804 try: 

805 logger.debug("OpenHCSToolongWidget unmounting, cleaning up resources") 

806 

807 # Stop file observer if running 

808 if self._file_observer: 

809 self._file_observer.stop() 

810 self._file_observer.join() 

811 self._file_observer = None 

812 logger.debug("File observer stopped") 

813 

814 # No shared watcher cleanup needed - each LogView has its own watcher 

815 # The individual watchers will be cleaned up when their LogLines widgets unmount 

816 logger.debug("No shared watcher cleanup needed - using separate watchers per LogView") 

817 

818 # Don't close shared watcher - other widgets might be using it 

819 # The shared watcher will be cleaned up when the app exits 

820 logger.debug("OpenHCSToolongWidget unmount completed") 

821 except Exception as e: 

822 logger.error(f"Error during OpenHCSToolongWidget unmount: {e}") 

823 

824 def _start_file_watcher(self): 

825 """Start watching for new OpenHCS log files.""" 

826 if not self.base_log_path: 

827 return 

828 

829 try: 

830 # base_log_path is now the logs directory 

831 log_dir = Path(self.base_log_path) 

832 if not log_dir.exists(): 

833 logger.warning(f"Log directory does not exist: {log_dir}") 

834 return 

835 

836 self._file_observer = Observer() 

837 handler = LogFileHandler(self) 

838 self._file_observer.schedule(handler, str(log_dir), recursive=False) 

839 self._file_observer.start() 

840 logger.debug(f"Started file watcher for OpenHCS logs in: {log_dir}") 

841 except Exception as e: 

842 logger.error(f"Failed to start file watcher: {e}") 

843 

844 def _is_relevant_log_file(self, file_path: Path) -> bool: 

845 """Check if a log file is relevant to the current session.""" 

846 if not file_path.name.endswith('.log'): 

847 return False 

848 

849 # Check if it's an OpenHCS unified log file 

850 return (file_path.name.startswith('openhcs_unified_') and 

851 str(file_path) not in self.file_paths) 

852 

853 def _add_log_file(self, log_file_path: str): 

854 """Thread-safe log file addition with debouncing to prevent race conditions.""" 

855 with self._log_addition_lock: 

856 # Check if already processing this log 

857 if log_file_path in self._pending_logs: 

858 logger.debug(f"Log file {log_file_path} already being processed, skipping") 

859 return 

860 

861 # Check if already exists 

862 if log_file_path in self.file_paths: 

863 logger.debug(f"Log file {log_file_path} already exists, skipping") 

864 return 

865 

866 # Add to pending set to prevent duplicate processing 

867 self._pending_logs.add(log_file_path) 

868 logger.debug(f"Adding new log file to pending: {log_file_path}") 

869 

870 # Use debouncing to handle rapid successive additions 

871 self._debounced_log_addition(log_file_path) 

872 

873 def _debounced_log_addition(self, log_file_path: str): 

874 """Debounced log addition to handle rapid file creation.""" 

875 # Cancel existing timer if any 

876 if self._debounce_timer: 

877 self._debounce_timer.cancel() 

878 

879 # Start new timer 

880 self._debounce_timer = threading.Timer( 

881 self._debounce_delay, 

882 self._process_pending_logs 

883 ) 

884 self._debounce_timer.start() 

885 

886 def _process_pending_logs(self): 

887 """Process all pending log additions in a single batch.""" 

888 with self._log_addition_lock: 

889 if not self._pending_logs: 

890 return 

891 

892 # Store old file paths before modifying 

893 old_file_paths = self.file_paths.copy() 

894 new_logs = list(self._pending_logs) 

895 

896 # Add all pending logs 

897 for log_path in new_logs: 

898 if log_path not in self.file_paths: 

899 self.file_paths.append(log_path) 

900 logger.debug(f"Processed pending log file: {log_path}") 

901 

902 # Sort for consistent display 

903 self.file_paths = UI.sort_paths(self.file_paths) 

904 

905 # Clear pending logs 

906 self._pending_logs.clear() 

907 

908 # Update UI in a single batch operation 

909 if len(self.file_paths) != len(old_file_paths): 

910 logger.debug(f"Batch updating UI: {len(old_file_paths)}{len(self.file_paths)} files") 

911 self.call_after_refresh(self.update_file_paths, self.file_paths, old_file_paths) 

912 

913 def _show_clear_confirmation(self) -> None: 

914 """Show confirmation dialog before clearing logs.""" 

915 # Find the TUI main log(s) to keep 

916 tui_logs = [path for path in self.file_paths if self._is_tui_main_log(path)] 

917 

918 if not tui_logs: 

919 logger.warning("No TUI main log found, cannot clear logs safely") 

920 return 

921 

922 # Keep only the most recent TUI log 

923 tui_log_to_keep = max(tui_logs, key=lambda p: os.path.getmtime(p)) 

924 logs_to_remove = [path for path in self.file_paths if path != tui_log_to_keep] 

925 

926 if not logs_to_remove: 

927 logger.info("No logs to remove") 

928 return 

929 

930 # Show confirmation window 

931 from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

932 from textual.widgets import Static, Button 

933 from textual.containers import Horizontal 

934 from textual.app import ComposeResult 

935 

936 tui_log_name = Path(tui_log_to_keep).name 

937 

938 class ClearLogsConfirmationWindow(BaseOpenHCSWindow): 

939 def __init__(self, tui_log_name: str, logs_to_remove_count: int, on_result_callback): 

940 super().__init__( 

941 window_id="clear_logs_confirmation", 

942 title="Clear All Logs", 

943 mode="temporary" 

944 ) 

945 self.tui_log_name = tui_log_name 

946 self.logs_to_remove_count = logs_to_remove_count 

947 self.on_result_callback = on_result_callback 

948 

949 def compose(self) -> ComposeResult: 

950 message = f"This will remove {self.logs_to_remove_count} log entries.\nOnly '{self.tui_log_name}' will remain.\nThis action cannot be undone." 

951 yield Static(message, classes="dialog-content") 

952 

953 with Horizontal(classes="dialog-buttons"): 

954 yield Button("Cancel", id="cancel", compact=True) 

955 yield Button("Clear All", id="clear", compact=True) 

956 

957 def on_button_pressed(self, event: Button.Pressed) -> None: 

958 result = event.button.id == "clear" 

959 if self.on_result_callback: 

960 self.on_result_callback(result) 

961 self.close_window() 

962 

963 def handle_confirmation(result): 

964 if result: 

965 self._clear_all_logs_except_tui() 

966 

967 # Create and mount confirmation window 

968 confirmation = ClearLogsConfirmationWindow(tui_log_name, len(logs_to_remove), handle_confirmation) 

969 self.app.mount(confirmation) 

970 confirmation.open_state = True 

971 

972 def _clear_all_logs_except_tui(self) -> None: 

973 """Clear all log entries except the main TUI log and properly clean up resources.""" 

974 logger.info("Clearing all logs except TUI main...") 

975 

976 # Find the TUI main log(s) to keep 

977 tui_logs = [path for path in self.file_paths if self._is_tui_main_log(path)] 

978 

979 if not tui_logs: 

980 logger.warning("No TUI main log found, keeping all logs") 

981 return 

982 

983 # Keep only the most recent TUI log 

984 tui_log_to_keep = max(tui_logs, key=lambda p: os.path.getmtime(p)) 

985 logs_to_remove = [path for path in self.file_paths if path != tui_log_to_keep] 

986 

987 logger.info(f"Keeping TUI log: {Path(tui_log_to_keep).name}") 

988 logger.info(f"Removing {len(logs_to_remove)} logs: {[Path(p).name for p in logs_to_remove]}") 

989 

990 # Set active tab to first one (TUI main) BEFORE clearing to avoid race conditions 

991 try: 

992 tabbed_content = self.query_one("#main_tabs", HiddenTabsTabbedContent) 

993 tab_panes = list(tabbed_content.query(TabPane)) 

994 if tab_panes: 

995 # Set active tab to first one (should be TUI main) 

996 tabbed_content.active = tab_panes[0].id 

997 logger.info("Set active tab to first tab (TUI main) before clearing") 

998 except Exception as e: 

999 logger.warning(f"Could not set active tab before clearing: {e}") 

1000 

1001 # Clean up resources for logs being removed 

1002 self._cleanup_removed_logs(logs_to_remove) 

1003 

1004 # Update file paths to only include the TUI log 

1005 old_file_paths = self.file_paths.copy() 

1006 self.file_paths = [tui_log_to_keep] 

1007 

1008 # Update UI 

1009 self.call_after_refresh(self.update_file_paths, self.file_paths, old_file_paths) 

1010 

1011 logger.info("Log clearing completed") 

1012 

1013 def _cleanup_removed_logs(self, logs_to_remove: List[str]) -> None: 

1014 """Clean up resources (watchers, threads) for removed log files.""" 

1015 try: 

1016 # Get all TabPanes and their associated LogViews 

1017 tabbed_content = self.query_one("#main_tabs", HiddenTabsTabbedContent) 

1018 tab_panes = list(tabbed_content.query(TabPane)) 

1019 

1020 # Find tabs corresponding to logs being removed 

1021 tabs_to_remove = [] 

1022 for i, path in enumerate(self.file_paths): 

1023 if path in logs_to_remove and i < len(tab_panes): 

1024 tabs_to_remove.append(tab_panes[i]) 

1025 

1026 # Clean up each tab's resources 

1027 for tab_pane in tabs_to_remove: 

1028 try: 

1029 # Find LogView in this tab 

1030 log_views = tab_pane.query("PersistentTailLogView") 

1031 for log_view in log_views: 

1032 # Clean up LogLines and their watchers 

1033 log_lines_widgets = log_view.query("LogLines") 

1034 for log_lines in log_lines_widgets: 

1035 # Stop the watcher for this LogLines 

1036 if hasattr(log_lines, 'watcher') and log_lines.watcher: 

1037 try: 

1038 log_lines.watcher.close() 

1039 logger.debug(f"Closed watcher for {getattr(log_lines, 'log_files', 'unknown')}") 

1040 except Exception as e: 

1041 logger.warning(f"Error closing watcher: {e}") 

1042 

1043 # Stop line reader thread if it exists 

1044 if hasattr(log_lines, '_line_reader') and log_lines._line_reader: 

1045 try: 

1046 log_lines._line_reader.exit_event.set() 

1047 logger.debug("Stopped line reader thread") 

1048 except Exception as e: 

1049 logger.warning(f"Error stopping line reader: {e}") 

1050 

1051 # Remove the tab pane 

1052 tab_pane.remove() 

1053 logger.debug(f"Removed tab pane") 

1054 

1055 except Exception as e: 

1056 logger.warning(f"Error cleaning up tab resources: {e}") 

1057 

1058 logger.debug(f"Cleaned up resources for {len(tabs_to_remove)} tabs") 

1059 

1060 except Exception as e: 

1061 logger.error(f"Error during resource cleanup: {e}") 

1062 

1063 

1064 

1065