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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +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
15import time
16from pathlib import Path
17from typing import List, Optional
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
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
32# Import file system watching
33from watchdog.observers import Observer
34from watchdog.events import FileSystemEventHandler, FileCreatedEvent
36logger = logging.getLogger(__name__)
38# Global shared watcher to prevent conflicts
39_shared_watcher = None
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
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")
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")
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")
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")
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())
86class LogFileHandler(FileSystemEventHandler):
87 """File system event handler for detecting new log files."""
89 def __init__(self, widget: 'OpenHCSToolongWidget'):
90 self.widget = widget
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))
101class HiddenTabsTabbedContent(TabbedContent):
102 """TabbedContent that can force-hide tabs regardless of tab count."""
104 def __init__(self, *args, force_hide_tabs=False, **kwargs):
105 super().__init__(*args, **kwargs)
106 self.force_hide_tabs = force_hide_tabs
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
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}")
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()
133class PersistentTailLogLines(LogLines):
134 """LogLines that doesn't automatically disable tailing on user interaction."""
136 def __init__(self, watcher, file_paths):
137 super().__init__(watcher, file_paths)
138 self._persistent_tail = True
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
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
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)
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)
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()
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))
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))
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))
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))
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))
234class PersistentTailLogView(LogView):
235 """LogView that uses PersistentTailLogLines."""
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}")
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)
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 )
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
265 yield LinePanel()
266 yield FindDialog(log_lines._suggester)
267 yield InfoOverlay().data_bind(LogView.tail)
269 # Create LogFooter with error handling for mount_keys
270 footer = LogFooter().data_bind(LogView.tail, LogView.can_tail)
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
282 yield footer
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
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
297class OpenHCSToolongWidget(Widget):
298 """
299 OpenHCS native toolong widget with dropdown selection and file management.
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 """
309 # CSS to control Select dropdown height
310 DEFAULT_CSS = """
311 OpenHCSToolongWidget SelectOverlay {
312 max-height: 20;
313 }
314 """
316 # Reactive variable to track when tabs are ready
317 tabs_ready = reactive(False)
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
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
345 # Dynamic log detection
346 self.base_log_path = base_log_path
347 self._file_observer = None
349 # Timing protection
350 self._last_tab_switch_time = 0
352 # Tab creation protection
353 self._tab_creation_in_progress = False
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
361 logger.debug(f"OpenHCSToolongWidget.__init__ completed with show_tabs={show_tabs}, show_dropdown={show_dropdown}, show_controls={show_controls}")
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()
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")
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)
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
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))
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
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)")
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)
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 )
438 logger.debug(f"OpenHCSToolongWidget compose() completed")
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
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
459 return tab_name
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
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")
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")
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()}")
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")
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}")
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")
506 for log_lines in log_lines_widgets:
507 logger.debug(f"🔍 Processing LogLines: {log_lines}, log_files={len(getattr(log_lines, 'log_files', []))}")
509 log_lines._persistent_tail = True
510 log_lines.post_message(TailFile(True))
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}")
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', []))}")
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}")
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")
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
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}")
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()}")
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}")
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()
573 def _update_dropdown_from_tabs(self) -> None:
574 """Update dropdown options to match current tabs."""
575 logger.debug("_update_dropdown_from_tabs called")
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
584 tabbed_content = self.query_one("#main_tabs", TabbedContent)
585 tab_panes = tabbed_content.query(TabPane)
587 logger.debug(f"Found {len(tab_panes)} tab panes")
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', [])))
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
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
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]}")
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))
625 logger.debug(f"Created options: {options}")
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")
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")
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")
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}")
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
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}")
686 # Update dropdown when tabs change
687 self._update_dropdown_from_tabs()
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())
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)
700 try:
701 tabbed_content = self.query_one("#main_tabs", TabbedContent)
702 tab_panes = tabbed_content.query(TabPane)
704 if 0 <= event.value < len(tab_panes):
705 target_tab = tab_panes[event.value]
706 tabbed_content.active = target_tab.id
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)}")
717 except Exception as e:
718 logger.error(f"Exception switching tab: {e}")
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}")
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}")
732 # Control persistent tailing through OpenHCSToolongWidget
733 self.toggle_persistent_tailing(self.manual_tail_enabled)
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")
740 elif event.button.id == "clear_all_logs":
741 self._show_clear_confirmation()
742 logger.debug("Showing clear confirmation dialog")
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")
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
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)
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}")
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")
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}")
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)
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))
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))
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")
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")
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")
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}")
824 def _start_file_watcher(self):
825 """Start watching for new OpenHCS log files."""
826 if not self.base_log_path:
827 return
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
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}")
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
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)
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
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
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}")
870 # Use debouncing to handle rapid successive additions
871 self._debounced_log_addition(log_file_path)
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()
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()
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
892 # Store old file paths before modifying
893 old_file_paths = self.file_paths.copy()
894 new_logs = list(self._pending_logs)
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}")
902 # Sort for consistent display
903 self.file_paths = UI.sort_paths(self.file_paths)
905 # Clear pending logs
906 self._pending_logs.clear()
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)
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)]
918 if not tui_logs:
919 logger.warning("No TUI main log found, cannot clear logs safely")
920 return
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]
926 if not logs_to_remove:
927 logger.info("No logs to remove")
928 return
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
936 tui_log_name = Path(tui_log_to_keep).name
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
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")
953 with Horizontal(classes="dialog-buttons"):
954 yield Button("Cancel", id="cancel", compact=True)
955 yield Button("Clear All", id="clear", compact=True)
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()
963 def handle_confirmation(result):
964 if result:
965 self._clear_all_logs_except_tui()
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
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...")
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)]
979 if not tui_logs:
980 logger.warning("No TUI main log found, keeping all logs")
981 return
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]
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]}")
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}")
1001 # Clean up resources for logs being removed
1002 self._cleanup_removed_logs(logs_to_remove)
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]
1008 # Update UI
1009 self.call_after_refresh(self.update_file_paths, self.file_paths, old_file_paths)
1011 logger.info("Log clearing completed")
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))
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])
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}")
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}")
1051 # Remove the tab pane
1052 tab_pane.remove()
1053 logger.debug(f"Removed tab pane")
1055 except Exception as e:
1056 logger.warning(f"Error cleaning up tab resources: {e}")
1058 logger.debug(f"Cleaned up resources for {len(tabs_to_remove)} tabs")
1060 except Exception as e:
1061 logger.error(f"Error during resource cleanup: {e}")