t3code/implement-issue-five #20

Merged
phg merged 7 commits from t3code/implement-issue-five into main 2026-09-08 16:37:56 +00:00
3 changed files with 182 additions and 7 deletions
Showing only changes of commit 40aa66d164 - Show all commits

View file

@ -105,11 +105,12 @@ stored mapping.
## Import and export Host Entries ## Import and export Host Entries
Press `Ctrl+X` to export the current in-memory Host Entries. Choose hosts, JSON, or 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. 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 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 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. visible in the message rail and leave the current file unchanged.

View file

@ -7,7 +7,15 @@ 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 Horizontal, Vertical
from textual.screen import ModalScreen 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 from ..core.import_export import ExportFormat, ImportFormat, ImportResult
@ -34,7 +42,7 @@ class _FormatModal(ModalScreen):
BINDINGS = [Binding("escape", "cancel", "Cancel")] BINDINGS = [Binding("escape", "cancel", "Cancel")]
CSS = """ CSS = """
_FormatModal { align: center middle; } ExportModal, ImportModal { align: center middle; }
#format-container { #format-container {
width: 72; height: auto; max-height: 90%; background: $surface; width: 72; height: auto; max-height: 90%; background: $surface;
border: thick $primary; padding: 1 2; border: thick $primary; padding: 1 2;
@ -82,7 +90,9 @@ class ExportModal(_FormatModal):
classes="format-copy", classes="format-copy",
) )
yield Label("Export file path") 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"): with RadioSet(id="format-select"):
for index, format in enumerate(self._formats): for index, format in enumerate(self._formats):
yield RadioButton( yield RadioButton(
@ -106,6 +116,8 @@ class ExportModal(_FormatModal):
self.action_cancel() self.action_cancel()
elif event.button.id == "continue-button": elif event.button.id == "continue-button":
self.action_continue() self.action_continue()
elif event.button.id == "browse-button":
self.action_browse()
elif event.button.id == "confirm-button" and self._request is not None: elif event.button.id == "confirm-button" and self._request is not None:
self.dismiss(self._request) self.dismiss(self._request)
@ -130,6 +142,22 @@ class ExportModal(_FormatModal):
return return
self.dismiss(request) 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): class ImportModal(_FormatModal):
"""Collect an import file and format before replacing Host Entries.""" """Collect an import file and format before replacing Host Entries."""
@ -147,7 +175,9 @@ class ImportModal(_FormatModal):
classes="format-copy", classes="format-copy",
) )
yield Label("Import file path") 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"): with RadioSet(id="format-select"):
for index, format in enumerate(self._formats): for index, format in enumerate(self._formats):
yield RadioButton( yield RadioButton(
@ -177,6 +207,95 @@ class ImportModal(_FormatModal):
) )
return return
self.dismiss(ImportRequest(path, format, result)) 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: def _format_label(format: ExportFormat | ImportFormat) -> str:
@ -186,3 +305,12 @@ def _format_label(format: ExportFormat | ImportFormat) -> str:
"json": "JSON (.json)", "json": "JSON (.json)",
"csv": "CSV (.csv)", "csv": "CSV (.csv)",
}[format.value] }[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]

View file

@ -13,7 +13,7 @@ from hosts.core.import_export import (
) )
from hosts.core.models import HostEntry, HostsFile from hosts.core.models import HostEntry, HostsFile
from hosts.tui.app import HostsManagerApp 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 @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 @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."""