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

614 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +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 

15from pathlib import Path 

16from typing import List, Optional 

17 

18from textual.app import ComposeResult 

19from textual.containers import Horizontal 

20from textual.lazy import Lazy 

21from textual.reactive import reactive 

22from textual.widget import Widget 

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

24 

25# Import toolong core components 

26from toolong.log_view import LogView, LogLines 

27from toolong.messages import TailFile 

28from toolong.ui import UI 

29from toolong.watcher import get_watcher 

30 

31# Import file system watching 

32from watchdog.observers import Observer 

33from watchdog.events import FileSystemEventHandler 

34 

35logger = logging.getLogger(__name__) 

36 

37# Global shared watcher to prevent conflicts 

38_shared_watcher = None 

39 

40 

41 

42 

43 

44def get_shared_watcher(): 

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

46 global _shared_watcher 

47 if _shared_watcher is None: 

48 _shared_watcher = get_watcher() 

49 return _shared_watcher 

50 

51 

52def clear_all_subprocess_logs_from_ui(app): 

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

54 try: 

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

56 from openhcs.textual_tui.widgets.openhcs_toolong_widget import OpenHCSToolongWidget 

57 toolong_widgets = app.query(OpenHCSToolongWidget) 

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

59 

60 for widget in toolong_widgets: 

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

62 widget._clear_all_logs_except_tui() 

63 logger.info("Clear logs completed") 

64 

65 # Also try to find toolong windows 

66 from openhcs.textual_tui.windows.toolong_window import ToolongWindow 

67 toolong_windows = app.query(ToolongWindow) 

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

69 

70 for window in toolong_windows: 

71 # Find the widget inside the window 

72 widgets = window.query(OpenHCSToolongWidget) 

73 for widget in widgets: 

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

75 widget._clear_all_logs_except_tui() 

76 logger.info("Clear logs completed") 

77 

78 except Exception as e: 

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

80 import traceback 

81 logger.error(traceback.format_exc()) 

82 

83 

84 

85class LogFileHandler(FileSystemEventHandler): 

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

87 

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

89 self.widget = widget 

90 

91 def on_created(self, event): 

92 """Handle file creation events.""" 

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

94 file_path = Path(event.src_path) 

95 if self.widget._is_relevant_log_file(file_path): 

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

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

98 

99 

100class HiddenTabsTabbedContent(TabbedContent): 

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

102 

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

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

105 self.force_hide_tabs = force_hide_tabs 

106 

107 def compose(self) -> ComposeResult: 

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

109 result = super().compose() 

110 if self.force_hide_tabs: 

111 # Hide tabs immediately after composition 

112 self.call_after_refresh(self._force_hide_tabs) 

113 return result 

114 

115 def _force_hide_tabs(self): 

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

117 try: 

118 tabs = self.query("ContentTabs") 

119 for tab in tabs: 

120 tab.display = False 

121 tab.styles.display = "none" 

122 except Exception as e: 

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

124 

125 def _on_mount(self, event): 

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

127 super()._on_mount(event) 

128 if self.force_hide_tabs: 

129 self._force_hide_tabs() 

130 

131 

132class PersistentTailLogLines(LogLines): 

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

134 

135 def __init__(self, watcher, file_paths): 

136 super().__init__(watcher, file_paths) 

137 self._persistent_tail = True 

138 

139 def post_message(self, message): 

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

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

142 from toolong.messages import FileError 

143 

144 if (isinstance(message, TailFile) and 

145 not message.tail and 

146 self._persistent_tail): 

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

148 return 

149 

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

151 if isinstance(message, FileError): 

152 try: 

153 super().post_message(message) 

154 except Exception as e: 

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

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

157 return 

158 else: 

159 super().post_message(message) 

160 

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

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

163 # Call parent first 

164 super().on_scan_complete(event) 

165 

166 # Force start_tail if conditions are met 

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

168 try: 

169 self.start_tail() 

170 except Exception as e: 

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

172 import traceback 

