t3code/implement-issue-five #20
3 changed files with 182 additions and 7 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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")
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue