Source code for build_tools.pipeline_tui.screens.file_selector

"""
File selector screen for Pipeline TUI.

This module provides a modal screen for selecting specific files from a directory.
Users can browse to a directory, see matching files, and select/deselect individual
files or use select all/none for batch operations.

**Features:**

- Directory tree navigation to find source folders
- File list with checkboxes for selection
- Select All / Select None buttons
- File pattern filtering
- Summary of selected files count

**Example Usage:**

.. code-block:: python

    from build_tools.pipeline_tui.screens.file_selector import FileSelectorScreen

    result = await self.app.push_screen_wait(
        FileSelectorScreen(
            initial_dir=Path("_working/codex"),
            file_pattern="*.txt",
        )
    )
    if result:
        selected_files = result  # List[Path]
        print(f"Selected {len(selected_files)} files")
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from textual import on
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Button, Checkbox, DirectoryTree, Label, Static, Tree

if TYPE_CHECKING:
    from textual.app import ComposeResult


[docs] class FileSelectorScreen(ModalScreen[list[Path] | None]): """ Modal screen for selecting multiple files from a directory. Displays a directory browser and a file list with checkboxes. When user navigates to a directory, shows all files matching the pattern with checkboxes for selection. Attributes: initial_dir: Starting directory for browser file_pattern: Glob pattern for filtering files (default: "*.txt") current_dir: Currently browsed directory selected_files: Set of selected file paths Returns: List of selected Path objects, or None if cancelled """ BINDINGS = [ ("escape", "cancel", "Cancel"), ("enter", "confirm", "Confirm"), ("a", "select_all", "Select All"), ("n", "select_none", "Select None"), ("j", "cursor_down", "Down"), ("k", "cursor_up", "Up"), ("h", "cursor_left", "Collapse"), ("l", "cursor_right", "Expand"), ("space", "toggle_node", "Toggle"), ] CSS = """ FileSelectorScreen { align: center middle; } #file-selector-container { width: 100; height: 40; background: $panel; border: thick $primary; padding: 1; } #selector-header { text-align: center; text-style: bold; color: $accent; margin-bottom: 1; } #main-panels { width: 100%; height: 1fr; } #dir-panel { width: 40%; height: 100%; border: solid $primary; padding: 0 1; } #file-panel { width: 60%; height: 100%; border: solid $primary; padding: 0 1; } .panel-header { text-style: bold; color: $text; margin-bottom: 1; } #directory-tree { width: 100%; height: 1fr; } #file-list { width: 100%; height: 1fr; } .file-checkbox { margin: 0; padding: 0; } #status-bar { height: 2; width: 100%; color: $text-muted; border-top: solid $primary; padding: 0 1; margin-top: 1; } #button-bar { width: 100%; height: auto; align: center middle; margin-top: 1; } #button-bar Button { margin: 0 1; } .empty-message { color: $text-muted; text-align: center; padding: 2; } """ def __init__( self, initial_dir: Path | None = None, file_pattern: str = "*.txt", title: str = "Select Files", root_dir: Path | None = None, ) -> None: """ Initialize file selector screen. Args: initial_dir: Starting directory for browser (default: home) file_pattern: Glob pattern for files (default: "*.txt") title: Header title for the screen root_dir: Root directory for tree navigation (default: home). Set higher than initial_dir to allow navigating up. """ super().__init__() self.initial_dir = initial_dir or Path.home() self.root_dir = root_dir or Path.home() self.file_pattern = file_pattern self.title_text = title self.current_dir: Path | None = None self.selected_files: set[Path] = set() self._file_checkboxes: dict[Path, Checkbox] = {}
[docs] def compose(self) -> ComposeResult: """Compose the file selector UI.""" with Container(id="file-selector-container"): yield Label(self.title_text, id="selector-header") with Horizontal(id="main-panels"): # Left panel: Directory browser (use root_dir to allow navigating up) with Vertical(id="dir-panel"): yield Label("Directory", classes="panel-header") yield DirectoryTree(str(self.root_dir), id="directory-tree") # Right panel: File list with checkboxes with Vertical(id="file-panel"): yield Label(f"Files ({self.file_pattern})", classes="panel-header") with VerticalScroll(id="file-list"): yield Static( "Navigate to a directory to see files", classes="empty-message", id="file-list-placeholder", ) # Status bar yield Static("No files selected", id="status-bar") # Buttons with Horizontal(id="button-bar"): yield Button("All", variant="default", id="select-all-button") yield Button("None", variant="default", id="select-none-button") yield Button("Select", variant="primary", id="select-button", disabled=True) yield Button("Cancel", variant="default", id="cancel-button")
async def _update_file_list(self, directory: Path) -> None: """ Update the file list to show files from the given directory. Args: directory: Directory to list files from """ self.current_dir = directory file_list = self.query_one("#file-list", VerticalScroll) # Clear existing content (must await to ensure children are removed) self._file_checkboxes.clear() await file_list.remove_children() # Get matching files try: files = sorted(directory.glob(self.file_pattern)) files = [f for f in files if f.is_file()] except Exception: files = [] if not files: file_list.mount( Static( f"No {self.file_pattern} files in this directory", classes="empty-message", ) ) return # Create checkboxes for each file for file_path in files: # Check if this file was previously selected is_selected = file_path in self.selected_files checkbox = Checkbox( file_path.name, value=is_selected, classes="file-checkbox", id=f"file-{hash(file_path)}", ) self._file_checkboxes[file_path] = checkbox file_list.mount(checkbox) self._update_status() def _update_status(self) -> None: """Update the status bar with current selection count.""" count = len(self.selected_files) status = self.query_one("#status-bar", Static) select_button = self.query_one("#select-button", Button) if count == 0: status.update("No files selected") select_button.disabled = True elif count == 1: status.update("1 file selected") select_button.disabled = False else: status.update(f"{count} files selected") select_button.disabled = False
[docs] @on(DirectoryTree.DirectorySelected) async def directory_selected(self, event: DirectoryTree.DirectorySelected) -> None: """Handle directory selection in tree.""" await self._update_file_list(Path(event.path))
[docs] @on(Tree.NodeExpanded) async def node_expanded(self, event: Tree.NodeExpanded) -> None: """Handle directory expansion - also load files.""" node = event.node if node.data and hasattr(node.data, "path"): path = Path(node.data.path) if path.is_dir(): await self._update_file_list(path)
[docs] @on(Checkbox.Changed) def checkbox_changed(self, event: Checkbox.Changed) -> None: """Handle file checkbox toggle.""" # Find which file this checkbox belongs to for file_path, checkbox in self._file_checkboxes.items(): if checkbox is event.checkbox: if event.value: self.selected_files.add(file_path) else: self.selected_files.discard(file_path) break self._update_status()
[docs] @on(Button.Pressed, "#select-all-button") def select_all_pressed(self) -> None: """Select all visible files.""" for file_path, checkbox in self._file_checkboxes.items(): checkbox.value = True self.selected_files.add(file_path) self._update_status()
[docs] @on(Button.Pressed, "#select-none-button") def select_none_pressed(self) -> None: """Deselect all visible files.""" for file_path, checkbox in self._file_checkboxes.items(): checkbox.value = False self.selected_files.discard(file_path) self._update_status()
[docs] @on(Button.Pressed, "#select-button") def select_pressed(self) -> None: """Confirm selection and dismiss.""" if self.selected_files: self.dismiss(sorted(self.selected_files))
[docs] @on(Button.Pressed, "#cancel-button") def cancel_pressed(self) -> None: """Cancel and dismiss.""" self.dismiss(None)
[docs] def action_cancel(self) -> None: """Cancel action (escape key).""" self.dismiss(None)
[docs] def action_confirm(self) -> None: """Confirm action (enter key).""" if self.selected_files: self.dismiss(sorted(self.selected_files))
[docs] def action_select_all(self) -> None: """Select all files (a key).""" self.select_all_pressed()
[docs] def action_select_none(self) -> None: """Deselect all files (n key).""" self.select_none_pressed()
# ------------------------------------------------------------------------- # Vim-style navigation actions # -------------------------------------------------------------------------
[docs] async def on_mount(self) -> None: """ Handle screen mount event. If initial_dir differs from root_dir, expand the tree to show initial_dir after a brief delay. """ if self.initial_dir != self.root_dir: self.set_timer(0.1, self._expand_to_initial_dir)
async def _expand_to_initial_dir(self) -> None: """Expand tree nodes from root to initial_dir.""" tree = self.query_one("#directory-tree", DirectoryTree) try: relative_parts = self.initial_dir.relative_to(self.root_dir).parts except ValueError: return current_node = tree.root if not current_node.is_expanded: current_node.expand() for part in relative_parts: child_node = None for child in current_node.children: if child.data and hasattr(child.data, "path"): child_path = Path(child.data.path) if child_path.name == part: if not child.is_expanded: child.expand() child_node = child break if child_node: current_node = child_node else: break if current_node and current_node != tree.root: tree.select_node(current_node) if current_node.data and hasattr(current_node.data, "path"): path = Path(current_node.data.path) if path.is_dir(): await self._update_file_list(path)
[docs] def action_cursor_down(self) -> None: """Move cursor down in directory tree (j key).""" tree = self.query_one("#directory-tree", DirectoryTree) tree.action_cursor_down()
[docs] def action_cursor_up(self) -> None: """Move cursor up in directory tree (k key).""" tree = self.query_one("#directory-tree", DirectoryTree) tree.action_cursor_up()
[docs] def action_cursor_left(self) -> None: """Collapse directory in tree (h key).""" tree = self.query_one("#directory-tree", DirectoryTree) tree.action_cursor_left() # type: ignore[attr-defined]
[docs] def action_cursor_right(self) -> None: """Expand directory in tree (l key).""" tree = self.query_one("#directory-tree", DirectoryTree) tree.action_cursor_right() # type: ignore[attr-defined]
[docs] def action_toggle_node(self) -> None: """Toggle expand/collapse of current node (space key).""" tree = self.query_one("#directory-tree", DirectoryTree) if tree.cursor_node: tree.cursor_node.toggle()