173 traceback.print_exc() 

174 

175 def action_scroll_up(self) -> None: 

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

177 if self.pointer_line is None: 

178 super().action_scroll_up() 

179 else: 

180 self.advance_search(-1) 

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

182 if not self._persistent_tail: 

183 self.post_message(TailFile(False)) 

184 

185 def action_scroll_home(self) -> None: 

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

187 if self.pointer_line is not None: 

188 self.pointer_line = 0 

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

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

191 if not self._persistent_tail: 

192 self.post_message(TailFile(False)) 

193 

194 def action_scroll_end(self) -> None: 

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

196 if self.pointer_line is not None: 

197 self.pointer_line = self.line_count 

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

199 self.post_message(TailFile(True)) 

200 else: 

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

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

203 if not self._persistent_tail: 

204 self.post_message(TailFile(False)) 

205 

206 def action_page_down(self) -> None: 

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

208 if self.pointer_line is None: 

209 super().action_page_down() 

210 else: 

211 self.pointer_line = ( 

212 self.pointer_line + self.scrollable_content_region.height 

213 ) 

214 self.scroll_pointer_to_center() 

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

216 if not self._persistent_tail: 

217 self.post_message(TailFile(False)) 

218 

219 def action_page_up(self) -> None: 

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

221 if self.pointer_line is None: 

222 super().action_page_up() 

223 else: 

224 self.pointer_line = max( 

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

226 ) 

227 self.scroll_pointer_to_center() 

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

229 if not self._persistent_tail: 

230 self.post_message(TailFile(False)) 

231 

232 

233class PersistentTailLogView(LogView): 

234 """LogView that uses PersistentTailLogLines.""" 

235 

236 def on_mount(self) -> None: 

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

238 # Force enable tailing for persistent behavior 

239 self.tail = True 

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

241 

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

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

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

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

246 

247 def compose(self): 

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

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

250 yield ( 

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

252 LogView.tail, 

253 LogView.show_line_numbers, 

254 LogView.show_find, 

255 LogView.can_tail, 

256 ) 

257 ) 

258 

259 # Import the other components from toolong 

260 from toolong.line_panel import LinePanel 

261 from toolong.find_dialog import FindDialog 

262 from toolong.log_view import InfoOverlay, LogFooter 

263 

264 yield LinePanel() 

265 yield FindDialog(log_lines._suggester) 

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

267 

268 # Create LogFooter with error handling for mount_keys 

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

270 

271 # Monkey patch mount_keys to add error handling 

272 original_mount_keys = footer.mount_keys 

273 async def safe_mount_keys(): 

274 try: 

275 await original_mount_keys() 

276 except Exception as e: 

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

278 # Continue without crashing 

279 footer.mount_keys = safe_mount_keys 

280 

281 yield footer 

282 

283 def on_mount(self) -> None: 

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

285 # Force enable tailing for persistent behavior 

286 self.tail = True 

287 

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

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

290 # Call parent method first 

291 await super().on_scan_complete(event) 

292 # Force enable tailing (this is critical!) 

293 self.tail = True 

294 

295 

296class OpenHCSToolongWidget(Widget): 

297 """ 

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

299  

300 This widget provides professional log viewing with: 

301 - Dropdown selection for multiple log files 

302 - Automatic switching to latest log files 

303 - Tab-based viewing with optional tab hiding 

304 - Persistent tailing functionality 

305 - OpenHCS-specific file naming and organization 

306 """ 

307 

308 # CSS to control Select dropdown height 

309 DEFAULT_CSS = """ 

310 OpenHCSToolongWidget SelectOverlay { 

311 max-height: 20; 

312 } 

313 """ 

314 

315 # Reactive variable to track when tabs are ready 

316 tabs_ready = reactive(False) 

317 

318 def __init__( 

319 self, 

320 file_paths: List[str], 

321 merge: bool = False, 

322 save_merge: str | None = None, 

323 show_tabs: bool = True, 

324 show_dropdown: bool = False, 

325 show_controls: bool = True, 

326 base_log_path: Optional[str] = None, 

327 **kwargs 

328 ) -> None: 

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

