From 40aa66d16479858700f7539887f21247452d0b09 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 15:56:08 +0200 Subject: [PATCH] Fix #5: center import export modals --- docs/user-guide.md | 5 +- src/hosts/tui/import_export_modal.py | 136 ++++++++++++++++++++++++++- tests/test_import_export_workflow.py | 48 +++++++++- 3 files changed, 182 insertions(+), 7 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 9d3e354..e994ac6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -105,11 +105,12 @@ stored mapping. ## Import and export Host Entries Press `Ctrl+X` to export the current in-memory Host Entries. Choose hosts, JSON, or -CSV and enter a destination path. The workflow validates the destination and +CSV and enter a destination path, or choose a directory with **Browse…** and +edit the suggested filename. The workflow validates the destination and requires explicit confirmation before an existing file is overwritten. Press `Ctrl+O` in Privileged Mode to import a hosts, JSON, or CSV file. Choose the -format explicitly, then select the source path. A successful import replaces +format explicitly, then select the source path with **Browse…** or by typing it. A successful import replaces the current Host Entries and saves the Hosts File; parse and save errors remain visible in the message rail and leave the current file unchanged. diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py index 094519a..2649dd4 100644 --- a/src/hosts/tui/import_export_modal.py +++ b/src/hosts/tui/import_export_modal.py @@ -7,7 +7,15 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Button, Input, Label, RadioButton, RadioSet, Static +from textual.widgets import ( + Button, + DirectoryTree, + Input, + Label, + RadioButton, + RadioSet, + Static, +) from ..core.import_export import ExportFormat, ImportFormat, ImportResult @@ -34,7 +42,7 @@ class _FormatModal(ModalScreen): BINDINGS = [Binding("escape", "cancel", "Cancel")] CSS = """ - _FormatModal { align: center middle; } + ExportModal, ImportModal { align: center middle; } #format-container { width: 72; height: auto; max-height: 90%; background: $surface; border: thick $primary; padding: 1 2; @@ -82,7 +90,9 @@ class ExportModal(_FormatModal): classes="format-copy", ) yield Label("Export file path") - yield Input(placeholder="/path/to/entries.hosts", id="path-input") + with Horizontal(): + yield Input(placeholder="/path/to/entries.hosts", id="path-input") + yield Button("Browse…", id="browse-button") with RadioSet(id="format-select"): for index, format in enumerate(self._formats): yield RadioButton( @@ -106,6 +116,8 @@ class ExportModal(_FormatModal): self.action_cancel() elif event.button.id == "continue-button": self.action_continue() + elif event.button.id == "browse-button": + self.action_browse() elif event.button.id == "confirm-button" and self._request is not None: self.dismiss(self._request) @@ -130,6 +142,22 @@ class ExportModal(_FormatModal): return self.dismiss(request) + def action_browse(self) -> None: + """Choose an export directory without requiring a typed path.""" + value = self.query_one("#path-input", Input).value.strip() + start = Path(value).expanduser() if value else Path.cwd() + if not start.is_dir(): + start = start.parent + + def selected(directory: Path | None) -> None: + if directory is not None: + format = self._selected_format(ExportFormat) + self.query_one("#path-input", Input).value = str( + directory / f"hosts-export{_format_suffix(format)}" + ) + + self.app.push_screen(FileBrowserModal(start, choose_directory=True), selected) + class ImportModal(_FormatModal): """Collect an import file and format before replacing Host Entries.""" @@ -147,7 +175,9 @@ class ImportModal(_FormatModal): classes="format-copy", ) yield Label("Import file path") - yield Input(placeholder="/path/to/entries.hosts", id="path-input") + with Horizontal(): + yield Input(placeholder="/path/to/entries.hosts", id="path-input") + yield Button("Browse…", id="browse-button") with RadioSet(id="format-select"): for index, format in enumerate(self._formats): yield RadioButton( @@ -177,6 +207,95 @@ class ImportModal(_FormatModal): ) return self.dismiss(ImportRequest(path, format, result)) + elif event.button.id == "browse-button": + self.action_browse() + + def action_browse(self) -> None: + """Choose an import file from the terminal file browser.""" + value = self.query_one("#path-input", Input).value.strip() + start = Path(value).expanduser() if value else Path.cwd() + if not start.is_dir(): + start = start.parent + + def selected(path: Path | None) -> None: + if path is not None: + self.query_one("#path-input", Input).value = str(path) + + self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected) + + +class FileBrowserModal(ModalScreen[Path | None]): + """A keyboard-accessible directory tree for file and folder selection.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + CSS = """ + FileBrowserModal { align: center middle; } + #file-browser-container { + width: 90; height: 90%; max-width: 90%; background: $surface; + border: thick $primary; padding: 1 2; + } + .browser-title { text-align: center; text-style: bold; color: $primary; } + .browser-copy { color: $text-muted; margin-top: 1; } + #file-browser-status { height: 1; color: $warning; margin-top: 1; } + #file-browser-tree { height: 1fr; margin-top: 1; border: round $primary; } + """ + + 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._choose_directory = choose_directory + self._selected_directory: Path | None = None + + def compose(self) -> ComposeResult: + with Vertical(id="file-browser-container"): + yield Static( + "Choose export directory" + if self._choose_directory + else "Choose import file", + classes="browser-title", + ) + yield Static( + "Use arrow keys to browse. Enter selects a file; expand folders with Right.", + classes="browser-copy", + ) + 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("Cancel", id="cancel-button") + if self._choose_directory: + yield Button( + "Choose directory", + id="choose-directory-button", + variant="primary", + ) + + def on_mount(self) -> None: + self.query_one("#file-browser-tree", DirectoryTree).focus() + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + if self._choose_directory: + self.query_one("#file-browser-status", Static).update( + "Choose a directory for the export destination." + ) + return + self.dismiss(event.path) + + def on_directory_tree_directory_selected( + self, event: DirectoryTree.DirectorySelected + ) -> None: + self._selected_directory = event.path + 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": + self.action_cancel() + elif event.button.id == "choose-directory-button": + self.dismiss(self._selected_directory or self._start_path) + + def action_cancel(self) -> None: + self.dismiss(None) def _format_label(format: ExportFormat | ImportFormat) -> str: @@ -186,3 +305,12 @@ def _format_label(format: ExportFormat | ImportFormat) -> str: "json": "JSON (.json)", "csv": "CSV (.csv)", }[format.value] + + +def _format_suffix(format: ExportFormat) -> str: + """Provide an editable default filename after choosing an export folder.""" + return { + ExportFormat.HOSTS: ".hosts", + ExportFormat.JSON: ".json", + ExportFormat.CSV: ".csv", + }[format] diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py index 7080957..c93f666 100644 --- a/tests/test_import_export_workflow.py +++ b/tests/test_import_export_workflow.py @@ -13,7 +13,7 @@ from hosts.core.import_export import ( ) from hosts.core.models import HostEntry, HostsFile from hosts.tui.app import HostsManagerApp -from hosts.tui.import_export_modal import ExportModal, ImportModal +from hosts.tui.import_export_modal import ExportModal, FileBrowserModal, ImportModal @pytest.mark.asyncio @@ -42,6 +42,52 @@ async def test_export_shortcut_opens_a_format_chooser_and_reports_validation(): ) +@pytest.mark.asyncio +async def test_export_modal_is_centered_in_the_terminal_viewport(): + """The export form is centered rather than pinned to a terminal corner.""" + app = HostsManagerApp() + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+x") + await pilot.pause() + + container = app.screen.query_one("#format-container") + assert container.region.x == (app.size.width - container.region.width) // 2 + + assert container.region.y == (app.size.height - container.region.height) // 2 + + +@pytest.mark.asyncio +async def test_export_browser_lets_the_user_choose_a_destination_directory(): + """Browse opens a centered directory tree rather than requiring a typed path.""" + app = HostsManagerApp() + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+x") + await pilot.pause() + await pilot.press("tab") + await pilot.press("enter") + await pilot.pause() + await pilot.pause() + + assert isinstance(app.screen, FileBrowserModal) + assert app.screen.query_one("#file-browser-tree") is not None + 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("enter") + await pilot.pause() + await pilot.pause() + assert isinstance(app.screen, ExportModal) + assert app.screen.query_one("#path-input", Input).value.endswith( + "hosts-export.hosts" + ) + + @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."""