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

1""" 

2Enhanced file browser using textual-universal-directorytree with OpenHCS FileManager. 

3 

4This provides a more robust file browser experience using the mature 

5textual-universal-directorytree widget adapted for OpenHCS backends. 

6""" 

7 

8import logging 

9from pathlib import Path 

10from typing import Optional, Set, List, Dict, Callable 

11from enum import Enum 

12 

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 

17 

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 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28class BrowserMode(Enum): 

29 """Browser operation mode.""" 

30 LOAD = "load" 

31 SAVE = "save" 

32 

33 

34class FileBrowserWindow(BaseOpenHCSWindow): 

35 """ 

36 Enhanced file browser window using OpenHCS DirectoryTree adapter with textual-window system. 

37 

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. 

41 

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 """ 

48 

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 } 

57 

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 } 

63 

64 /* Buttons panel - single horizontal row */ 

65 FileBrowserWindow #buttons_panel { 

66 height: 1; /* Exactly 1 row */ 

67 width: 100%; 

68 align: center middle; 

69 } 

70 

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 } 

77 

78 /* Checkbox should also be very compact */ 

79 FileBrowserWindow #buttons_panel Checkbox { 

80 width: auto; /* Auto-size checkbox */ 

81 padding: 0; /* No padding */ 

82 } 

83 

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 } 

90 

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 } 

98 

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 } 

107 

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 } 

117 

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 } 

125 

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 } 

134 

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 } 

142 

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 } 

150 

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 } 

158 

159 /* Filename input styling */ 

160 FileBrowserWindow #filename_input { 

161 width: 1fr; 

162 height: 1; 

163 } 

164 

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 } 

175 

176 FileBrowserWindow .edit-help { 

177 text-align: left; 

178 text-style: dim; 

179 height: 1; 

180 } 

181 

182 FileBrowserWindow #folder_edit_input { 

183 width: 1fr; 

184 height: 1; 

185 } 