330 super().__init__(**kwargs) 

331 self.file_paths = UI.sort_paths(file_paths) 

332 self.merge = merge 

333 self.save_merge = save_merge 

334 self.show_tabs = show_tabs 

335 self.show_dropdown = show_dropdown 

336 self.show_controls = show_controls 

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

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

339 

340 # Control states 

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

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

343 

344 # Dynamic log detection 

345 self.base_log_path = base_log_path 

346 self._file_observer = None 

347 

348 # Timing protection 

349 self._last_tab_switch_time = 0 

350 

351 # Tab creation protection 

352 self._tab_creation_in_progress = False 

353 

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

355 self._log_addition_lock = threading.Lock() 

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

357 self._debounce_timer = None 

358 self._debounce_delay = 0.1 # 100ms debounce delay 

359 

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

361 

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

363 if self.base_log_path: 

364 self._start_file_watcher() 

365 

366 def compose(self) -> ComposeResult: 

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

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

369 

370 # Conditionally add control buttons 

371 if self.show_controls: 

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

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

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

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

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

377 

378 # Conditionally add dropdown selector 

379 if self.show_dropdown: 

380 if self.file_paths: 

381 # Create initial options from file paths 

382 initial_options = [] 

383 current_index = 0 # Default to first file 

384 

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

386 # Create friendly tab names 

387 tab_name = self._create_friendly_tab_name(path) 

388 initial_options.append((tab_name, i)) 

389 

390 # Check if this is the currently viewed file 

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

392 current_index = i 

393 

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

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

396 else: 

397 # Create empty Select that will be populated later 

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

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

400 else: 

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

402 

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

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

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

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

407 with TabPane(tab_name): 

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

409 from toolong.watcher import get_watcher 

410 watcher = get_watcher() 

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

412 yield Lazy( 

413 PersistentTailLogView( 

414 self.file_paths, 

415 watcher, # Separate watcher 

416 can_tail=False, 

417 ) 

418 ) 

419 else: 

420 for path in self.file_paths: 

421 # Create friendly tab names 

422 tab_name = self._create_friendly_tab_name(path) 

423 

424 with TabPane(tab_name): 

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

426 from toolong.watcher import get_watcher 

427 watcher = get_watcher() 

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

429 yield Lazy( 

430 PersistentTailLogView( 

431 [path], 

432 watcher, # Separate watcher for each tab 

433 can_tail=True, 

434 ) 

435 ) 

436 

437 logger.debug("OpenHCSToolongWidget compose() completed") 

438 

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

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

441 tab_name = Path(path).name 

442 

443 # Check for most specific patterns first 

444 if "worker_" in tab_name: 

445 # Extract worker ID 

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

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

448 elif "subprocess" in tab_name: 

449 # Subprocess runner (plate manager spawned process) 

450 tab_name = "Subprocess" 

451 elif "unified" in tab_name: 

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

453 tab_name = "TUI Main" 

454 else: 

455 # Fallback to filename 

456 tab_name = Path(path).stem 

457 

458 return tab_name 

459 

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

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

462 log_name = Path(path).name 

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

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

465 

466 

467 

468 def on_mount(self) -> None: 

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

470 try: 

471 logger.debug("OpenHCSToolongWidget on_mount called") 

472 

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

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

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

476 self.watcher.start() 

477 else: 

478 logger.debug("Watcher already running") 

479 

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

481 self.call_after_refresh(self._mark_tabs_ready) 

482 # Enable persistent tailing by default 

483 self.call_after_refresh(self._enable_persistent_tailing) 

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

485 except Exception as e: 

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

487 import traceback 

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

489 

490 def _enable_persistent_tailing(self) -> None: 

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

492 try: 

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

494 

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

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

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

498 log_view.tail = True 

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

500 

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

