From 20e7d63665bcb7112c9b30e4500572c3eae6c567 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 15:22:55 +0200 Subject: [PATCH 1/7] Feat #5: expose import and export workflows --- README.md | 3 + docs/user-guide.md | 13 ++ src/hosts/tui/app.py | 72 ++++++++++ src/hosts/tui/help_modal.py | 4 + src/hosts/tui/import_export_modal.py | 188 +++++++++++++++++++++++++++ src/hosts/tui/keybindings.py | 6 + tests/test_import_export_workflow.py | 165 +++++++++++++++++++++++ 7 files changed, 451 insertions(+) create mode 100644 src/hosts/tui/import_export_modal.py create mode 100644 tests/test_import_export_workflow.py diff --git a/README.md b/README.md index 9bd183e..591333e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ on macOS and Linux. - Creates a timestamped Pre-edit Backup before Privileged Mode begins. - Restores the current session's Pre-edit Backup after explicit confirmation. - Supports undo and redo during the current Privileged Mode session. +- Exports Host Entries as hosts, JSON, or CSV files, and imports those formats in Privileged Mode. The application starts in Read-only Mode. Default Entries cannot be edited, deleted, activated, deactivated, or moved. @@ -76,6 +77,8 @@ including persistence behavior and manual recovery. | `Space` | Activate or deactivate the selected Host Entry | | `Ctrl+S` | Save the current in-memory state | | `Ctrl+F` | Open advanced filters | +| `Ctrl+X` | Export Host Entries (hosts, JSON, or CSV) | +| `Ctrl+O` | Import Host Entries from hosts, JSON, or CSV (Privileged Mode) | | `?` | Show help | | `q` or `Ctrl+C` | Quit | diff --git a/docs/user-guide.md b/docs/user-guide.md index 5ec7d36..9d3e354 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -102,6 +102,17 @@ Both actions require Privileged Mode because a successful result changes `/etc/hosts`. A failed lookup reports the failure and keeps the previously 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 +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 +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. + ## Verify what is on disk The details shown by the application normally match the last successful save. @@ -247,6 +258,7 @@ continually retrying it. | `Ctrl+R` | Reload `/etc/hosts` | | `Ctrl+F` | Open advanced filters | | `c` | Open configuration | +| `Ctrl+X` | Export Host Entries | | `?` | Show help | | `q` or `Ctrl+C` | Quit | | `Ctrl+E` | Enter or leave Privileged Mode | @@ -266,6 +278,7 @@ continually retrying it. | `Ctrl+Z` / `Ctrl+Y` | Undo or redo and save the result | | `Ctrl+S` | Save the current in-memory state | | `b` | Review session backup changes and confirm restoration | +| `Ctrl+O` | Import Host Entries from hosts, JSON, or CSV | Within the Entry Editor, use `Tab` and `Shift+Tab` to move between fields and `Escape` to leave the form. If values changed, the application asks whether to diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 9aa3b74..044901f 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -29,12 +29,14 @@ from ..core.manager import HostsManager, MutationState from ..core.restore_preview import RestorePreviewError, create_restore_preview from ..core.dns import DNSService from ..core.filters import EntryFilter, FilterOptions +from ..core.import_export import ImportExportService, ExportFormat, ImportFormat from .config_modal import ConfigModal from .add_entry_modal import AddEntryModal from .backup_restore_modal import BackupRestoreModal from .delete_confirmation_modal import DeleteConfirmationModal from .filter_modal import FilterModal from .help_modal import HelpModal +from .import_export_modal import ExportModal, ImportModal, ExportRequest, ImportRequest from .custom_footer import CustomFooter from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS @@ -85,6 +87,7 @@ class HostsManagerApp(App): self.parser = HostsParser() self.config = Config() self.manager = HostsManager() + self.import_export_service = ImportExportService() # Initialize DNS service dns_config = self.config.get("dns_resolution", {}) @@ -904,6 +907,75 @@ class HostsManagerApp(App): return self.navigation_handler.save_hosts_file() + def action_export_entries(self) -> None: + """Export the in-memory Host Entries without requiring Privileged Mode.""" + + def handle_export(request: ExportRequest | None) -> None: + if request is None: + return + exporters = { + ExportFormat.HOSTS: self.import_export_service.export_hosts_format, + ExportFormat.JSON: self.import_export_service.export_json_format, + ExportFormat.CSV: self.import_export_service.export_csv_format, + } + result = exporters[request.format](self.hosts_file, request.path) + if result.success: + self.update_status( + f"✓ Exported {result.entries_exported} Host Entries to {result.file_path}" + ) + else: + self.update_status( + f"Error exporting Host Entries: {'; '.join(result.errors)}" + ) + + self.push_screen( + ExportModal( + self.import_export_service.get_supported_export_formats(), + self.import_export_service.validate_export_path, + ), + handle_export, + ) + + def action_import_entries(self) -> None: + """Import and persist replacement Host Entries in Privileged Mode.""" + if not self._allow_mutation_action(): + return + if not self.edit_mode: + self.update_status( + "Cannot import Host Entries in Read-only Mode. Enter Privileged Mode first." + ) + return + + importers = { + ImportFormat.HOSTS: self.import_export_service.import_hosts_format, + ImportFormat.JSON: self.import_export_service.import_json_format, + ImportFormat.CSV: self.import_export_service.import_csv_format, + } + + def import_file(format: ImportFormat, path): + return importers[format](path) + + def handle_import(request: ImportRequest | None) -> None: + if request is None: + return + result = request.result + snapshot = self.capture_mutation_state() + self.hosts_file = HostsFile(entries=result.entries) + self.selected_entry_index = 0 + if self.save_mutation(snapshot, "Import"): + self.table_handler.populate_entries_table() + self.details_handler.update_entry_details() + self.update_status( + f"✓ Imported and saved {result.successfully_imported} Host Entries from {request.path}" + ) + + self.push_screen( + ImportModal( + self.import_export_service.get_supported_import_formats(), import_file + ), + handle_import, + ) + def action_add_entry(self) -> None: """Show the add entry modal.""" if not self._allow_add_entry_action(): diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py index 63d5e1a..3ab92ef 100644 --- a/src/hosts/tui/help_modal.py +++ b/src/hosts/tui/help_modal.py @@ -48,6 +48,10 @@ class HelpModal(ModalScreen[None]): "Type in Search for immediate filtering Ctrl+F Advanced filters", classes="help-copy", ) + yield Static( + "Ctrl+X Export Host Entries Ctrl+O Import Host Entries", + classes="help-copy", + ) yield Static("Privileged Mode", classes="help-section") yield Static( "Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n" diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py new file mode 100644 index 0000000..094519a --- /dev/null +++ b/src/hosts/tui/import_export_modal.py @@ -0,0 +1,188 @@ +"""Keyboard-first import and export forms for Host Entries.""" + +from dataclasses import dataclass +from pathlib import Path + +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 ..core.import_export import ExportFormat, ImportFormat, ImportResult + + +@dataclass(frozen=True) +class ExportRequest: + """A validated request to export the displayed Hosts File.""" + + path: Path + format: ExportFormat + + +@dataclass(frozen=True) +class ImportRequest: + """A validated request to replace the Hosts File from an import.""" + + path: Path + format: ImportFormat + result: ImportResult + + +class _FormatModal(ModalScreen): + """Shared form mechanics for selecting a file and supported format.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + CSS = """ + _FormatModal { align: center middle; } + #format-container { + width: 72; height: auto; max-height: 90%; background: $surface; + border: thick $primary; padding: 1 2; + } + .format-title { text-align: center; text-style: bold; color: $primary; } + .format-copy { margin-top: 1; color: $text-muted; } + #path-input { margin-top: 1; } + #workflow-error { color: $error; height: auto; margin-top: 1; } + #workflow-warning { color: $warning; height: auto; margin-top: 1; } + .button-row { margin-top: 1; height: 3; align: center middle; } + """ + + def _selected_format(self, enum_type): + for member in enum_type: + if self.query_one(f"#format-{member.value}", RadioButton).value: + return member + return next(iter(enum_type)) + + def _path_or_error(self) -> Path | None: + value = self.query_one("#path-input", Input).value.strip() + if value: + self.query_one("#workflow-error", Static).update("") + return Path(value).expanduser() + self.query_one("#workflow-error", Static).update("Choose a file path first.") + return None + + def action_cancel(self) -> None: + self.dismiss(None) + + +class ExportModal(_FormatModal): + """Collect an export destination and require confirmation for overwrite.""" + + def __init__(self, formats: list[ExportFormat], validate_path): + super().__init__() + self._formats = formats + self._validate_path = validate_path + self._request: ExportRequest | None = None + + def compose(self) -> ComposeResult: + with Vertical(id="format-container"): + yield Static("Export Host Entries", classes="format-title") + yield Static( + "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") + 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 Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + yield Button("Continue", id="continue-button", variant="primary") + yield Button("Overwrite export", id="confirm-button", variant="error") + + def on_mount(self) -> None: + self.query_one("#path-input", Input).focus() + self.query_one("#confirm-button", Button).display = False + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-button": + self.action_cancel() + elif event.button.id == "continue-button": + self.action_continue() + elif event.button.id == "confirm-button" and self._request is not None: + self.dismiss(self._request) + + def action_continue(self) -> None: + path = self._path_or_error() + if path is None: + return + request = ExportRequest(path, self._selected_format(ExportFormat)) + warnings = self._validate_path(path, request.format) + self._request = request + warning = self.query_one("#workflow-warning", Static) + warning.update("\n".join(warnings)) + blocking_warnings = [item for item in warnings if "already exists" not in item] + if blocking_warnings: + self.query_one("#workflow-error", Static).update( + "Correct the destination before exporting: " + + "; ".join(blocking_warnings) + ) + return + if warnings: + self.query_one("#confirm-button", Button).display = True + return + self.dismiss(request) + + +class ImportModal(_FormatModal): + """Collect an import file and format before replacing Host Entries.""" + + def __init__(self, formats: list[ImportFormat], import_file): + super().__init__() + self._formats = formats + self._import_file = import_file + + def compose(self) -> ComposeResult: + with Vertical(id="format-container"): + yield Static("Import Host Entries", classes="format-title") + yield Static( + "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") + 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 Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + yield Button("Import and save", id="import-button", variant="primary") + + def on_mount(self) -> None: + self.query_one("#path-input", Input).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-button": + self.action_cancel() + elif event.button.id == "import-button": + path = self._path_or_error() + if path is not None: + format = self._selected_format(ImportFormat) + result = self._import_file(format, path) + if not result.success: + self.query_one("#workflow-error", Static).update( + "; ".join(result.errors) + ) + return + self.dismiss(ImportRequest(path, format, result)) + + +def _format_label(format: ExportFormat | ImportFormat) -> str: + """Give the core-supported formats concise, file-oriented labels.""" + return { + "hosts": "Hosts file (.hosts)", + "json": "JSON (.json)", + "csv": "CSV (.csv)", + }[format.value] diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 20a7a25..4637241 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -34,6 +34,12 @@ HOSTS_MANAGER_BINDINGS = [ id="left:toggle_edit_mode", ), Binding("c", "config", "Configuration", show=True, id="right:config"), + Binding( + "ctrl+x", "export_entries", "Export Host Entries", show=False, priority=True + ), + Binding( + "ctrl+o", "import_entries", "Import Host Entries", show=False, priority=True + ), Binding( "ctrl+f", "show_filters", diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py new file mode 100644 index 0000000..7080957 --- /dev/null +++ b/tests/test_import_export_workflow.py @@ -0,0 +1,165 @@ +"""User-visible import and export workflows.""" + +from unittest.mock import Mock + +import pytest +from textual.widgets import Input, RadioButton, Static + +from hosts.core.import_export import ( + ExportFormat, + ExportResult, + ImportFormat, + ImportResult, +) +from hosts.core.models import HostEntry, HostsFile +from hosts.tui.app import HostsManagerApp +from hosts.tui.import_export_modal import ExportModal, ImportModal + + +@pytest.mark.asyncio +async def test_export_shortcut_opens_a_format_chooser_and_reports_validation(): + """Export presents every core format and keeps invalid input visible.""" + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])] + ) + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+x") + await pilot.pause() + assert isinstance(app.screen, ExportModal) + assert [button.label.plain for button in app.screen.query(RadioButton)] == [ + "Hosts file (.hosts)", + "JSON (.json)", + "CSV (.csv)", + ] + + app.screen.query_one("#path-input", Input).value = "" + await pilot.click("#continue-button") + assert "Choose a file path" in str( + app.screen.query_one("#workflow-error", Static).render() + ) + + +@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.""" + target = tmp_path / "hosts.json" + target.write_text("existing", encoding="utf-8") + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])] + ) + app.import_export_service = Mock() + app.import_export_service.get_supported_export_formats.return_value = list( + ExportFormat + ) + app.import_export_service.validate_export_path.return_value = [ + f"File {target} already exists and will be overwritten" + ] + app.import_export_service.export_json_format.return_value = ExportResult( + success=True, + file_path=target, + entries_exported=1, + errors=[], + format=ExportFormat.JSON, + ) + + async with app.run_test(size=(120, 40)) as pilot: + app.action_export_entries() + await pilot.pause() + app.screen.query_one("#path-input", Input).value = str(target) + app.screen.query_one("#format-json", RadioButton).value = True + await pilot.click("#continue-button") + assert "already exists" in str( + app.screen.query_one("#workflow-warning", Static).render() + ) + app.import_export_service.export_json_format.assert_not_called() + + await pilot.click("#confirm-button") + await pilot.pause() + await pilot.pause() + + app.import_export_service.export_json_format.assert_called_once_with( + app.hosts_file, target + ) + + +@pytest.mark.asyncio +async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_errors(): + """Import cannot mutate in Read-only Mode and reports parser failures.""" + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])] + ) + app.load_hosts_file = Mock() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+o") + await pilot.pause() + assert not isinstance(app.screen, ImportModal) + assert "Read-only Mode" in str(app.query_one("#message-rail", Static).render()) + + app.edit_mode = True + app.manager.edit_mode = True + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + app.import_export_service.import_csv_format.return_value = Mock( + success=False, errors=["CSV column 'ip_address' is missing"] + ) + app.action_import_entries() + await pilot.pause() + assert isinstance(app.screen, ImportModal) + app.screen.query_one("#path-input", Input).value = "/tmp/broken.csv" + csv_button = app.screen.query_one("#format-csv", RadioButton) + csv_button.value = True + await pilot.click("#import-button") + await pilot.pause() + assert "CSV column 'ip_address' is missing" in str( + app.screen.query_one("#workflow-error", Static).render() + ) + + +@pytest.mark.asyncio +async def test_successful_import_replaces_entries_and_saves_through_the_manager(): + """A valid import reaches the persistence seam without a real Hosts File.""" + app = HostsManagerApp() + app.hosts_file = HostsFile( + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])] + ) + app.edit_mode = True + app.manager.edit_mode = True + app.save_mutation = Mock(return_value=True) + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"]) + app.import_export_service.import_json_format.return_value = ImportResult( + success=True, + entries=[imported], + errors=[], + warnings=[], + total_processed=1, + successfully_imported=1, + ) + + async with app.run_test(size=(120, 40)) as pilot: + app.action_import_entries() + await pilot.pause() + app.screen.query_one("#path-input", Input).value = "/tmp/entries.json" + app.screen.query_one("#format-json", RadioButton).value = True + await pilot.click("#import-button") + await pilot.pause() + await pilot.pause() + + assert [entry.hostnames for entry in app.hosts_file.entries] == [ + ["imported.test"] + ] + app.save_mutation.assert_called_once() + assert "Imported and saved 1 Host Entries" in str( + app.query_one("#message-rail", Static).render() + ) -- 2.51.2 From 40aa66d16479858700f7539887f21247452d0b09 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 15:56:08 +0200 Subject: [PATCH 2/7] 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.""" -- 2.51.2 From 1b19d5eca2ad20d5d49592b669ca6fd9f7fc1245 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 17:13:27 +0200 Subject: [PATCH 3/7] Fix #5: keep browse control visible --- src/hosts/tui/import_export_modal.py | 11 +++++------ tests/test_import_export_workflow.py | 10 +++++++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py index 2649dd4..a4c03c7 100644 --- a/src/hosts/tui/import_export_modal.py +++ b/src/hosts/tui/import_export_modal.py @@ -52,6 +52,7 @@ class _FormatModal(ModalScreen): #path-input { margin-top: 1; } #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; } .button-row { margin-top: 1; height: 3; align: center middle; } """ @@ -90,9 +91,8 @@ class ExportModal(_FormatModal): classes="format-copy", ) yield Label("Export file path") - with Horizontal(): - yield Input(placeholder="/path/to/entries.hosts", id="path-input") - yield Button("Browse…", id="browse-button") + 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( @@ -175,9 +175,8 @@ class ImportModal(_FormatModal): classes="format-copy", ) yield Label("Import file path") - with Horizontal(): - yield Input(placeholder="/path/to/entries.hosts", id="path-input") - yield Button("Browse…", id="browse-button") + 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( diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py index c93f666..664b9cf 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 Input, RadioButton, Static +from textual.widgets import Button, Input, RadioButton, Static from hosts.core.import_export import ( ExportFormat, @@ -67,6 +67,14 @@ async def test_export_browser_lets_the_user_choose_a_destination_directory(): async with app.run_test(size=(120, 40)) as pilot: await pilot.press("ctrl+x") await pilot.pause() + browse = app.screen.query_one("#browse-button", Button) + 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.right <= container.region.right + assert browse.region.bottom <= container.region.bottom await pilot.press("tab") await pilot.press("enter") await pilot.pause() -- 2.51.2 From 41a96b81560ec5c3ccd079f7edce38be22be21ef Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 17:19:41 +0200 Subject: [PATCH 4/7] Fix #5: make file chooser navigable --- src/hosts/tui/import_export_modal.py | 87 +++++++++++++++++++--------- tests/test_import_export_workflow.py | 27 ++++++++- 2 files changed, 83 insertions(+), 31 deletions(-) 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.""" -- 2.51.2 From 2c7840426d9232a4ea7989a48a574faa38e51676 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 17:34:25 +0200 Subject: [PATCH 5/7] Fix #5: compact file workflow forms --- src/hosts/tui/import_export_modal.py | 150 ++++++++++++++++----------- tests/test_import_export_workflow.py | 47 +++++++-- 2 files changed, 128 insertions(+), 69 deletions(-) diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py index 63d90f5..7f1b8a9 100644 --- a/src/hosts/tui/import_export_modal.py +++ b/src/hosts/tui/import_export_modal.py @@ -7,15 +7,7 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Grid, Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import ( - Button, - DirectoryTree, - Input, - Label, - RadioButton, - RadioSet, - Static, -) +from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static from ..core.import_export import ExportFormat, ImportFormat, ImportResult @@ -49,13 +41,19 @@ class _FormatModal(ModalScreen): } .format-title { text-align: center; text-style: bold; color: $primary; } .format-copy { margin-top: 1; color: $text-muted; } - .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; height: 3; margin: 0; } - .format-section { margin-top: 1; padding: 0 1; border: round $primary; } + .path-section { + height: 3; margin-top: 1; padding: 0 1; border: round $primary; + } + #path-row { grid-size: 2; grid-columns: 1fr 14; height: 1; } + #path-input { width: 1fr; height: 1; margin: 0; padding: 0; border: none; } + #workflow-error { color: $error; height: auto; } + #workflow-warning { color: $warning; height: auto; } + .workflow-message.hidden { display: none; } + .browse-button { width: 14; height: 1; margin: 0; border: none; } + .format-section { + height: 5; margin-top: 1; padding: 0 1; border: round $primary; + } + #format-select { height: 3; margin: 0; padding: 0; border: none; } .button-row { margin-top: 1; height: 3; align: center middle; } """ @@ -68,11 +66,16 @@ class _FormatModal(ModalScreen): def _path_or_error(self) -> Path | None: value = self.query_one("#path-input", Input).value.strip() if value: - self.query_one("#workflow-error", Static).update("") + self._show_message("#workflow-error", "") return Path(value).expanduser() - self.query_one("#workflow-error", Static).update("Choose a file path first.") + self._show_message("#workflow-error", "Choose a file path first.") return None + def _show_message(self, selector: str, message: str) -> None: + widget = self.query_one(selector, Static) + widget.update(message) + widget.set_class(not message, "hidden") + def action_cancel(self) -> None: self.dismiss(None) @@ -93,21 +96,31 @@ class ExportModal(_FormatModal): "Choose a destination and format. Existing files require confirmation.", classes="format-copy", ) - with Vertical(classes="form-section"): - yield Label("Export file path") + with Vertical(classes="path-section") as location: + location.border_title = "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") + yield Input( + placeholder="/path/to/entries.hosts", + id="path-input", + compact=True, + ) + yield Button( + "Browse…", + id="browse-button", + classes="browse-button", + compact=True, + ) + yield Static("", id="workflow-error", classes="workflow-message hidden") + yield Static("", id="workflow-warning", classes="workflow-message hidden") with Vertical(classes="format-section") as formats: formats.border_title = "File format" - with RadioSet(id="format-select"): + with RadioSet(id="format-select", compact=True): for index, format in enumerate(self._formats): yield RadioButton( _format_label(format), id=f"format-{format.value}", value=index == 0, + compact=True, ) with Horizontal(classes="button-row"): yield Button("Cancel", id="cancel-button") @@ -135,13 +148,13 @@ class ExportModal(_FormatModal): request = ExportRequest(path, self._selected_format(ExportFormat)) warnings = self._validate_path(path, request.format) self._request = request - warning = self.query_one("#workflow-warning", Static) - warning.update("\n".join(warnings)) + self._show_message("#workflow-warning", "\n".join(warnings)) blocking_warnings = [item for item in warnings if "already exists" not in item] if blocking_warnings: - self.query_one("#workflow-error", Static).update( + self._show_message( + "#workflow-error", "Correct the destination before exporting: " - + "; ".join(blocking_warnings) + + "; ".join(blocking_warnings), ) return if warnings: @@ -181,20 +194,30 @@ class ImportModal(_FormatModal): "Import replaces the current Host Entries and saves the Hosts File.", classes="format-copy", ) - with Vertical(classes="form-section"): - yield Label("Import file path") + with Vertical(classes="path-section") as location: + location.border_title = "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") + yield Input( + placeholder="/path/to/entries.hosts", + id="path-input", + compact=True, + ) + yield Button( + "Browse…", + id="browse-button", + classes="browse-button", + compact=True, + ) + yield Static("", id="workflow-error", classes="workflow-message hidden") with Vertical(classes="format-section") as formats: formats.border_title = "File format" - with RadioSet(id="format-select"): + with RadioSet(id="format-select", compact=True): for index, format in enumerate(self._formats): yield RadioButton( _format_label(format), id=f"format-{format.value}", value=index == 0, + compact=True, ) with Horizontal(classes="button-row"): yield Button("Cancel", id="cancel-button") @@ -212,9 +235,7 @@ class ImportModal(_FormatModal): format = self._selected_format(ImportFormat) result = self._import_file(format, path) if not result.success: - self.query_one("#workflow-error", Static).update( - "; ".join(result.errors) - ) + self._show_message("#workflow-error", "; ".join(result.errors)) return self.dismiss(ImportRequest(path, format, result)) elif event.button.id == "browse-button": @@ -234,13 +255,28 @@ class ImportModal(_FormatModal): self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected) +class ParentDirectoryTree(DirectoryTree): + """Directory tree with a conventional parent entry above its root.""" + + def _populate_node(self, node, content) -> None: + super()._populate_node(node, content) + if node is not self.root or node.data is None: + return + current = node.data.path.expanduser().resolve() + parent = current.parent + if parent != current: + node.add( + "..", + data=type(node.data)(parent), + allow_expand=False, + before=0, + ) + + class FileBrowserModal(ModalScreen[Path | None]): """A keyboard-accessible directory tree for file and folder selection.""" - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("u", "go_up", "Up directory"), - ] + BINDINGS = [Binding("escape", "cancel", "Cancel")] CSS = """ FileBrowserModal { align: center middle; } #file-browser-container { @@ -269,13 +305,12 @@ class FileBrowserModal(ModalScreen[Path | None]): classes="browser-title", ) yield Static( - "Use arrow keys to browse. Enter selects a file; expand folders with Right.", + "Use arrow keys to browse. Select .. to go up; 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") + yield ParentDirectoryTree(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( @@ -300,33 +335,24 @@ class FileBrowserModal(ModalScreen[Path | None]): def on_directory_tree_directory_selected( self, event: DirectoryTree.DirectorySelected ) -> None: + if str(event.node.label) == "..": + self._current_path = event.path + self._selected_directory = None + self.query_one("#file-browser-tree", DirectoryTree).path = event.path + self.query_one("#file-browser-status", Static).update(str(event.path)) + return 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 == "up-button": - self.action_go_up() - elif event.button.id == "cancel-button": + 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) + self.dismiss(self._selected_directory or self._current_path) 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 5e20471..e6e37a3 100644 --- a/tests/test_import_export_workflow.py +++ b/tests/test_import_export_workflow.py @@ -3,7 +3,8 @@ from unittest.mock import Mock import pytest -from textual.widgets import Button, DirectoryTree, Input, RadioButton, Static +from textual.containers import Vertical +from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static from hosts.core.import_export import ( ExportFormat, @@ -58,6 +59,31 @@ async def test_export_modal_is_centered_in_the_terminal_viewport(): assert container.region.y == (app.size.height - container.region.height) // 2 +@pytest.mark.asyncio +async def test_format_modal_uses_compact_consistent_form_sections(): + """Location and format fields use matching borders without excess rows.""" + 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() + + location = app.screen.query_one(".path-section", Vertical) + formats = app.screen.query_one(".format-section", Vertical) + path_input = app.screen.query_one("#path-input", Input) + browse = app.screen.query_one("#browse-button", Button) + format_select = app.screen.query_one("#format-select", RadioSet) + container = app.screen.query_one("#format-container") + + assert location.border_title == "Export file path" + assert path_input.region.height == 1 + assert browse.region.height == 1 + assert format_select.region.height == 3 + assert formats.region.height == 5 + assert container.region.height <= 22 + + @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.""" @@ -71,6 +97,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 path_input.region.height == 1 + assert browse.region.height == 1 assert browse.region.x >= path_input.region.right assert browse.region.y == path_input.region.y assert browse.region.right <= container.region.right @@ -85,7 +113,6 @@ 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") @@ -98,8 +125,8 @@ 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.""" +async def test_file_browser_presents_parent_directory_as_dot_dot(tmp_path): + """The chooser exposes its parent as the first conventional tree entry.""" child = tmp_path / "child" child.mkdir() app = HostsManagerApp() @@ -110,11 +137,17 @@ async def test_file_browser_can_move_to_its_parent_directory(tmp_path): await pilot.pause() browser = app.screen assert isinstance(browser, FileBrowserModal) - - browser.action_go_up() + tree = browser.query_one("#file-browser-tree", DirectoryTree) await pilot.pause() - assert browser.query_one("#file-browser-tree", DirectoryTree).path == tmp_path + assert str(tree.root.children[0].label) == ".." + assert len(browser.query("#up-button")) == 0 + + tree.focus() + tree.select_node(tree.root.children[0]) + await pilot.pause() + + assert tree.path == tmp_path @pytest.mark.asyncio -- 2.51.2 From 48c9760e3c1da089e4a53c6ef5ad23f81a976c66 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 18:05:26 +0200 Subject: [PATCH 6/7] Fix #5: clarify parent directory icon --- src/hosts/tui/import_export_modal.py | 15 +++++++++++++++ tests/test_import_export_workflow.py | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py index 7f1b8a9..b3c792f 100644 --- a/src/hosts/tui/import_export_modal.py +++ b/src/hosts/tui/import_export_modal.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from pathlib import Path +from rich.text import Text from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Grid, Horizontal, Vertical @@ -258,6 +259,8 @@ class ImportModal(_FormatModal): class ParentDirectoryTree(DirectoryTree): """Directory tree with a conventional parent entry above its root.""" + PARENT_ICON = "↑ " + def _populate_node(self, node, content) -> None: super()._populate_node(node, content) if node is not self.root or node.data is None: @@ -272,6 +275,18 @@ class ParentDirectoryTree(DirectoryTree): before=0, ) + def render_label(self, node, base_style, style) -> Text: + """Render the parent entry as navigation rather than as a file.""" + if str(node.label) != "..": + return super().render_label(node, base_style, style) + + label = node.label.copy() + label.stylize(style) + label.stylize_before( + self.get_component_rich_style("directory-tree--folder", partial=True) + ) + return Text.assemble((self.PARENT_ICON, base_style), label) + class FileBrowserModal(ModalScreen[Path | None]): """A keyboard-accessible directory tree for file and folder selection.""" diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py index e6e37a3..339d87f 100644 --- a/tests/test_import_export_workflow.py +++ b/tests/test_import_export_workflow.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest +from rich.style import Style from textual.containers import Vertical from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static @@ -141,6 +142,9 @@ async def test_file_browser_presents_parent_directory_as_dot_dot(tmp_path): await pilot.pause() assert str(tree.root.children[0].label) == ".." + assert tree.render_label( + tree.root.children[0], Style(), Style() + ).plain.startswith("↑ ") assert len(browser.query("#up-button")) == 0 tree.focus() -- 2.51.2 From 9040cf72a8efdf75330dd8bd63a21a518e56aa92 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 8 Sep 2026 18:20:50 +0200 Subject: [PATCH 7/7] Feat #5: choose additive or replacement import --- README.md | 3 +- src/hosts/tui/app.py | 63 ++++++++++++--- src/hosts/tui/import_export_modal.py | 94 +++++++++++++++++++++- tests/test_import_export_workflow.py | 113 +++++++++++++++++++++++++-- 4 files changed, 251 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 591333e..f49f9fe 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ on macOS and Linux. - Creates a timestamped Pre-edit Backup before Privileged Mode begins. - Restores the current session's Pre-edit Backup after explicit confirmation. - Supports undo and redo during the current Privileged Mode session. -- Exports Host Entries as hosts, JSON, or CSV files, and imports those formats in Privileged Mode. +- Exports Host Entries as hosts, JSON, or CSV files. In Privileged Mode, + imports can add Host Entries or replace the Hosts File after confirmation. The application starts in Read-only Mode. Default Entries cannot be edited, deleted, activated, deactivated, or moved. diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 044901f..0fd0277 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -36,7 +36,14 @@ from .backup_restore_modal import BackupRestoreModal from .delete_confirmation_modal import DeleteConfirmationModal from .filter_modal import FilterModal from .help_modal import HelpModal -from .import_export_modal import ExportModal, ImportModal, ExportRequest, ImportRequest +from .import_export_modal import ( + ExportModal, + ImportModal, + ExportRequest, + ImportMode, + ImportRequest, + ReplaceImportConfirmationModal, +) from .custom_footer import CustomFooter from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS @@ -955,19 +962,53 @@ class HostsManagerApp(App): def import_file(format: ImportFormat, path): return importers[format](path) + def apply_import(request: ImportRequest) -> None: + result = request.result + snapshot = self.capture_mutation_state() + if request.mode is ImportMode.ADD: + self.hosts_file = HostsFile( + entries=[*self.hosts_file.entries, *result.entries], + header_comments=list(self.hosts_file.header_comments), + footer_comments=list(self.hosts_file.footer_comments), + ) + action = "Add imported Host Entries" + outcome = "Added and saved" + else: + self.hosts_file = HostsFile(entries=list(result.entries)) + action = "Replace Hosts File from import" + outcome = "Replaced Hosts File with" + self.selected_entry_index = 0 + if self.save_mutation(snapshot, action): + self.table_handler.populate_entries_table() + self.details_handler.update_entry_details() + noun = ( + "Host Entry" + if result.successfully_imported == 1 + else "Host Entries" + ) + self.update_status( + f"✓ {outcome} {result.successfully_imported} " + f"{'imported ' if request.mode is ImportMode.REPLACE else ''}" + f"{noun} from {request.path}" + ) + def handle_import(request: ImportRequest | None) -> None: if request is None: return - result = request.result - snapshot = self.capture_mutation_state() - self.hosts_file = HostsFile(entries=result.entries) - self.selected_entry_index = 0 - if self.save_mutation(snapshot, "Import"): - self.table_handler.populate_entries_table() - self.details_handler.update_entry_details() - self.update_status( - f"✓ Imported and saved {result.successfully_imported} Host Entries from {request.path}" - ) + if request.mode is ImportMode.ADD: + apply_import(request) + return + + def handle_replace_confirmation(confirmed: bool | None) -> None: + if confirmed: + apply_import(request) + + self.push_screen( + ReplaceImportConfirmationModal( + len(self.hosts_file.entries), len(request.result.entries) + ), + handle_replace_confirmation, + ) self.push_screen( ImportModal( diff --git a/src/hosts/tui/import_export_modal.py b/src/hosts/tui/import_export_modal.py index b3c792f..a90f920 100644 --- a/src/hosts/tui/import_export_modal.py +++ b/src/hosts/tui/import_export_modal.py @@ -1,6 +1,7 @@ """Keyboard-first import and export forms for Host Entries.""" from dataclasses import dataclass +from enum import Enum from pathlib import Path from rich.text import Text @@ -21,12 +22,20 @@ class ExportRequest: format: ExportFormat +class ImportMode(Enum): + """How imported Host Entries are applied to the current Hosts File.""" + + ADD = "add" + REPLACE = "replace" + + @dataclass(frozen=True) class ImportRequest: - """A validated request to replace the Hosts File from an import.""" + """A validated request to apply imported Host Entries.""" path: Path format: ImportFormat + mode: ImportMode result: ImportResult @@ -55,6 +64,10 @@ class _FormatModal(ModalScreen): height: 5; margin-top: 1; padding: 0 1; border: round $primary; } #format-select { height: 3; margin: 0; padding: 0; border: none; } + .import-mode-section { + height: 4; margin-top: 1; padding: 0 1; border: round $primary; + } + #import-mode-select { height: 2; margin: 0; padding: 0; border: none; } .button-row { margin-top: 1; height: 3; align: center middle; } """ @@ -181,7 +194,7 @@ class ExportModal(_FormatModal): class ImportModal(_FormatModal): - """Collect an import file and format before replacing Host Entries.""" + """Collect an import file, format, and application mode.""" def __init__(self, formats: list[ImportFormat], import_file): super().__init__() @@ -192,7 +205,7 @@ class ImportModal(_FormatModal): with Vertical(id="format-container"): yield Static("Import Host Entries", classes="format-title") yield Static( - "Import replaces the current Host Entries and saves the Hosts File.", + "Choose a file, format, and how to apply its Host Entries.", classes="format-copy", ) with Vertical(classes="path-section") as location: @@ -220,6 +233,15 @@ class ImportModal(_FormatModal): value=index == 0, compact=True, ) + with Vertical(classes="import-mode-section") as modes: + modes.border_title = "Import action" + with RadioSet(id="import-mode-select", compact=True): + yield RadioButton( + "Add to Hosts File", id="mode-add", value=True, compact=True + ) + yield RadioButton( + "Replace Hosts File", id="mode-replace", compact=True + ) with Horizontal(classes="button-row"): yield Button("Cancel", id="cancel-button") yield Button("Import and save", id="import-button", variant="primary") @@ -238,7 +260,12 @@ class ImportModal(_FormatModal): if not result.success: self._show_message("#workflow-error", "; ".join(result.errors)) return - self.dismiss(ImportRequest(path, format, result)) + mode = ( + ImportMode.REPLACE + if self.query_one("#mode-replace", RadioButton).value + else ImportMode.ADD + ) + self.dismiss(ImportRequest(path, format, mode, result)) elif event.button.id == "browse-button": self.action_browse() @@ -256,6 +283,59 @@ class ImportModal(_FormatModal): self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected) +class ReplaceImportConfirmationModal(ModalScreen[bool]): + """Warn before replacing every current Host Entry with imported entries.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + CSS = """ + ReplaceImportConfirmationModal { align: center middle; } + #replace-import-confirmation-container { + width: 68; height: auto; max-width: 90%; background: $surface; + border: thick $error; padding: 1 2; + } + .replace-title { + text-align: center; text-style: bold; color: $error; + } + #replace-warning { height: auto; margin-top: 1; color: $warning; } + .replace-consequence { height: auto; margin-top: 1; } + .button-row { margin-top: 1; height: 3; align: center middle; } + """ + + def __init__(self, current_count: int, imported_count: int): + super().__init__() + self._current_count = current_count + self._imported_count = imported_count + + def compose(self) -> ComposeResult: + with Vertical(id="replace-import-confirmation-container"): + yield Static("Replace Hosts File?", classes="replace-title") + yield Static( + f"This will remove all {_entry_count(self._current_count, 'current')} " + f"and replace the Hosts File with " + f"{_entry_count(self._imported_count, 'imported')}.", + id="replace-warning", + ) + yield Static( + "The current Hosts File will not be changed unless you confirm.", + classes="replace-consequence", + ) + with Horizontal(classes="button-row"): + yield Button("Cancel", id="cancel-button") + yield Button("Replace Hosts File", id="replace-button", variant="error") + + def on_mount(self) -> None: + self.query_one("#cancel-button", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "replace-button": + self.dismiss(True) + elif event.button.id == "cancel-button": + self.action_cancel() + + def action_cancel(self) -> None: + self.dismiss(False) + + class ParentDirectoryTree(DirectoryTree): """Directory tree with a conventional parent entry above its root.""" @@ -378,6 +458,12 @@ def _format_label(format: ExportFormat | ImportFormat) -> str: }[format.value] +def _entry_count(count: int, qualifier: str) -> str: + """Describe a qualified number of Host Entries with correct grammar.""" + noun = "Host Entry" if count == 1 else "Host Entries" + return f"{count} {qualifier} {noun}" + + def _format_suffix(format: ExportFormat) -> str: """Provide an editable default filename after choosing an export folder.""" return { diff --git a/tests/test_import_export_workflow.py b/tests/test_import_export_workflow.py index 339d87f..8e2a6a7 100644 --- a/tests/test_import_export_workflow.py +++ b/tests/test_import_export_workflow.py @@ -15,7 +15,12 @@ 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, FileBrowserModal, ImportModal +from hosts.tui.import_export_modal import ( + ExportModal, + FileBrowserModal, + ImportModal, + ReplaceImportConfirmationModal, +) @pytest.mark.asyncio @@ -236,14 +241,45 @@ async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_err @pytest.mark.asyncio -async def test_successful_import_replaces_entries_and_saves_through_the_manager(): - """A valid import reaches the persistence seam without a real Hosts File.""" +async def test_import_form_asks_how_to_apply_entries_at_minimum_supported_size(): + """Both import actions remain visible and reachable at 100 by 30.""" + app = HostsManagerApp() + app.edit_mode = True + app.manager.edit_mode = True + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + + async with app.run_test(size=(100, 30)) as pilot: + app.action_import_entries() + await pilot.pause() + + assert isinstance(app.screen, ImportModal) + actions = app.screen.query_one("#import-mode-select", RadioSet) + assert [button.label.plain for button in actions.query(RadioButton)] == [ + "Add to Hosts File", + "Replace Hosts File", + ] + assert app.screen.query_one("#mode-add", RadioButton).value + assert not app.screen.query_one("#mode-replace", RadioButton).value + container = app.screen.query_one("#format-container") + submit = app.screen.query_one("#import-button", Button) + assert submit.region.bottom <= container.region.bottom + + +@pytest.mark.asyncio +async def test_successful_import_adds_entries_and_saves_through_the_manager(): + """The safe default appends imported Host Entries to the Hosts File.""" app = HostsManagerApp() app.hosts_file = HostsFile( - entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])] + entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])], + header_comments=["existing header"], + footer_comments=["existing footer"], ) app.edit_mode = True app.manager.edit_mode = True + app.load_hosts_file = Mock() app.save_mutation = Mock(return_value=True) app.import_export_service = Mock() app.import_export_service.get_supported_import_formats.return_value = list( @@ -269,9 +305,74 @@ async def test_successful_import_replaces_entries_and_saves_through_the_manager( await pilot.pause() assert [entry.hostnames for entry in app.hosts_file.entries] == [ - ["imported.test"] + ["original.test"], + ["imported.test"], ] + assert app.hosts_file.header_comments == ["existing header"] + assert app.hosts_file.footer_comments == ["existing footer"] app.save_mutation.assert_called_once() - assert "Imported and saved 1 Host Entries" in str( + assert "Added and saved 1 Host Entry" in str( + app.query_one("#message-rail", Static).render() + ) + + +@pytest.mark.asyncio +async def test_replace_import_requires_explicit_destructive_confirmation(): + """Replacement cannot mutate until the warning modal is confirmed.""" + app = HostsManagerApp() + original = HostEntry(ip_address="192.0.2.10", hostnames=["original.test"]) + imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"]) + app.hosts_file = HostsFile(entries=[original]) + app.edit_mode = True + app.manager.edit_mode = True + app.load_hosts_file = Mock() + app.save_mutation = Mock(return_value=True) + app.import_export_service = Mock() + app.import_export_service.get_supported_import_formats.return_value = list( + ImportFormat + ) + app.import_export_service.import_json_format.return_value = ImportResult( + success=True, + entries=[imported], + errors=[], + warnings=[], + total_processed=1, + successfully_imported=1, + ) + + async with app.run_test(size=(120, 40)) as pilot: + + async def request_replacement() -> None: + app.action_import_entries() + await pilot.pause() + app.screen.query_one("#path-input", Input).value = "/tmp/entries.json" + app.screen.query_one("#format-json", RadioButton).value = True + app.screen.query_one("#mode-replace", RadioButton).value = True + await pilot.click("#import-button") + await pilot.pause() + + await request_replacement() + + assert isinstance(app.screen, ReplaceImportConfirmationModal) + assert app.focused is app.screen.query_one("#cancel-button", Button) + assert "remove all 1 current Host Entry" in str( + app.screen.query_one("#replace-warning", Static).render() + ) + assert app.hosts_file.entries == [original] + app.save_mutation.assert_not_called() + + await pilot.click("#cancel-button") + await pilot.pause() + assert app.hosts_file.entries == [original] + app.save_mutation.assert_not_called() + + await request_replacement() + await pilot.click("#replace-button") + await pilot.pause() + await pilot.pause() + + assert app.hosts_file.entries == [imported] + app.save_mutation.assert_called_once() + assert "Replaced Hosts File with 1 imported Host Entry" in str( app.query_one("#message-rail", Static).render() ) -- 2.51.2