186 """ 

187 

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

206 

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 ) 

214 

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 

228 

229 # Path caching for performance 

230 self.path_cache: Dict[str, List[Path]] = {} 

231 

232 # Hidden files toggle 

233 self.show_hidden_files = False 

234 

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 ) 

245 

246 logger.debug(f"FileBrowserWindow created for {backend.value} at {initial_path}") 

247 

248 

249 

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 ) 

262 

263 # Directory tree - scrollable area (this should expand to fill remaining space) 

264 with ScrollableContainer(id="tree_area"): 

265 yield self.directory_tree 

266 

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 ) 

278 

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) 

285 

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) 

297 

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) 

305 

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

313 

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

318 

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) 

339 

340 def _finish_with_result(self, result): 

341 """Finish the dialog with a result.""" 

342 import logging 

343 logger = logging.getLogger(__name__) 

344 

345 # Log result for debugging (only when result exists) 

346 if result is not None: 

347 logger.debug(f"File browser returning: {result}") 

348 

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) 

352 

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

358 

359 # Close the window 

360 self.close_window() 

361 

362 def _cache_successful_path(self, result): 

363 """Cache the successful path selection.""" 

364 from openhcs.core.path_cache import get_path_cache 

365 

366 try: 

367 path_cache = get_path_cache() 

368 cache_path = None 

369 

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 

378 

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

383 

384 except Exception as e: 

385 logger.warning(f"Failed to cache path: {e}") 

386 

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

391 

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 

399 

400 # Focus the directory tree for keyboard navigation 

401 self.directory_tree.focus() 

402 

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

407 

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)) 

417 

418 # Update path input 

419 self._update_path_display(self.selected_path) 

420 

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) 

425 

426 logger.debug(f"🔍 STORED selected_path: {self.selected_path} (type: {type(self.selected_path)})") 

427 

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() 

436 

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

453 

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

459 

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

464 

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)) 

474 

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) 

479 

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

486 

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

491 

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) 

496 

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 

503 

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

510 

511 except Exception as e: 

512 logger.error(f"❌ ERROR adding path to list: {path}, error: {e}") 

513 

514 # Update the selection display 

515 self._update_selected_display() 

516 logger.debug(f"🔍 SELECTION LIST UPDATED: Total {len(self.selected_paths)} items") 

517 

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

522 

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 

528 

529 # Navigate to the new folder 

530 self._navigate_to_path(event.path) 

531 

532 except Exception as e: 

533 logger.error(f"❌ ERROR navigating to {event.path}: {e}") 

534 

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

539 

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 

545 

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 

549 

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 

556 

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

564 

565 except Exception as e: 

566 logger.error(f"❌ ERROR selecting file {event.path}: {e}") 

567 

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) 

573 

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) 

579 

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

583 

584 try: 

585 # Remove the editing container 

586 edit_container = self.query_one("#folder_edit_container", Container) 

587 edit_container.remove() 

588 

589 # Check if we have editing state 

590 if not hasattr(self, 'editing_folder_path'): 

591 logger.warning("❌ No editing state found") 

592 return 

593 

594 old_path = self.editing_folder_path 

595 original_name = self.editing_original_name 

596 

597 # Clean up editing state 

598 delattr(self, 'editing_folder_path') 

599 delattr(self, 'editing_original_name') 

600 

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 

606 

607 # Create new path 

608 new_path = old_path.parent / new_name 

609 

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 

614 

615 # Rename the folder using FileManager move operation 

616 self.file_manager.move(old_path, new_path, self.backend.value) 

617 

618 logger.debug(f"✅ FOLDER RENAMED: {original_name} -> {new_name}") 

619 

620 # Refresh the tree to show the renamed folder 

621 tree = self.query_one("#tree_panel", OpenHCSDirectoryTree) 

622 tree.reload() 

623 

624 except Exception as e: 

625 logger.error(f"❌ ERROR finishing folder edit: {e}") 

626 

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

640 

641 # Button handling now done through handle_button_action method 

642 

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

650 

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() 

655 

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 

659 

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

666 

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) 

671 

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 

676 

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] 

682 

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

688 

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 

693 

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

700 

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) 

705 

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

713 

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) 

718 

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

723 

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

734 

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

741 

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 

749 

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

757 

758 except Exception as e: 

759 logger.error(f"❌ ERROR adding path {path}: {e}") 

760 

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

764 

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

768 

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 

773 

774 # Generate a unique folder name 

775 base_name = "New Folder" 

776 counter = 1 

777 new_folder_name = base_name 

778 

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

785 

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

789 

790 # Refresh the tree to show the new folder 

791 tree.reload() 

792 

793 # Start in-place editing of the new folder name 

794 self._start_folder_editing(new_folder_path, new_folder_name) 

795 

796 except Exception as e: 

797 logger.error(f"❌ ERROR creating new folder: {e}") 

798 

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

802 

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 

808 

809 # Create a simple editing container with proper Textual pattern 

810 from textual.containers import Container 

811 from textual.widgets import Input, Static 

812 

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 = "📁 " 

822 

823 # Create container and mount it with just the input 

824 edit_container = Container( 

825 edit_input, 

826 id="folder_edit_container" 

827 ) 

828 

829 # Mount the complete container 

830 self.mount(edit_container) 

831 

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) 

836 

837 logger.debug(f"✅ EDIT FOLDER: Folder name editing started for {current_name}") 

838 

839 except Exception as e: 

840 logger.error(f"❌ ERROR starting folder editing: {e}") 

841 

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

848 

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() 

858 

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) 

862 

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) 

871 

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) 

879 

880 except Exception: 

881 # Skip paths we can't validate 

882 continue 

883 

884 if valid_paths: 

885 logger.info(f"🔍 SELECT: Using {len(valid_paths)} multi-selected folders from tree") 

886 return valid_paths 

887 

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) 

892 

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] 

903 

904 except Exception: 

905 pass 

906 

907 # No valid selection 

908 logger.debug("🔍 SELECT: No valid selection found") 

909 return None 

910 

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() 

917 

918 if not filename: 

919 logger.warning("No filename provided for save operation") 

920 return False # Don't dismiss, show error 

921 

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 

926 

927 # Ensure proper extension 

928 if self.filter_extensions: 

929 filename = self._ensure_extension(filename, self.filter_extensions[0]) 

930 

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 

945 

946 # Construct full save path 

947 save_path = save_dir / filename 

948 

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 

953 

954 logger.debug(f"Save file operation: {save_path}") 

955 return save_path 

956 

957 except Exception as e: 

958 logger.error(f"Error in save file operation: {e}") 

959 return False # Don't dismiss, show error 

960 

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) 

967 

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

973 

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

985 

986 paths_text = "\n".join(paths_list) 

987 display_widget.update(paths_text) 

988 

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 

993 

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 

1002 

1003 except Exception: 

1004 # Widget might not be mounted yet 

1005 pass 

1006 

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) 

1013 

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 

1019 

1020 def _update_filename_from_selection(self, selected_path: Path) -> None: 

1021 """Update the filename input field based on the selected file or directory. 

1022 

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 

1029 

1030 try: 

1031 filename_input = self.query_one("#filename_input", Input) 

1032 

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 

1039 

1040 if is_dir: 

1041 # For directories, use the directory name as filename base 

1042 suggested_name = selected_path.name 

1043 

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]) 

1047 

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

1054 

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 

1058 

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 

1067 

1068 def _validate_filename(self, filename: str) -> bool: 

1069 """Validate filename for save operations.""" 

1070 if not filename.strip(): 

1071 return False 

1072 

1073 # Check for invalid characters (basic validation) 

1074 invalid_chars = '<>:"/\\|?*' 

1075 if any(char in filename for char in invalid_chars): 

1076 return False 

1077 

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 

1085 

1086 return True 

1087 

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 

1094 

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 

1102 

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 

1112 

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

1116 

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

1118 yield Button("Yes", id="yes", compact=True) 

1119 yield Button("No", id="no", compact=True) 

1120 

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() 

1126 

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 

1133 

1134 # Create and mount confirmation window 

1135 confirmation = OverwriteConfirmationWindow(save_path, handle_confirmation) 

1136 self.app.run_worker(self._mount_confirmation(confirmation)) 

1137 

1138 async def _mount_confirmation(self, confirmation): 

1139 """Mount confirmation dialog.""" 

1140 await self.app.mount(confirmation) 

1141 confirmation.open_state = True 

1142 

1143 

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. 

1161 

1162 This replaces the old push_screen pattern with proper textual-window mounting. 

1163 

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

1177 

1178 Returns: 

1179 The created FileBrowserWindow instance 

1180 """ 

1181 from textual.css.query import NoMatches 

1182 

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 

1219 

1220 return window 

1221 

1222