502 log_lines_widgets = self.query("PersistentTailLogLines") 

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

504 

505 for log_lines in log_lines_widgets: 

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

507 

508 log_lines._persistent_tail = True 

509 log_lines.post_message(TailFile(True)) 

510 

511 # Check if file is opened before starting tailing 

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

513 log_file = log_lines.log_files[0] 

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

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

516 

517 if file_opened: 

518 try: 

519 log_lines.start_tail() 

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

521 except Exception as e: 

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

523 import traceback 

524 traceback.print_exc() 

525 else: 

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

527 else: 

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

529 

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

531 except Exception as e: 

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

533 

534 def _mark_tabs_ready(self) -> None: 

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

536 try: 

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

538 

539 # Control tab visibility using the existing logic pattern 

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

541 # When show_tabs=True, use actual tab count 

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

543 effective_tab_count = actual_tab_count if self.show_tabs else 1 

544 

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

546 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}") 

547 

548 self.tabs_ready = True 

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

550 except Exception as e: 

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

552 import traceback 

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

554 

555 def _force_hide_tabs_after_activation(self): 

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

557 try: 

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

559 if tabs_elements: 

560 for tabs_element in tabs_elements: 

561 tabs_element.display = False 

562 tabs_element.styles.display = "none" 

563 except Exception as e: 

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

565 

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

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

568 if tabs_ready: 

569 logger.debug("tabs_ready watcher triggered") 

570 self._update_dropdown_from_tabs() 

571 

572 def _update_dropdown_from_tabs(self) -> None: 

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

574 logger.debug("_update_dropdown_from_tabs called") 

575 

576 # Check if dropdown exists 

577 try: 

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

579 except: 

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

581 return 

582 

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

584 tab_panes = tabbed_content.query(TabPane) 

585 

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

587 

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

589 current_value = select.value 

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

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

592 

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

594 selection_needs_update = False 

595 if self._current_file_path: 

596 try: 

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

598 if current_value != current_file_index: 

599 selection_needs_update = True 

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

601 except ValueError: 

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

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

604 

605 if not options_need_update and not selection_needs_update: 

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

607 return 

608 

609 # Only update options if needed 

610 if options_need_update: 

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

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

613 

614 # Create dropdown options from tab labels 

615 options = [] 

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

617 for i, tab_pane in enumerate(tab_panes): 

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

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

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

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

622 options.append((tab_label, i)) 

623 

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

625 

626 # Update dropdown - let Textual handle sizing automatically 

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

628 select.set_options(options) 

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

630 else: 

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

632 

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

634 if self._current_file_path: 

635 try: 

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

637 select.value = current_file_index 

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

639 return 

640 except ValueError: 

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

642 

643 # Fallback to active tab if current file not found 

644 if len(tab_panes) > 0: 

645 try: 

646 active_tab = tabbed_content.active_pane 

647 if active_tab: 

648 try: 

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

650 select.value = active_index 

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

652 except ValueError: 

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

654 select.value = 0 

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

656 else: 

657 # No active tab, select first option 

658 select.value = 0 

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

660 except Exception as e: 

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

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

663 select.value = 0 

664 else: 

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

666 

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

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

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

670 

671 # Update current file path tracking based on active tab 

672 try: 

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

674 tab_panes = tabbed_content.query(TabPane) 

675 active_tab = tabbed_content.active_pane 

676 

677 if active_tab: 

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

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

680 self._current_file_path = self.file_paths[active_index] 

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

682 except Exception as e: 

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

684 

685 # Update dropdown when tabs change 

686 self._update_dropdown_from_tabs() 

687 

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

689 if not self.show_tabs: 

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

691 

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

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

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

695 event.value is not None and 

696 isinstance(event.value, int) and 

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

698 

699 try: 

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

701 tab_panes = tabbed_content.query(TabPane) 

702 

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

704 target_tab = tab_panes[event.value] 

705 tabbed_content.active = target_tab.id 

706 

707 # Update current file path tracking 

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

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

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

