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
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.

View file

@ -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]

View file

@ -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."""