Fix #5: make file chooser navigable

This commit is contained in:
Philip Henning 2026-09-08 17:19:41 +02:00
parent 1b19d5eca2
commit 41a96b8156
2 changed files with 83 additions and 31 deletions

View file

@ -5,7 +5,7 @@ from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding 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.screen import ModalScreen
from textual.widgets import ( from textual.widgets import (
Button, Button,
@ -49,10 +49,13 @@ class _FormatModal(ModalScreen):
} }
.format-title { text-align: center; text-style: bold; color: $primary; } .format-title { text-align: center; text-style: bold; color: $primary; }
.format-copy { margin-top: 1; color: $text-muted; } .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-error { color: $error; height: auto; margin-top: 1; }
#workflow-warning { color: $warning; 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; } .button-row { margin-top: 1; height: 3; align: center middle; }
""" """
@ -90,9 +93,15 @@ class ExportModal(_FormatModal):
"Choose a destination and format. Existing files require confirmation.", "Choose a destination and format. Existing files require confirmation.",
classes="format-copy", classes="format-copy",
) )
with Vertical(classes="form-section"):
yield Label("Export file path") yield Label("Export file path")
with Grid(id="path-row"):
yield Input(placeholder="/path/to/entries.hosts", id="path-input") yield Input(placeholder="/path/to/entries.hosts", id="path-input")
yield Button("Browse…", id="browse-button", classes="browse-button") 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"): with RadioSet(id="format-select"):
for index, format in enumerate(self._formats): for index, format in enumerate(self._formats):
yield RadioButton( yield RadioButton(
@ -100,8 +109,6 @@ class ExportModal(_FormatModal):
id=f"format-{format.value}", id=f"format-{format.value}",
value=index == 0, value=index == 0,
) )
yield Static("", id="workflow-error")
yield Static("", id="workflow-warning")
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button") yield Button("Cancel", id="cancel-button")
yield Button("Continue", id="continue-button", variant="primary") yield Button("Continue", id="continue-button", variant="primary")
@ -174,9 +181,14 @@ class ImportModal(_FormatModal):
"Import replaces the current Host Entries and saves the Hosts File.", "Import replaces the current Host Entries and saves the Hosts File.",
classes="format-copy", classes="format-copy",
) )
with Vertical(classes="form-section"):
yield Label("Import file path") yield Label("Import file path")
with Grid(id="path-row"):
yield Input(placeholder="/path/to/entries.hosts", id="path-input") yield Input(placeholder="/path/to/entries.hosts", id="path-input")
yield Button("Browse…", id="browse-button", classes="browse-button") 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"): with RadioSet(id="format-select"):
for index, format in enumerate(self._formats): for index, format in enumerate(self._formats):
yield RadioButton( yield RadioButton(
@ -184,7 +196,6 @@ class ImportModal(_FormatModal):
id=f"format-{format.value}", id=f"format-{format.value}",
value=index == 0, value=index == 0,
) )
yield Static("", id="workflow-error")
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button") yield Button("Cancel", id="cancel-button")
yield Button("Import and save", id="import-button", variant="primary") yield Button("Import and save", id="import-button", variant="primary")
@ -226,7 +237,10 @@ class ImportModal(_FormatModal):
class FileBrowserModal(ModalScreen[Path | None]): class FileBrowserModal(ModalScreen[Path | None]):
"""A keyboard-accessible directory tree for file and folder selection.""" """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 = """ CSS = """
FileBrowserModal { align: center middle; } FileBrowserModal { align: center middle; }
#file-browser-container { #file-browser-container {
@ -242,6 +256,7 @@ class FileBrowserModal(ModalScreen[Path | None]):
def __init__(self, start_path: Path, *, choose_directory: bool): def __init__(self, start_path: Path, *, choose_directory: bool):
super().__init__() super().__init__()
self._start_path = start_path if start_path.is_dir() else Path.cwd() 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._choose_directory = choose_directory
self._selected_directory: Path | None = None 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 Static(str(self._start_path), id="file-browser-status")
yield DirectoryTree(self._start_path, id="file-browser-tree") yield DirectoryTree(self._start_path, id="file-browser-tree")
with Horizontal(classes="button-row"): with Horizontal(classes="button-row"):
yield Button("Up", id="up-button")
yield Button("Cancel", id="cancel-button") yield Button("Cancel", id="cancel-button")
if self._choose_directory: if self._choose_directory:
yield Button( yield Button(
@ -288,7 +304,9 @@ class FileBrowserModal(ModalScreen[Path | None]):
self.query_one("#file-browser-status", Static).update(str(event.path)) self.query_one("#file-browser-status", Static).update(str(event.path))
def on_button_pressed(self, event: Button.Pressed) -> None: 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() self.action_cancel()
elif event.button.id == "choose-directory-button": elif event.button.id == "choose-directory-button":
self.dismiss(self._selected_directory or self._start_path) self.dismiss(self._selected_directory or self._start_path)
@ -296,6 +314,19 @@ class FileBrowserModal(ModalScreen[Path | None]):
def action_cancel(self) -> None: def action_cancel(self) -> None:
self.dismiss(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: def _format_label(format: ExportFormat | ImportFormat) -> str:
"""Give the core-supported formats concise, file-oriented labels.""" """Give the core-supported formats concise, file-oriented labels."""

View file

@ -3,7 +3,7 @@
from unittest.mock import Mock from unittest.mock import Mock
import pytest 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 ( from hosts.core.import_export import (
ExportFormat, 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) path_input = app.screen.query_one("#path-input", Input)
container = app.screen.query_one("#format-container") container = app.screen.query_one("#format-container")
assert browse.region.width > 0 assert browse.region.width > 0
assert browse.region.x >= path_input.region.x assert browse.region.x >= path_input.region.right
assert browse.region.y >= path_input.region.bottom assert browse.region.y == path_input.region.y
assert browse.region.right <= container.region.right assert browse.region.right <= container.region.right
assert browse.region.bottom <= container.region.bottom assert browse.region.bottom <= container.region.bottom
await pilot.press("tab") 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") container = app.screen.query_one("#file-browser-container")
assert container.region.x == (app.size.width - container.region.width) // 2 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("tab") await pilot.press("tab")
await pilot.press("enter") 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 @pytest.mark.asyncio
async def test_export_requires_overwrite_confirmation_before_calling_service(tmp_path): async def test_export_requires_overwrite_confirmation_before_calling_service(tmp_path):
"""An existing output file is not replaced until the user confirms it.""" """An existing output file is not replaced until the user confirms it."""