711 else: 

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

713 else: 

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

715 

716 except Exception as e: 

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

718 

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

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

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

722 self.auto_tail = not self.auto_tail 

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

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

725 

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

727 self.manual_tail_enabled = not self.manual_tail_enabled 

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

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

730 

731 # Control persistent tailing through OpenHCSToolongWidget 

732 self.toggle_persistent_tailing(self.manual_tail_enabled) 

733 

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

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

736 self.scroll_to_bottom_and_tail() 

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

738 

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

740 self._show_clear_confirmation() 

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

742 

743 

744 

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

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

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

748 

749 # Use provided old_file_paths or current self.file_paths 

750 if old_file_paths is None: 

751 old_file_paths = self.file_paths 

752 

753 old_file_paths_set = set(old_file_paths) 

754 self.file_paths = new_file_paths 

755 new_file_paths_set = set(new_file_paths) 

756 

757 # Find newly added files 

758 newly_added = new_file_paths_set - old_file_paths_set 

759 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)}") 

760 if newly_added: 

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

762 # Find the most recent file 

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

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

765 

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

767 self._current_file_path = most_recent_file 

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

769 else: 

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

771 

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

773 if not self._current_file_path and new_file_paths: 

774 self._current_file_path = new_file_paths[0] 

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

776 

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

778 if newly_added: 

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

780 self.refresh(recompose=True) 

781 else: 

782 # Just update dropdown if no new files 

783 self.call_after_refresh(self._update_dropdown_from_tabs) 

784 

785 def toggle_persistent_tailing(self, enabled: bool): 

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

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

788 log_lines._persistent_tail = enabled 

789 if enabled: 

790 log_lines.post_message(TailFile(True)) 

791 else: 

792 log_lines.post_message(TailFile(False)) 

793 

794 def scroll_to_bottom_and_tail(self): 

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

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

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

798 log_lines._persistent_tail = True 

799 log_lines.post_message(TailFile(True)) 

800 

801 def on_unmount(self) -> None: 

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

803 try: 

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

805 

806 # Stop file observer if running 

807 if self._file_observer: 

808 self._file_observer.stop() 

809 self._file_observer.join() 

810 self._file_observer = None 

811 logger.debug("File observer stopped") 

812 

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

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

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

816 

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

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

819 logger.debug("OpenHCSToolongWidget unmount completed") 

820 except Exception as e: 

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

822 

823 def _start_file_watcher(self): 

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

825 if not self.base_log_path: 

826 return 

827 

828 try: 

829 # base_log_path is now the logs directory 

830 log_dir = Path(self.base_log_path) 

831 if not log_dir.exists(): 

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

833 return 

834 

835 self._file_observer = Observer() 

836 handler = LogFileHandler(self) 

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

838 self._file_observer.start() 

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

840 except Exception as e: 

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

842 

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

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

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

846 return False 

847 

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

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

850 str(file_path) not in self.file_paths) 

851 

852 def _add_log_file(self, log_file_path: str): 

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

854 with self._log_addition_lock: 

855 # Check if already processing this log 

856 if log_file_path in self._pending_logs: 

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

858 return 

859 

860 # Check if already exists 

861 if log_file_path in self.file_paths: 

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

863 return 

864 

865 # Add to pending set to prevent duplicate processing 

866 self._pending_logs.add(log_file_path) 

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

868 

869 # Use debouncing to handle rapid successive additions 

870 self._debounced_log_addition(log_file_path) 

871 

872 def _debounced_log_addition(self, log_file_path: str): 

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

874 # Cancel existing timer if any 

875 if self._debounce_timer: 

876 self._debounce_timer.cancel() 

877 

878 # Start new timer 

879 self._debounce_timer = threading.Timer( 

880 self._debounce_delay, 

881 self._process_pending_logs 

882 ) 

883 self._debounce_timer.start() 

884 

885 def _process_pending_logs(self): 

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

887 with self._log_addition_lock: 

888 if not self._pending_logs: 

