diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py index a4c03c7..63d90f5 100644 --- a/src/hosts/tui/import_export_modal.py +++ b/src/hosts/tui/import_export_modal.py @@ -5,7 +5,7 @@ from pathlib import Path from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Horizontal, Vertical +from textual.containers import Grid, Horizontal, Vertical from textual.screen import ModalScreen from textual.widgets import ( Button, @@ -49,10 +49,13 @@ class _FormatModal(ModalScreen): } .format-title { text-align: center; text-style: bold; color: $primary; } .format-copy { margin-top: 1; color: $text-muted; } - #path-input { margin-top: 1; } + .form-section { margin-top: 1; } + #path-row { grid-size: 2; grid-columns: 1fr 16; height: 3; } + #path-input { width: 1fr; height: 3; margin: 0; } #workflow-error { color: $error; height: auto; margin-top: 1; } #workflow-warning { color: $warning; height: auto; margin-top: 1; } - .browse-button { width: 16; margin-top: 1; } + .browse-button { width: 16; height: 3; margin: 0; } + .format-section { margin-top: 1; padding: 0 1; border: round $primary; } .button-row { margin-top: 1; height: 3; align: center middle; } """ @@ -90,18 +93,22 @@ class ExportModal(_FormatModal): "Choose a destination and format. Existing files require confirmation.", classes="format-copy", ) - yield Label("Export file path") - yield Input(placeholder="/path/to/entries.hosts", id="path-input") - yield Button("Browse…", id="browse-button", classes="browse-button") - with RadioSet(id="format-select"): - for index, format in enumerate(self._formats): - yield RadioButton( - _format_label(format), - id=f"format-{format.value}", - value=index == 0, - ) - yield Static("", id="workflow-error") - yield Static("", id="workflow-warning") + with Vertical(classes="form-section"): + yield Label("Export file path") + with Grid(id="path-row"): + yield Input(placeholder="/path/to/entries.hosts", id="path-input") + yield Button("Browse…", id="browse-button", classes="browse-button") + yield Static("", id="workflow-error") + yield Static("", id="workflow-warning") + with Vertical(classes="format-section") as formats: + formats.border_title = "File format" + with RadioSet(id="format-select"): + for index, format in enumerate(self._formats): + yield RadioButton( + _format_label(format), + id=f"format-{format.value}", + value=index == 0, + ) with Horizontal(classes="button-row"): yield Button("Cancel", id="cancel-button") yield Button("Continue", id="continue-button", variant="primary") @@ -174,17 +181,21 @@ class ImportModal(_FormatModal): "Import replaces the current Host Entries and saves the Hosts File.", classes="format-copy", ) - yield Label("Import file path") - yield Input(placeholder="/path/to/entries.hosts", id="path-input") - yield Button("Browse…", id="browse-button", classes="browse-button") - with RadioSet(id="format-select"): - for index, format in enumerate(self._formats): - yield RadioButton( - _format_label(format), - id=f"format-{format.value}", - value=index == 0, - ) - yield Static("", id="workflow-error") + with Vertical(classes="form-section"): + yield Label("Import file path") + with Grid(id="path-row"): + yield Input(placeholder="/path/to/entries.hosts", id="path-input") + yield Button("Browse…", id="browse-button", classes="browse-button") + yield Static("", id="workflow-error") + with Vertical(classes="format-section") as formats: + formats.border_title = "File format" + with RadioSet(id="format-select"): + for index, format in enumerate(self._formats): + yield RadioButton( + _format_label(format), + id=f"format-{format.value}", + value=index == 0, + ) with Horizontal(classes="button-row"): yield Button("Cancel", id="cancel-button") yield Button("Import and save", id="import-button", variant="primary") @@ -226,7 +237,10 @@ class ImportModal(_FormatModal): class FileBrowserModal(ModalScreen[Path | None]): """A keyboard-accessible directory tree for file and folder selection.""" - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("u", "go_up", "Up directory"), + ] CSS = """ FileBrowserModal { align: center middle; } #file-browser-container { @@ -242,6 +256,7 @@ class FileBrowserModal(ModalScreen[Path | None]): def __init__(self, start_path: Path, *, choose_directory: bool): super().__init__() self._start_path = start_path if start_path.is_dir() else Path.cwd() + self._current_path = self._start_path self._choose_directory = choose_directory self._selected_directory: Path | None = None @@ -260,6 +275,7 @@ class FileBrowserModal(ModalScreen[Path | None]): yield Static(str(self._start_path), id="file-browser-status") yield DirectoryTree(self._start_path, id="file-browser-tree") with Horizontal(classes="button-row"): + yield Button("Up", id="up-button") yield Button("Cancel", id="cancel-button") if self._choose_directory: yield Button( @@ -288,7 +304,9 @@ class FileBrowserModal(ModalScreen[Path | None]): self.query_one("#file-browser-status", Static).update(str(event.path)) def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "cancel-button": + if event.button.id == "up-button": + self.action_go_up() + elif event.button.id == "cancel-button": self.action_cancel() elif event.button.id == "choose-directory-button": self.dismiss(self._selected_directory or self._start_path) @@ -296,6 +314,19 @@ class FileBrowserModal(ModalScreen[Path | None]): def action_cancel(self) -> None: self.dismiss(None) + def action_go_up(self) -> None: + """Re-root the tree at the parent of the current directory.""" + parent = self._current_path.parent + if parent == self._current_path: + self.query_one("#file-browser-status", Static).update( + "Already at the filesystem root." + ) + return + self._current_path = parent + self._selected_directory = None + self.query_one("#file-browser-tree", DirectoryTree).path = parent + self.query_one("#file-browser-status", Static).update(str(parent)) + def _format_label(format: ExportFormat | ImportFormat) -> str: """Give the core-supported formats concise, file-oriented labels.""" diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py index 664b9cf..5e20471 100644 --- a/tests/test_import_export_workflow.py +++ b/tests/test_import_export_workflow.py @@ -3,7 +3,7 @@ from unittest.mock import Mock import pytest -from textual.widgets import Button, Input, RadioButton, Static +from textual.widgets import Button, DirectoryTree, Input, RadioButton, Static from hosts.core.import_export import ( ExportFormat, @@ -71,8 +71,8 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory(): path_input = app.screen.query_one("#path-input", Input) container = app.screen.query_one("#format-container") assert browse.region.width > 0 - assert browse.region.x >= path_input.region.x - assert browse.region.y >= path_input.region.bottom + assert browse.region.x >= path_input.region.right + assert browse.region.y == path_input.region.y assert browse.region.right <= container.region.right assert browse.region.bottom <= container.region.bottom await pilot.press("tab") @@ -85,6 +85,7 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory(): container = app.screen.query_one("#file-browser-container") assert container.region.x == (app.size.width - container.region.width) // 2 + await pilot.press("tab") await pilot.press("tab") await pilot.press("tab") await pilot.press("enter") @@ -96,6 +97,26 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory(): ) +@pytest.mark.asyncio +async def test_file_browser_can_move_to_its_parent_directory(tmp_path): + """The chooser exposes an explicit route back up the directory tree.""" + child = tmp_path / "child" + child.mkdir() + app = HostsManagerApp() + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + app.push_screen(FileBrowserModal(child, choose_directory=True)) + await pilot.pause() + browser = app.screen + assert isinstance(browser, FileBrowserModal) + + browser.action_go_up() + await pilot.pause() + + assert browser.query_one("#file-browser-tree", DirectoryTree).path == tmp_path + + @pytest.mark.asyncio async def test_export_requires_overwrite_confirmation_before_calling_service(tmp_path): """An existing output file is not replaced until the user confirms it."""