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