Coverage for openhcs/textual_tui/windows/file_browser_window.py: 0.0%
589 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"""
2Enhanced file browser using textual-universal-directorytree with OpenHCS FileManager.
4This provides a more robust file browser experience using the mature
5textual-universal-directorytree widget adapted for OpenHCS backends.
6"""
8import logging
9from pathlib import Path
10from typing import Optional, Set, List, Dict, Callable
11from enum import Enum
13from textual import on
14from textual.app import ComposeResult
15from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, VerticalScroll
16from textual.widgets import Button, DirectoryTree, Static, Checkbox, Input
18from openhcs.constants.constants import Backend
19from openhcs.io.filemanager import FileManager
20from openhcs.textual_tui.adapters.universal_directorytree import OpenHCSDirectoryTree
21from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow
22from openhcs.core.path_cache import PathCacheKey
23from openhcs.textual_tui.services.file_browser_service import SelectionMode
25logger = logging.getLogger(__name__)
28class BrowserMode(Enum):
29 """Browser operation mode."""
30 LOAD = "load"
31 SAVE = "save"
34class FileBrowserWindow(BaseOpenHCSWindow):
35 """
36 Enhanced file browser window using OpenHCS DirectoryTree adapter with textual-window system.
38 This provides a more robust file browsing experience using the mature
39 textual-universal-directorytree widget adapted to work with OpenHCS's
40 FileManager backend system.
42 Features:
43 - Single and multi-selection modes (multi-selection is opt-in)
44 - Inline folder creation and editing
45 - Backend-agnostic file operations through FileManager
46 - Custom click behaviors (left: select, right: multi-select if enabled, double: navigate)
47 """
49 DEFAULT_CSS = """
50 FileBrowserWindow {
51 width: 80; height: 30;
52 min-width: 60; min-height: 25;
53 }
54 FileBrowserWindow #content_pane {
55 padding: 0; /* Remove padding for compact layout */
56 }
58 /* Bottom area should have minimal height */
59 FileBrowserWindow #bottom_area {
60 height: auto;
61 max-height: 10; /* Slightly more space for horizontal buttons + selection area */
62 }
64 /* Buttons panel - single horizontal row */
65 FileBrowserWindow #buttons_panel {
66 height: 1; /* Exactly 1 row */
67 width: 100%;
68 align: center middle;
69 }
71 /* Buttons should be very compact */
72 FileBrowserWindow #buttons_panel Button {
73 width: auto; /* Auto-size buttons to content */
74 min-width: 4; /* Very small minimum width */
75 padding: 0; /* No padding for maximum compactness */
76 }
78 /* Checkbox should also be very compact */
79 FileBrowserWindow #buttons_panel Checkbox {
80 width: auto; /* Auto-size checkbox */
81 padding: 0; /* No padding */
82 }
84 /* Selection panel - starts at 2 rows (label + 1 for content), expands as needed (LOAD mode only) */
85 FileBrowserWindow #selection_panel {
86 width: 100%; /* Full width */
87 height: 2; /* Start at 2 rows (1 for label + 1 for content) */
88 max-height: 5; /* Maximum 5 rows (1 for label + 4 for list) */
89 }
91 /* Selections label - compact and left-aligned */
92 FileBrowserWindow #selections_label {
93 height: 1; /* Exactly 1 row for label */
94 text-align: left;
95 padding: 0;
96 margin: 0;
97 }
99 /* Selection list should start at 1 row and expand when needed */
100 FileBrowserWindow #selected_list {
101 height: 1; /* Start at exactly 1 row */
102 max-height: 4; /* Maximum 4 rows for the list itself */
103 text-align: left; /* Ensure container is left-aligned */
104 content-align: left top; /* Force content alignment to left */
105 align: left top; /* Additional alignment property */
106 }
108 /* Selected display text should be left-aligned */
109 FileBrowserWindow #selected_display {
110 text-align: left;
111 content-align: left top; /* Force content alignment to left */
112 align: left top; /* Additional alignment property */
113 padding: 0;
114 margin: 0;
115 width: 100%; /* Ensure full width */
116 }
118 /* Path area - horizontal layout for label + input */
119 FileBrowserWindow #path_area {
120 height: 1;
121 width: 100%;
122 margin: 0;
123 padding: 0;
124 }
126 /* Path label styling */
127 FileBrowserWindow .path-label {
128 width: auto;
129 min-width: 6;
130 text-align: left;
131 padding: 0 1 0 0;
132 margin: 0;
133 }
135 /* Path input should be minimal and editable */
136 FileBrowserWindow #path_input {
137 height: 1;
138 width: 1fr;
139 margin: 0;
140 padding: 0;
141 }
143 /* Filename area should have explicit height and styling */
144 FileBrowserWindow #filename_area {
145 height: 3;
146 width: 100%;
147 margin: 0;
148 padding: 0;
149 }
151 /* Filename label styling */
152 FileBrowserWindow .filename-label {
153 width: auto;
154 min-width: 10;
155 text-align: left;
156 padding: 0 1 0 0;
157 }
159 /* Filename input styling */
160 FileBrowserWindow #filename_input {
161 width: 1fr;
162 height: 1;
163 }
165 /* Folder editing container - inline style aligned with tree folders */
166 FileBrowserWindow #folder_edit_container {
167 layer: overlay;
168 width: 50;
169 height: 1;
170 background: $surface;
171 align: left top;
172 offset: 4 6; /* Align with tree folder indentation */
173 padding: 0;
174 }
176 FileBrowserWindow .edit-help {
177 text-align: left;
178 text-style: dim;
179 height: 1;
180 }
182 FileBrowserWindow #folder_edit_input {
183 width: 1fr;
184 height: 1;
185 }
186 """
188 def __init__(
189 self,
190 file_manager: FileManager,
191 initial_path: Path,
192 backend: Backend = Backend.DISK,
193 title: str = "Select Directory",
194 mode: BrowserMode = BrowserMode.LOAD,
195 selection_mode: SelectionMode = SelectionMode.DIRECTORIES_ONLY,
196 filter_extensions: Optional[List[str]] = None,
197 default_filename: str = "",
198 cache_key: Optional[PathCacheKey] = None,
199 on_result_callback: Optional[Callable] = None,
200 caller_id: str = "unknown",
201 enable_multi_selection: bool = False,
202 **kwargs
203 ):
204 # Create unique window ID based on caller to avoid conflicts
205 unique_window_id = f"file_browser_{caller_id}"
207 # Use unique window ID - textual-window expects consistent IDs per caller
208 super().__init__(
209 window_id=unique_window_id,
210 title=title,
211 mode="temporary",
212 **kwargs
213 )
215 self.file_manager = file_manager
216 self.initial_path = initial_path
217 self.backend = backend
218 self.browser_title = title
219 self.mode = mode
220 self.selection_mode = selection_mode
221 self.filter_extensions = filter_extensions
222 self.default_filename = default_filename
223 self.cache_key = cache_key
224 self.on_result_callback = on_result_callback
225 self.enable_multi_selection = enable_multi_selection
226 self.selected_path: Optional[Path] = None
227 self.selected_paths: Set[Path] = set() # For multi-selection
229 # Path caching for performance
230 self.path_cache: Dict[str, List[Path]] = {}
232 # Hidden files toggle
233 self.show_hidden_files = False
235 # Create OpenHCS DirectoryTree
236 self.directory_tree = OpenHCSDirectoryTree(
237 filemanager=file_manager,
238 backend=backend,
239 path=initial_path,
240 show_hidden=self.show_hidden_files,
241 filter_extensions=self.filter_extensions,
242 enable_multi_selection=self.enable_multi_selection,
243 id='tree_panel'
244 )
246 logger.debug(f"FileBrowserWindow created for {backend.value} at {initial_path}")
250 def compose(self) -> ComposeResult:
251 """Compose the enhanced file browser content."""
252 with Vertical():
253 # Path input with label (fixed height at top) - editable path field
254 with Horizontal(id="path_area"):
255 yield Static("Path:", classes="path-label")
256 yield Input(
257 value=str(self.initial_path),
258 placeholder="Enter path...",
259 id="path_input",
260 compact=True
261 )
263 # Directory tree - scrollable area (this should expand to fill remaining space)
264 with ScrollableContainer(id="tree_area"):
265 yield self.directory_tree
267 # Filename input for save mode - horizontal layout (fixed height)
268 if self.mode == BrowserMode.SAVE:
269 logger.debug(f"🔍 SAVE MODE: Rendering filename input area with default: '{self.default_filename}'")
270 with Horizontal(id="filename_area"):
271 yield Static("Filename:", classes="filename-label")
272 yield Input(
273 placeholder="Enter filename...",
274 value=self.default_filename,
275 id="filename_input",
276 compact=True
277 )
279 # Bottom area: buttons on top, selection area below (fixed height at bottom)
280 with Vertical(id="bottom_area"):
281 # All buttons in single horizontal row with compact spacing
282 with Horizontal(id="buttons_panel", classes="dialog-buttons"):
283 yield Button("🏠 Home", id="go_home", compact=True)
284 yield Button("⬆️ Up", id="go_up", compact=True)
286 # Mode-specific buttons
287 if self.mode == BrowserMode.LOAD:
288 # Only show Add/Remove buttons if multi-selection is enabled
289 if self.enable_multi_selection:
290 yield Button("Add", id="add_current", compact=True)
291 yield Button("Remove", id="remove_selected", compact=True)
292 yield Button("📁 New", id="new_folder", compact=True)
293 yield Button("Select", id="select_all", compact=True)
294 else: # SAVE mode
295 yield Button("Save", id="save_file", compact=True)
296 yield Button("📁 New", id="new_folder", compact=True)
298 yield Checkbox(
299 label="Hidden",
300 value=self.show_hidden_files,
301 id="show_hidden_checkbox",
302 compact=True
303 )
304 yield Button("Cancel", id="cancel", compact=True)
306 # Selection panel below buttons (only for LOAD mode with multi-selection enabled)
307 if self.mode == BrowserMode.LOAD and self.enable_multi_selection:
308 with Vertical(id="selection_panel"):
309 # Add "Selections:" label
310 yield Static("Selections:", id="selections_label")
311 with ScrollableContainer(id="selected_list"):
312 yield Static("(none)", id="selected_display")
314 def on_button_pressed(self, event: Button.Pressed) -> None:
315 """Handle button presses."""
316 button_id = event.button.id
317 logger.debug(f"🔍 BUTTON PRESSED: {button_id}")
319 if button_id == "go_home":
320 self._handle_go_home()
321 elif button_id == "go_up":
322 self._handle_go_up()
323 elif button_id == "add_current":
324 logger.debug("🔍 ADD BUTTON: Calling _handle_add_current")
325 self._handle_add_current()
326 elif button_id == "remove_selected":
327 self._handle_remove_selected()
328 elif button_id == "new_folder":
329 self._handle_new_folder()
330 elif button_id == "select_all":
331 result = self._handle_select_all()
332 self._finish_with_result(result)
333 elif button_id == "save_file":
334 result = self._handle_save_file()
335 if result is not False: # False means don't dismiss
336 self._finish_with_result(result)
337 elif button_id == "cancel":
338 self._finish_with_result(None)
340 def _finish_with_result(self, result):
341 """Finish the dialog with a result."""
342 import logging
343 logger = logging.getLogger(__name__)
345 # Log result for debugging (only when result exists)
346 if result is not None:
347 logger.debug(f"File browser returning: {result}")
349 # Cache the path if successful
350 if result is not None and self.cache_key is not None:
351 self._cache_successful_path(result)
353 # Call the callback if provided
354 if self.on_result_callback:
355 self.on_result_callback(result)
356 else:
357 logger.debug("No callback provided to file browser")
359 # Close the window
360 self.close_window()
362 def _cache_successful_path(self, result):
363 """Cache the successful path selection."""
364 from openhcs.core.path_cache import get_path_cache
366 try:
367 path_cache = get_path_cache()
368 cache_path = None
370 if isinstance(result, Path):
371 # Single path result - cache its parent directory
372 cache_path = result.parent if result.is_file() else result
373 elif isinstance(result, list) and result:
374 # List of paths - cache the parent of the first path
375 first_path = result[0]
376 if isinstance(first_path, Path):
377 cache_path = first_path.parent if first_path.is_file() else first_path
379 # Cache the path if we determined one
380 if cache_path and cache_path.exists():
381 path_cache.set_cached_path(self.cache_key, cache_path)
382 logger.debug(f"Cached path for {self.cache_key.value}: {cache_path}")
384 except Exception as e:
385 logger.warning(f"Failed to cache path: {e}")
387 def on_mount(self) -> None:
388 """Called when the screen is mounted."""
389 # Set initial border title
390 self.directory_tree.border_title = f"Path: {self.initial_path}"
392 # Set initial border title for selected panel (LOAD mode only)
393 if self.mode == BrowserMode.LOAD:
394 try:
395 selection_panel = self.query_one("#selection_panel", Vertical)
396 selection_panel.border_title = "Selected:"
397 except Exception:
398 pass # Widget might not be mounted yet
400 # Focus the directory tree for keyboard navigation
401 self.directory_tree.focus()
403 @on(DirectoryTree.DirectorySelected)
404 def on_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None:
405 """Handle directory selection from tree."""
406 logger.debug(f"🔍 DIRECTORY SELECTED: {event.path}")
408 # Store selected path - ensure it's always a Path object
409 if hasattr(event.path, '_path'):
410 # OpenHCSPathAdapter
411 self.selected_path = Path(event.path._path)
412 elif isinstance(event.path, Path):
413 self.selected_path = event.path
414 else:
415 # Convert string or other types to Path
416 self.selected_path = Path(str(event.path))
418 # Update path input
419 self._update_path_display(self.selected_path)
421 # For save mode, update filename input when directory is selected
422 # This allows users to see the directory name as a potential filename base
423 if self.mode == BrowserMode.SAVE:
424 self._update_filename_from_selection(self.selected_path)
426 logger.debug(f"🔍 STORED selected_path: {self.selected_path} (type: {type(self.selected_path)})")
428 @on(Input.Submitted)
429 def on_path_input_submitted(self, event: Input.Submitted) -> None:
430 """Handle path input submission to navigate to entered path."""
431 if event.input.id == "path_input":
432 entered_path = event.input.value.strip()
433 if entered_path:
434 try:
435 new_path = Path(entered_path).resolve()
437 # Check if path exists and is accessible
438 if self.file_manager.exists(new_path, self.backend.value):
439 # Check if it's a directory
440 if self.file_manager.is_dir(new_path, self.backend.value):
441 self._navigate_to_path(new_path)
442 logger.debug(f"🔍 NAVIGATED via path input to: {new_path}")
443 else:
444 # It's a file, navigate to its parent directory
445 parent_path = new_path.parent
446 self._navigate_to_path(parent_path)
447 logger.debug(f"🔍 NAVIGATED via path input to parent of file: {parent_path}")
448 else:
449 # Path doesn't exist, revert to current path
450 current_path = self.selected_path or self.initial_path
451 event.input.value = str(current_path)
452 logger.warning(f"🔍 PATH NOT FOUND: {new_path}, reverted to {current_path}")
454 except Exception as e:
455 # Invalid path, revert to current path
456 current_path = self.selected_path or self.initial_path
457 event.input.value = str(current_path)
458 logger.warning(f"🔍 INVALID PATH: {entered_path}, error: {e}, reverted to {current_path}")
460 @on(DirectoryTree.FileSelected)
461 def on_file_selected(self, event: DirectoryTree.FileSelected) -> None:
462 """Handle file selection from tree."""
463 logger.debug(f"File selected event: {event.path} (selection_mode: {self.selection_mode})")
465 # Always store the selected file path for save mode filename updates
466 if hasattr(event.path, '_path'):
467 # OpenHCSPathAdapter
468 selected_file_path = Path(event.path._path)
469 elif isinstance(event.path, Path):
470 selected_file_path = event.path
471 else:
472 # Convert string or other types to Path
473 selected_file_path = Path(str(event.path))
475 # For save mode, always update filename input with selected file name
476 # This works regardless of selection_mode since we want filename suggestions
477 if self.mode == BrowserMode.SAVE:
478 self._update_filename_from_selection(selected_file_path)
480 # Store selected path only if selection mode allows files
481 if self.selection_mode in [SelectionMode.FILES_ONLY, SelectionMode.BOTH]:
482 self.selected_path = selected_file_path
483 logger.info(f"✅ FILE STORED: {self.selected_path} (type: {type(self.selected_path)})")
484 else:
485 logger.info(f"❌ FILE IGNORED: selection_mode {self.selection_mode} only allows directories")
487 @on(OpenHCSDirectoryTree.AddToSelectionList)
488 def on_add_to_selection_list(self, event: OpenHCSDirectoryTree.AddToSelectionList) -> None:
489 """Handle adding multiple folders to selection list via double-click."""
490 logger.info(f"🔍 ADD TO SELECTION LIST: {len(event.paths)} folders")
492 for path in event.paths:
493 try:
494 # Check if path is compatible with selection mode
495 is_dir = self.file_manager.is_dir(path, self.backend.value)
497 if self.selection_mode == SelectionMode.DIRECTORIES_ONLY and not is_dir:
498 logger.info(f"❌ SKIPPED: Cannot add file in DIRECTORIES_ONLY mode: {path}")
499 continue
500 if self.selection_mode == SelectionMode.FILES_ONLY and is_dir:
501 logger.info(f"❌ SKIPPED: Cannot add directory in FILES_ONLY mode: {path}")
502 continue
504 # Add to selection list if not already present
505 if path not in self.selected_paths:
506 self.selected_paths.add(path)
507 logger.info(f"✅ ADDED TO LIST: {path}")
508 else:
509 logger.info(f"⚠️ ALREADY IN LIST: {path}")
511 except Exception as e:
512 logger.error(f"❌ ERROR adding path to list: {path}, error: {e}")
514 # Update the selection display
515 self._update_selected_display()
516 logger.debug(f"🔍 SELECTION LIST UPDATED: Total {len(self.selected_paths)} items")
518 @on(OpenHCSDirectoryTree.NavigateToFolder)
519 def on_navigate_to_folder(self, event: OpenHCSDirectoryTree.NavigateToFolder) -> None:
520 """Handle double-click navigation into a folder."""
521 logger.debug(f"🔍 NAVIGATE TO FOLDER: {event.path}")
523 try:
524 # Verify the path is a directory
525 if not self.file_manager.is_dir(event.path, self.backend.value):
526 logger.warning(f"❌ Cannot navigate to non-directory: {event.path}")
527 return
529 # Navigate to the new folder
530 self._navigate_to_path(event.path)
532 except Exception as e:
533 logger.error(f"❌ ERROR navigating to {event.path}: {e}")
535 @on(OpenHCSDirectoryTree.SelectFile)
536 def on_select_file(self, event: OpenHCSDirectoryTree.SelectFile) -> None:
537 """Handle double-click file selection - equivalent to highlight + Select button."""
538 logger.debug(f"🔍 SELECT FILE: {event.path}")
540 try:
541 # Verify the path is a file (not a directory)
542 if self.file_manager.is_dir(event.path, self.backend.value):
543 logger.warning(f"❌ Cannot select directory as file: {event.path}")
544 return
546 # Set the selected path (this highlights it)
547 self.selected_path = event.path
548 self._update_path_display(event.path.parent) # Update path display to parent directory
550 # For save mode, update filename input with selected file name
551 if self.mode == BrowserMode.SAVE:
552 self._update_filename_from_selection(event.path)
553 # In save mode, don't auto-close on double-click - just populate filename
554 logger.debug(f"✅ FILENAME POPULATED: {event.path.name}")
555 return
557 # For load mode, immediately trigger the select action (equivalent to clicking Select button)
558 result = self._handle_select_all()
559 if result is not None:
560 logger.debug(f"✅ FILE SELECTED: {event.path}")
561 self._finish_with_result(result)
562 else:
563 logger.warning(f"❌ FILE SELECTION FAILED: {event.path}")
565 except Exception as e:
566 logger.error(f"❌ ERROR selecting file {event.path}: {e}")
568 @on(Input.Submitted)
569 def on_folder_edit_submitted(self, event: Input.Submitted) -> None:
570 """Handle Enter key during folder name editing."""
571 if event.input.id == "folder_edit_input":
572 self._finish_folder_editing(event.value)
574 @on(Input.Blurred)
575 def on_folder_edit_blurred(self, event: Input.Blurred) -> None:
576 """Handle focus loss during folder name editing."""
577 if event.input.id == "folder_edit_input":
578 self._finish_folder_editing(event.value)
580 def _finish_folder_editing(self, new_name: str) -> None:
581 """Complete the folder editing process."""
582 logger.debug(f"🔍 FINISH EDIT: Completing folder edit with name '{new_name}'")
584 try:
585 # Remove the editing container
586 edit_container = self.query_one("#folder_edit_container", Container)
587 edit_container.remove()
589 # Check if we have editing state
590 if not hasattr(self, 'editing_folder_path'):
591 logger.warning("❌ No editing state found")
592 return
594 old_path = self.editing_folder_path
595 original_name = self.editing_original_name
597 # Clean up editing state
598 delattr(self, 'editing_folder_path')
599 delattr(self, 'editing_original_name')
601 # Validate new name
602 new_name = new_name.strip()
603 if not new_name or new_name == original_name:
604 logger.debug(f"📝 EDIT CANCELLED: No change or empty name")
605 return
607 # Create new path
608 new_path = old_path.parent / new_name
610 # Check if new name already exists
611 if self.file_manager.exists(new_path, self.backend.value):
612 logger.warning(f"❌ RENAME FAILED: {new_name} already exists")
613 return
615 # Rename the folder using FileManager move operation
616 self.file_manager.move(old_path, new_path, self.backend.value)
618 logger.debug(f"✅ FOLDER RENAMED: {original_name} -> {new_name}")
620 # Refresh the tree to show the renamed folder
621 tree = self.query_one("#tree_panel", OpenHCSDirectoryTree)
622 tree.reload()
624 except Exception as e:
625 logger.error(f"❌ ERROR finishing folder edit: {e}")
627 def on_key(self, event) -> None:
628 """Handle key events, including Escape to cancel folder editing."""
629 if event.key == "escape" and hasattr(self, 'editing_folder_path'):
630 # Cancel folder editing
631 logger.debug("🔍 EDIT CANCELLED: Escape key pressed")
632 try:
633 edit_container = self.query_one("#folder_edit_container", Container)
634 edit_container.remove()
635 # Clean up editing state
636 delattr(self, 'editing_folder_path')
637 delattr(self, 'editing_original_name')
638 except Exception as e:
639 logger.error(f"❌ ERROR cancelling edit: {e}")
641 # Button handling now done through handle_button_action method
643 def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
644 """Handle checkbox changes."""
645 if event.checkbox.id == "show_hidden_checkbox":
646 self.show_hidden_files = event.value
647 # Use call_after_refresh to ensure proper async context
648 self.call_after_refresh(self._refresh_directory_tree)
649 logger.debug(f"Hidden files toggle: {self.show_hidden_files}")
651 def _refresh_directory_tree(self) -> None:
652 """Refresh directory tree with current settings."""
653 # Clear path cache when settings change
654 self.path_cache.clear()
656 # Update tree settings and reload instead of recreating
657 self.directory_tree.show_hidden = self.show_hidden_files
658 self.directory_tree.filter_extensions = self.filter_extensions
660 try:
661 # Reload the tree to apply new settings
662 self.directory_tree.reload()
663 self.directory_tree.focus()
664 except Exception as e:
665 logger.warning(f"Failed to refresh directory tree: {e}")
667 def _get_cached_paths(self, path: Path) -> Optional[List[Path]]:
668 """Get cached directory contents."""
669 cache_key = f"{path}:{self.show_hidden_files}"
670 return self.path_cache.get(cache_key)
672 def _cache_paths(self, path: Path, paths: List[Path]) -> None:
673 """Cache directory contents."""
674 cache_key = f"{path}:{self.show_hidden_files}"
675 self.path_cache[cache_key] = paths
677 # Limit cache size to prevent memory issues
678 if len(self.path_cache) > 100:
679 # Remove oldest entries (simple FIFO)
680 oldest_key = next(iter(self.path_cache))
681 del self.path_cache[oldest_key]
683 def _handle_go_home(self) -> None:
684 """Navigate to home directory."""
685 home_path = Path.home()
686 self._navigate_to_path(home_path)
687 logger.debug(f"Navigated to home: {home_path}")
689 def _handle_go_up(self) -> None:
690 """Navigate to parent directory."""
691 current_path = self.selected_path or self.initial_path
692 parent_path = current_path.parent
694 # Don't go above root
695 if parent_path != current_path:
696 self._navigate_to_path(parent_path)
697 logger.debug(f"Navigated up from {current_path} to {parent_path}")
698 else:
699 logger.debug(f"Already at root directory: {current_path}")
701 def _navigate_to_path(self, new_path: Path) -> None:
702 """Navigate to a new path and refresh the tree."""
703 self.selected_path = new_path
704 self._update_path_display(new_path)
706 # Update tree path and reload
707 try:
708 self.directory_tree.path = new_path
709 self.directory_tree.reload()
710 self.directory_tree.focus()
711 except Exception as e:
712 logger.warning(f"Failed to navigate to {new_path}: {e}")
714 def _handle_add_current(self) -> None:
715 """Add selected folders to selection list (multi-selection if enabled, otherwise single selection)."""
716 # Get the directory tree
717 tree = self.query_one("#tree_panel", OpenHCSDirectoryTree)
719 if self.enable_multi_selection:
720 # Multi-selection mode: add all folders with checkmarks
721 selected_folders = tree.multi_selected_paths
722 logger.debug(f"🔍 ADD BUTTON: Adding {len(selected_folders)} multi-selected folders, selection_mode={self.selection_mode}")
724 if not selected_folders:
725 logger.warning("❌ ADD FAILED: No folders selected (use right-click to select multiple folders)")
726 return
727 else:
728 # Single selection mode: add only the current cursor selection
729 if not self.selected_path:
730 logger.warning("❌ ADD FAILED: No folder selected")
731 return
732 selected_folders = {self.selected_path}
733 logger.debug(f"🔍 ADD BUTTON: Adding single selected folder {self.selected_path}, selection_mode={self.selection_mode}")
735 added_count = 0
736 for path in selected_folders:
737 try:
738 is_dir = self.file_manager.is_dir(path, self.backend.value)
739 item_type = "directory" if is_dir else "file"
740 logger.debug(f"🔍 ADD CHECK: {item_type} '{path.name}' in {self.selection_mode} mode")
742 # Check if this type is allowed
743 if self.selection_mode == SelectionMode.DIRECTORIES_ONLY and not is_dir:
744 logger.info(f"❌ SKIPPED: Cannot add {item_type} in DIRECTORIES_ONLY mode: {path}")
745 continue
746 if self.selection_mode == SelectionMode.FILES_ONLY and is_dir:
747 logger.info(f"❌ SKIPPED: Cannot add {item_type} in FILES_ONLY mode: {path}")
748 continue
750 # Add if not already present
751 if path not in self.selected_paths:
752 self.selected_paths.add(path)
753 added_count += 1
754 logger.info(f"✅ ADDED: {item_type} '{path.name}'")
755 else:
756 logger.info(f"⚠️ ALREADY ADDED: {item_type} '{path.name}'")
758 except Exception as e:
759 logger.error(f"❌ ERROR adding path {path}: {e}")
761 # Update the selection display
762 self._update_selected_display()
763 logger.debug(f"✅ ADD COMPLETE: Added {added_count} new items (Total: {len(self.selected_paths)})")
765 def _handle_new_folder(self) -> None:
766 """Create a new folder with in-place editable name."""
767 logger.info("🔍 NEW FOLDER: Creating new folder")
769 try:
770 # Get current directory from the tree
771 tree = self.query_one("#tree_panel", OpenHCSDirectoryTree)
772 current_dir = tree.path # This should be the current directory being viewed
774 # Generate a unique folder name
775 base_name = "New Folder"
776 counter = 1
777 new_folder_name = base_name
779 while True:
780 new_folder_path = Path(current_dir) / new_folder_name
781 if not self.file_manager.exists(new_folder_path, self.backend.value):
782 break
783 counter += 1
784 new_folder_name = f"{base_name} {counter}"
786 # Create the folder using FileManager
787 self.file_manager.ensure_directory(new_folder_path, self.backend.value)
788 logger.info(f"✅ CREATED FOLDER: {new_folder_path}")
790 # Refresh the tree to show the new folder
791 tree.reload()
793 # Start in-place editing of the new folder name
794 self._start_folder_editing(new_folder_path, new_folder_name)
796 except Exception as e:
797 logger.error(f"❌ ERROR creating new folder: {e}")
799 def _start_folder_editing(self, folder_path: Path, current_name: str) -> None:
800 """Start editing of a folder name using a simple modal approach."""
801 logger.debug(f"🔍 EDIT FOLDER: Starting folder name editing for {folder_path}")
803 try:
804 # For now, implement a simple approach - create a temporary input area
805 # Store editing state
806 self.editing_folder_path = folder_path
807 self.editing_original_name = current_name
809 # Create a simple editing container with proper Textual pattern
810 from textual.containers import Container
811 from textual.widgets import Input, Static
813 # Create widgets first - compact inline editing with folder icon
814 edit_input = Input(
815 value=current_name,
816 id="folder_edit_input",
817 placeholder="Folder name",
818 compact=True
819 )
820 # Add folder icon prefix to make it look like a tree node
821 edit_input.prefix = "📁 "
823 # Create container and mount it with just the input
824 edit_container = Container(
825 edit_input,
826 id="folder_edit_container"
827 )
829 # Mount the complete container
830 self.mount(edit_container)
832 # Focus the input and select all text
833 edit_input.focus()
834 edit_input.action_home(select=True)
835 edit_input.action_end(select=True)
837 logger.debug(f"✅ EDIT FOLDER: Folder name editing started for {current_name}")
839 except Exception as e:
840 logger.error(f"❌ ERROR starting folder editing: {e}")
842 def _handle_remove_selected(self) -> None:
843 """Remove current directory from selection."""
844 if self.selected_path and self.selected_path in self.selected_paths:
845 self.selected_paths.remove(self.selected_path)
846 self._update_selected_display()
847 logger.info(f"Removed {self.selected_path} from selection")
849 def _handle_select_all(self):
850 """
851 Return selected paths with intelligent priority system:
852 1. Selection area (explicit Add button usage) - highest priority
853 2. Multi-selected folders from tree (green checkmarks) - medium priority
854 3. Current cursor selection - lowest priority fallback
855 """
856 if self.mode == BrowserMode.SAVE:
857 return self._handle_save_file()
859 # Priority 1: Return selected paths if any exist in selections area (explicit Add button usage)
860 if self.selected_paths:
861 return list(self.selected_paths)
863 # Priority 2: Return multi-selected folders from tree (green checkmarks) if any exist
864 tree = self.query_one("#tree_panel", OpenHCSDirectoryTree)
865 if tree.multi_selected_paths:
866 # Filter multi-selected paths based on selection mode
867 valid_paths = []
868 for path in tree.multi_selected_paths:
869 try:
870 is_dir = self.file_manager.is_dir(path, self.backend.value)
872 # Check compatibility with selection mode
873 if self.selection_mode == SelectionMode.DIRECTORIES_ONLY and is_dir:
874 valid_paths.append(path)
875 elif self.selection_mode == SelectionMode.FILES_ONLY and not is_dir:
876 valid_paths.append(path)
877 elif self.selection_mode == SelectionMode.BOTH:
878 valid_paths.append(path)
880 except Exception:
881 # Skip paths we can't validate
882 continue
884 if valid_paths:
885 logger.info(f"🔍 SELECT: Using {len(valid_paths)} multi-selected folders from tree")
886 return valid_paths
888 # Priority 3: Fallback to current tree cursor selection if nothing else is selected
889 if self.selected_path:
890 try:
891 is_dir = self.file_manager.is_dir(self.selected_path, self.backend.value)
893 # Check compatibility with selection mode
894 if self.selection_mode == SelectionMode.DIRECTORIES_ONLY and is_dir:
895 logger.debug(f"🔍 SELECT: Using cursor selection {self.selected_path}")
896 return [self.selected_path]
897 elif self.selection_mode == SelectionMode.FILES_ONLY and not is_dir:
898 logger.debug(f"🔍 SELECT: Using cursor selection {self.selected_path}")
899 return [self.selected_path]
900 elif self.selection_mode == SelectionMode.BOTH:
901 logger.debug(f"🔍 SELECT: Using cursor selection {self.selected_path}")
902 return [self.selected_path]
904 except Exception:
905 pass
907 # No valid selection
908 logger.debug("🔍 SELECT: No valid selection found")
909 return None
911 def _handle_save_file(self):
912 """Handle save file operation with overwrite confirmation."""
913 try:
914 # Get filename from input
915 filename_input = self.query_one("#filename_input", Input)
916 filename = filename_input.value.strip()
918 if not filename:
919 logger.warning("No filename provided for save operation")
920 return False # Don't dismiss, show error
922 # Validate filename
923 if not self._validate_filename(filename):
924 logger.warning(f"Invalid filename: {filename}")
925 return False # Don't dismiss, show error
927 # Ensure proper extension
928 if self.filter_extensions:
929 filename = self._ensure_extension(filename, self.filter_extensions[0])
931 # Get current directory (use selected_path if it's a directory, otherwise its parent)
932 if self.selected_path:
933 try:
934 # Use FileManager to check if it's a directory (respects backend abstraction)
935 is_dir = self.file_manager.is_dir(self.selected_path, self.backend.value)
936 if is_dir:
937 save_dir = self.selected_path
938 else:
939 save_dir = self.selected_path.parent
940 except Exception:
941 # If we can't determine type, use parent directory
942 save_dir = self.selected_path.parent
943 else:
944 save_dir = self.initial_path
946 # Construct full save path
947 save_path = save_dir / filename
949 # Check if file already exists and show confirmation dialog
950 if self._file_exists(save_path):
951 self._show_overwrite_confirmation(save_path)
952 return False # Don't dismiss yet, wait for confirmation
954 logger.debug(f"Save file operation: {save_path}")
955 return save_path
957 except Exception as e:
958 logger.error(f"Error in save file operation: {e}")
959 return False # Don't dismiss, show error
961 def _update_selected_display(self) -> None:
962 """Update the selected directories display and adjust height."""
963 try:
964 display_widget = self.query_one("#selected_display", Static)
965 selection_panel = self.query_one("#selection_panel", Vertical)
966 selected_list = self.query_one("#selected_list", ScrollableContainer)
968 # Force left alignment programmatically
969 display_widget.styles.text_align = "left"
970 display_widget.styles.content_align = ("left", "top")
971 selected_list.styles.text_align = "left"
972 selected_list.styles.content_align = ("left", "top")
974 if self.selected_paths:
975 # Show files and directories with appropriate icons
976 paths_list = []
977 for path in sorted(self.selected_paths):
978 try:
979 is_dir = self.file_manager.is_dir(path, self.backend.value)
980 icon = "📁" if is_dir else "📄"
981 paths_list.append(f"{icon} {path.name}")
982 except Exception:
983 # Fallback if we can't determine type
984 paths_list.append(f"📄 {path.name}")
986 paths_text = "\n".join(paths_list)
987 display_widget.update(paths_text)
989 # Dynamically adjust height based on number of items (1-4 rows for list + 1 for label)
990 num_items = len(self.selected_paths)
991 list_height = min(max(num_items, 1), 4) # Clamp between 1 and 4 for the list
992 panel_height = list_height + 1 # Add 1 for the "Selections:" label
994 # Update the height of the selection components
995 selection_panel.styles.height = panel_height
996 selected_list.styles.height = list_height
997 else:
998 display_widget.update("(none)")
999 # Reset to minimum height when no items (1 for list + 1 for label)
1000 selection_panel.styles.height = 2 # 1 for label + 1 for "(none)"
1001 selected_list.styles.height = 1
1003 except Exception:
1004 # Widget might not be mounted yet
1005 pass
1007 def _update_path_display(self, path: Path) -> None:
1008 """Update the path input field and tree border title."""
1009 try:
1010 # Update the path input field
1011 path_input = self.query_one("#path_input", Input)
1012 path_input.value = str(path)
1014 # Set the border title on the directory tree
1015 self.directory_tree.border_title = f"Path: {path}"
1016 except Exception:
1017 # Widget might not be mounted yet
1018 pass
1020 def _update_filename_from_selection(self, selected_path: Path) -> None:
1021 """Update the filename input field based on the selected file or directory.
1023 This provides intelligent filename suggestions in save mode:
1024 - For files: Use the filename directly
1025 - For directories: Use the directory name as a base filename
1026 """
1027 if self.mode != BrowserMode.SAVE:
1028 return
1030 try:
1031 filename_input = self.query_one("#filename_input", Input)
1033 # Determine if the selected path is a file or directory
1034 try:
1035 is_dir = self.file_manager.is_dir(selected_path, self.backend.value)
1036 except Exception:
1037 # If we can't determine, assume it's a file based on extension
1038 is_dir = not selected_path.suffix
1040 if is_dir:
1041 # For directories, use the directory name as filename base
1042 suggested_name = selected_path.name
1044 # Add appropriate extension if filter is specified
1045 if self.filter_extensions and suggested_name:
1046 suggested_name = self._ensure_extension(suggested_name, self.filter_extensions[0])
1048 filename_input.value = suggested_name
1049 logger.debug(f"🔍 FILENAME UPDATED from directory: {suggested_name}")
1050 else:
1051 # For files, use the filename directly
1052 filename_input.value = selected_path.name
1053 logger.debug(f"🔍 FILENAME UPDATED from file: {selected_path.name}")
1055 except Exception as e:
1056 logger.debug(f"Failed to update filename input: {e}")
1057 # Input might not be mounted yet or other error
1059 def _ensure_extension(self, filename: str, extension: str) -> str:
1060 """Ensure filename has the correct extension."""
1061 if not extension.startswith('.'):
1062 extension = f'.{extension}'
1063 path = Path(filename)
1064 if path.suffix.lower() != extension.lower():
1065 return str(path.with_suffix(extension))
1066 return filename
1068 def _validate_filename(self, filename: str) -> bool:
1069 """Validate filename for save operations."""
1070 if not filename.strip():
1071 return False
1073 # Check for invalid characters (basic validation)
1074 invalid_chars = '<>:"/\\|?*'
1075 if any(char in filename for char in invalid_chars):
1076 return False
1078 # Check extension if filter is specified
1079 if self.filter_extensions:
1080 path = Path(filename)
1081 if path.suffix:
1082 # Has extension, check if it's allowed
1083 return any(path.suffix.lower() == ext.lower() for ext in self.filter_extensions)
1084 # No extension, will be added by _ensure_extension
1086 return True
1088 def _file_exists(self, file_path: Path) -> bool:
1089 """Check if file exists using FileManager."""
1090 try:
1091 return self.file_manager.exists(file_path, self.backend.value)
1092 except Exception:
1093 return False
1095 def _show_overwrite_confirmation(self, save_path: Path) -> None:
1096 """Show confirmation dialog for overwriting existing file."""
1097 # Create a simple confirmation window using BaseOpenHCSWindow
1098 from openhcs.textual_tui.windows.base_window import BaseOpenHCSWindow
1099 from textual.widgets import Static, Button
1100 from textual.containers import Container, Horizontal
1101 from textual.app import ComposeResult
1103 class OverwriteConfirmationWindow(BaseOpenHCSWindow):
1104 def __init__(self, save_path: Path, on_result_callback):
1105 super().__init__(
1106 window_id="overwrite_confirmation",
1107 title="Confirm Overwrite",
1108 mode="temporary"
1109 )
1110 self.save_path = save_path
1111 self.on_result_callback = on_result_callback
1113 def compose(self) -> ComposeResult:
1114 message = f"File '{self.save_path.name}' already exists.\nDo you want to overwrite it?"
1115 yield Static(message, classes="dialog-content")
1117 with Horizontal(classes="dialog-buttons"):
1118 yield Button("Yes", id="yes", compact=True)
1119 yield Button("No", id="no", compact=True)
1121 def on_button_pressed(self, event: Button.Pressed) -> None:
1122 result = event.button.id == "yes"
1123 if self.on_result_callback:
1124 self.on_result_callback(result)
1125 self.close_window()
1127 def handle_confirmation(result):
1128 """Handle the confirmation dialog result."""
1129 if result: # User clicked Yes
1130 logger.debug(f"User confirmed overwrite for: {save_path}")
1131 self._finish_with_result(save_path) # Finish with the save path (will auto-cache)
1132 # If result is False/None (No/Cancel), do nothing - stay in dialog
1134 # Create and mount confirmation window
1135 confirmation = OverwriteConfirmationWindow(save_path, handle_confirmation)
1136 self.app.run_worker(self._mount_confirmation(confirmation))
1138 async def _mount_confirmation(self, confirmation):
1139 """Mount confirmation dialog."""
1140 await self.app.mount(confirmation)
1141 confirmation.open_state = True
1144async def open_file_browser_window(
1145 app,
1146 file_manager: FileManager,
1147 initial_path: Path,
1148 backend: Backend = Backend.DISK,
1149 title: str = "Select Directory",
1150 mode: BrowserMode = BrowserMode.LOAD,
1151 selection_mode: SelectionMode = SelectionMode.DIRECTORIES_ONLY,
1152 filter_extensions: Optional[List[str]] = None,
1153 default_filename: str = "",
1154 cache_key: Optional[PathCacheKey] = None,
1155 on_result_callback: Optional[Callable] = None,
1156 caller_id: str = "unknown",
1157 enable_multi_selection: bool = False,
1158) -> FileBrowserWindow:
1159 """
1160 Convenience function to open a file browser window.
1162 This replaces the old push_screen pattern with proper textual-window mounting.
1164 Args:
1165 app: The Textual app instance
1166 file_manager: FileManager instance
1167 initial_path: Starting directory path
1168 backend: Storage backend to use
1169 title: Window title
1170 mode: LOAD or SAVE mode
1171 selection_mode: What can be selected (files/dirs/both)
1172 filter_extensions: File extensions to filter (e.g., ['.pipeline'])
1173 default_filename: Default filename for save mode
1174 cache_key: Path cache key for remembering location
1175 on_result_callback: Callback function for when selection is made
1176 caller_id: Unique identifier for the calling window/widget (e.g., "plate_manager")
1178 Returns:
1179 The created FileBrowserWindow instance
1180 """
1181 from textual.css.query import NoMatches
1183 # Follow ConfigWindow pattern exactly - check if file browser already exists for this caller
1184 unique_window_id = f"file_browser_{caller_id}"
1185 try:
1186 window = app.query_one(f"#{unique_window_id}")
1187 # Window exists, update its parameters and open it
1188 window.file_manager = file_manager
1189 window.initial_path = initial_path
1190 window.backend = backend
1191 window.mode = mode
1192 window.selection_mode = selection_mode
1193 window.filter_extensions = filter_extensions
1194 window.default_filename = default_filename
1195 window.cache_key = cache_key
1196 window.on_result_callback = on_result_callback
1197 window.title = title
1198 # Refresh the window content with new parameters
1199 window._navigate_to_path(initial_path)
1200 window.open_state = True
1201 except NoMatches:
1202 # Expected case: window doesn't exist yet, create new one
1203 window = FileBrowserWindow(
1204 file_manager=file_manager,
1205 initial_path=initial_path,
1206 backend=backend,
1207 title=title,
1208 mode=mode,
1209 selection_mode=selection_mode,
1210 filter_extensions=filter_extensions,
1211 default_filename=default_filename,
1212 cache_key=cache_key,
1213 on_result_callback=on_result_callback,
1214 caller_id=caller_id,
1215 enable_multi_selection=enable_multi_selection,
1216 )
1217 await app.mount(window) # Properly await mounting like ConfigWindow
1218 window.open_state = True
1220 return window