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
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2OpenHCS Toolong Widget
4Consolidated toolong widget that combines the best of the external toolong library
5with OpenHCS-specific dropdown selection logic and file management.
7This widget is moved from src/toolong/ui.py to be a native OpenHCS component
8while still importing the core toolong functionality.
9"""
11import logging
12import os
13import re
14import threading
15from pathlib import Path
16from typing import List, Optional
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
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
31# Import file system watching
32from watchdog.observers import Observer
33from watchdog.events import FileSystemEventHandler
35logger = logging.getLogger(__name__)
37# Global shared watcher to prevent conflicts
38_shared_watcher = None
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
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")
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")
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")
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")
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())
85class LogFileHandler(FileSystemEventHandler):
86 """File system event handler for detecting new log files."""
88 def __init__(self, widget: 'OpenHCSToolongWidget'):
89 self.widget = widget
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))
100class HiddenTabsTabbedContent(TabbedContent):
101 """TabbedContent that can force-hide tabs regardless of tab count."""
103 def __init__(self, *args, force_hide_tabs=False, **kwargs):
104 super().__init__(*args, **kwargs)
105 self.force_hide_tabs = force_hide_tabs
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
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}")
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()
132class PersistentTailLogLines(LogLines):
133 """LogLines that doesn't automatically disable tailing on user interaction."""
135 def __init__(self, watcher, file_paths):
136 super().__init__(watcher, file_paths)
137 self._persistent_tail = True
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
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
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)
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)
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()
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))
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))
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))
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))
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))
233class PersistentTailLogView(LogView):
234 """LogView that uses PersistentTailLogLines."""
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}")
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)
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 )
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
264 yield LinePanel()
265 yield FindDialog(log_lines._suggester)
266 yield InfoOverlay().data_bind(LogView.tail)
268 # Create LogFooter with error handling for mount_keys
269 footer = LogFooter().data_bind(LogView.tail, LogView.can_tail)
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
281 yield footer
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
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
296class OpenHCSToolongWidget(Widget):
297 """
298 OpenHCS native toolong widget with dropdown selection and file management.
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 """
308 # CSS to control Select dropdown height
309 DEFAULT_CSS = """
310 OpenHCSToolongWidget SelectOverlay {
311 max-height: 20;
312 }
313 """
315 # Reactive variable to track when tabs are ready
316 tabs_ready = reactive(False)
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
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
344 # Dynamic log detection
345 self.base_log_path = base_log_path
346 self._file_observer = None
348 # Timing protection
349 self._last_tab_switch_time = 0
351 # Tab creation protection
352 self._tab_creation_in_progress = False
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
360 logger.debug(f"OpenHCSToolongWidget.__init__ completed with show_tabs={show_tabs}, show_dropdown={show_dropdown}, show_controls={show_controls}")
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()
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")
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)
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
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))
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
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)")
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)
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 )
437 logger.debug("OpenHCSToolongWidget compose() completed")
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
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
458 return tab_name
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
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")
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")
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()}")
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")
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}")
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")
505 for log_lines in log_lines_widgets:
506 logger.debug(f"🔍 Processing LogLines: {log_lines}, log_files={len(getattr(log_lines, 'log_files', []))}")
508 log_lines._persistent_tail = True
509 log_lines.post_message(TailFile(True))
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}")
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', []))}")
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}")
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")
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
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}")
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()}")
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}")
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()
572 def _update_dropdown_from_tabs(self) -> None:
573 """Update dropdown options to match current tabs."""
574 logger.debug("_update_dropdown_from_tabs called")
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
583 tabbed_content = self.query_one("#main_tabs", TabbedContent)
584 tab_panes = tabbed_content.query(TabPane)
586 logger.debug(f"Found {len(tab_panes)} tab panes")
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', [])))
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
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
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]}")
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))
624 logger.debug(f"Created options: {options}")
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")
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")
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")
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}")
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
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}")
685 # Update dropdown when tabs change
686 self._update_dropdown_from_tabs()
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())
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)
699 try:
700 tabbed_content = self.query_one("#main_tabs", TabbedContent)
701 tab_panes = tabbed_content.query(TabPane)
703 if 0 <= event.value < len(tab_panes):
704 target_tab = tab_panes[event.value]
705 tabbed_content.active = target_tab.id
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)}")
716 except Exception as e:
717 logger.error(f"Exception switching tab: {e}")
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}")
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}")
731 # Control persistent tailing through OpenHCSToolongWidget
732 self.toggle_persistent_tailing(self.manual_tail_enabled)
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")
739 elif event.button.id == "clear_all_logs":
740 self._show_clear_confirmation()
741 logger.debug("Showing clear confirmation dialog")
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")
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
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)
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}")
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")
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}")
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)
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))
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))
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")
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")
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")
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}")
823 def _start_file_watcher(self):
824 """Start watching for new OpenHCS log files."""
825 if not self.base_log_path:
826 return
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
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}")
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
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)
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
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
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}")
869 # Use debouncing to handle rapid successive additions
870 self._debounced_log_addition(log_file_path)
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()
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()
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
891 # Store old file paths before modifying
892 old_file_paths = self.file_paths.copy()
893 new_logs = list(self._pending_logs)
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}")
901 # Sort for consistent display
902 self.file_paths = UI.sort_paths(self.file_paths)
904 # Clear pending logs
905 self._pending_logs.clear()
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)
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)]
917 if not tui_logs:
918 logger.warning("No TUI main log found, cannot clear logs safely")
919 return
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]
925 if not logs_to_remove:
926 logger.info("No logs to remove")
927 return
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
935 tui_log_name = Path(tui_log_to_keep).name
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
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")
952 with Horizontal(classes="dialog-buttons"):
953 yield Button("Cancel", id="cancel", compact=True)
954 yield Button("Clear All", id="clear", compact=True)
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()
962 def handle_confirmation(result):
963 if result:
964 self._clear_all_logs_except_tui()
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
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...")
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)]
978 if not tui_logs:
979 logger.warning("No TUI main log found, keeping all logs")
980 return
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]
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]}")
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}")
1000 # Clean up resources for logs being removed
1001 self._cleanup_removed_logs(logs_to_remove)
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]
1007 # Update UI
1008 self.call_after_refresh(self.update_file_paths, self.file_paths, old_file_paths)
1010 logger.info("Log clearing completed")
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))
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])
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}")
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}")
1050 # Remove the tab pane
1051 tab_pane.remove()
1052 logger.debug("Removed tab pane")
1054 except Exception as e:
1055 logger.warning(f"Error cleaning up tab resources: {e}")
1057 logger.debug(f"Cleaned up resources for {len(tabs_to_remove)} tabs")
1059 except Exception as e:
1060 logger.error(f"Error during resource cleanup: {e}")