889 return 

890 

891 # Store old file paths before modifying 

892 old_file_paths = self.file_paths.copy() 

893 new_logs = list(self._pending_logs) 

894 

895 # Add all pending logs 

896 for log_path in new_logs: 

897 if log_path not in self.file_paths: 

898 self.file_paths.append(log_path) 

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

900 

901 # Sort for consistent display 

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

903 

904 # Clear pending logs 

905 self._pending_logs.clear() 

906 

907 # Update UI in a single batch operation 

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

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

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

911 

912 def _show_clear_confirmation(self) -> None: 

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

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

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

916 

917 if not tui_logs: 

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

919 return 

920 

921 # Keep only the most recent TUI log 

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

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

924 

925 if not logs_to_remove: 

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

927 return 

928 

929 # Show confirmation window 

930 from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow 

931 from textual.widgets import Static, Button 

932 from textual.containers import Horizontal 

933 from textual.app import ComposeResult 

934 

935 tui_log_name = Path(tui_log_to_keep).name 

936 

937 class ClearLogsConfirmationWindow(BaseOpenHCSWindow): 

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

939 super().__init__( 

940 window_id="clear_logs_confirmation", 

941 title="Clear All Logs", 

942 mode="temporary" 

943 ) 

944 self.tui_log_name = tui_log_name 

945 self.logs_to_remove_count = logs_to_remove_count 

946 self.on_result_callback = on_result_callback 

947 

948 def compose(self) -> ComposeResult: 

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

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

951 

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

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

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

955 

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

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

958 if self.on_result_callback: 

959 self.on_result_callback(result) 

960 self.close_window() 

961 

962 def handle_confirmation(result): 

963 if result: 

964 self._clear_all_logs_except_tui() 

965 

966 # Create and mount confirmation window 

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

968 self.app.mount(confirmation) 

969 confirmation.open_state = True 

970 

971 def _clear_all_logs_except_tui(self) -> None: 

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

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

974 

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

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

977 

978 if not tui_logs: 

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

980 return 

981 

982 # Keep only the most recent TUI log 

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

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

985 

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

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

988 

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

990 try: 

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

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

993 if tab_panes: 

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

995 tabbed_content.active = tab_panes[0].id 

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

997 except Exception as e: 

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

999 

1000 # Clean up resources for logs being removed 

1001 self._cleanup_removed_logs(logs_to_remove) 

1002 

1003 # Update file paths to only include the TUI log 

1004 old_file_paths = self.file_paths.copy() 

1005 self.file_paths = [tui_log_to_keep] 

1006 

1007 # Update UI 

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

1009 

1010 logger.info("Log clearing completed") 

1011 

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

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

1014 try: 

1015 # Get all TabPanes and their associated LogViews 

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

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

1018 

1019 # Find tabs corresponding to logs being removed 

1020 tabs_to_remove = [] 

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

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

1023 tabs_to_remove.append(tab_panes[i]) 

1024 

1025 # Clean up each tab's resources 

1026 for tab_pane in tabs_to_remove: 

1027 try: 

1028 # Find LogView in this tab 

1029 log_views = tab_pane.query("PersistentTailLogView") 

1030 for log_view in log_views: 

1031 # Clean up LogLines and their watchers 

1032 log_lines_widgets = log_view.query("LogLines") 

1033 for log_lines in log_lines_widgets: 

1034 # Stop the watcher for this LogLines 

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

1036 try: 

1037 log_lines.watcher.close() 

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

1039 except Exception as e: 

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

1041 

1042 # Stop line reader thread if it exists 

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

1044 try: 

1045 log_lines._line_reader.exit_event.set() 

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

1047 except Exception as e: 

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

1049 

1050 # Remove the tab pane 

1051 tab_pane.remove() 

1052 logger.debug("Removed tab pane") 

1053 

1054 except Exception as e: 

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

1056 

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

1058 

1059 except Exception as e: 

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

1061 

1062 

1063 

1064