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
7 changed files with 992 additions and 0 deletions

View file

@ -22,6 +22,8 @@ on macOS and Linux.
- Creates a timestamped Pre-edit Backup before Privileged Mode begins.
- Restores the current session's Pre-edit Backup after explicit confirmation.
- Supports undo and redo during the current Privileged Mode session.
- Exports Host Entries as hosts, JSON, or CSV files. In Privileged Mode,
imports can add Host Entries or replace the Hosts File after confirmation.
The application starts in Read-only Mode. Default Entries cannot be edited,
deleted, activated, deactivated, or moved.
@ -76,6 +78,8 @@ including persistence behavior and manual recovery.
| `Space` | Activate or deactivate the selected Host Entry |
| `Ctrl+S` | Save the current in-memory state |
| `Ctrl+F` | Open advanced filters |
| `Ctrl+X` | Export Host Entries (hosts, JSON, or CSV) |
| `Ctrl+O` | Import Host Entries from hosts, JSON, or CSV (Privileged Mode) |
| `?` | Show help |
| `q` or `Ctrl+C` | Quit |

View file

@ -102,6 +102,18 @@ Both actions require Privileged Mode because a successful result changes
`/etc/hosts`. A failed lookup reports the failure and keeps the previously
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, 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 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.
## Verify what is on disk
The details shown by the application normally match the last successful save.
@ -247,6 +259,7 @@ continually retrying it.
| `Ctrl+R` | Reload `/etc/hosts` |
| `Ctrl+F` | Open advanced filters |
| `c` | Open configuration |
| `Ctrl+X` | Export Host Entries |
| `?` | Show help |
| `q` or `Ctrl+C` | Quit |
| `Ctrl+E` | Enter or leave Privileged Mode |
@ -266,6 +279,7 @@ continually retrying it.
| `Ctrl+Z` / `Ctrl+Y` | Undo or redo and save the result |
| `Ctrl+S` | Save the current in-memory state |
| `b` | Review session backup changes and confirm restoration |
| `Ctrl+O` | Import Host Entries from hosts, JSON, or CSV |
Within the Entry Editor, use `Tab` and `Shift+Tab` to move between fields and
`Escape` to leave the form. If values changed, the application asks whether to

View file

@ -29,12 +29,21 @@ from ..core.manager import HostsManager, MutationState
from ..core.restore_preview import RestorePreviewError, create_restore_preview
from ..core.dns import DNSService
from ..core.filters import EntryFilter, FilterOptions
from ..core.import_export import ImportExportService, ExportFormat, ImportFormat
from .config_modal import ConfigModal
from .add_entry_modal import AddEntryModal
from .backup_restore_modal import BackupRestoreModal
from .delete_confirmation_modal import DeleteConfirmationModal
from .filter_modal import FilterModal
from .help_modal import HelpModal
from .import_export_modal import (
ExportModal,
ImportModal,
ExportRequest,
ImportMode,
ImportRequest,
ReplaceImportConfirmationModal,
)
from .custom_footer import CustomFooter
from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS
@ -85,6 +94,7 @@ class HostsManagerApp(App):
self.parser = HostsParser()
self.config = Config()
self.manager = HostsManager()
self.import_export_service = ImportExportService()
# Initialize DNS service
dns_config = self.config.get("dns_resolution", {})
@ -904,6 +914,109 @@ class HostsManagerApp(App):
return
self.navigation_handler.save_hosts_file()
def action_export_entries(self) -> None:
"""Export the in-memory Host Entries without requiring Privileged Mode."""
def handle_export(request: ExportRequest | None) -> None:
if request is None:
return
exporters = {
ExportFormat.HOSTS: self.import_export_service.export_hosts_format,
ExportFormat.JSON: self.import_export_service.export_json_format,
ExportFormat.CSV: self.import_export_service.export_csv_format,
}
result = exporters[request.format](self.hosts_file, request.path)
if result.success:
self.update_status(
f"✓ Exported {result.entries_exported} Host Entries to {result.file_path}"
)
else:
self.update_status(
f"Error exporting Host Entries: {'; '.join(result.errors)}"
)
self.push_screen(
ExportModal(
self.import_export_service.get_supported_export_formats(),
self.import_export_service.validate_export_path,
),
handle_export,
)
def action_import_entries(self) -> None:
"""Import and persist replacement Host Entries in Privileged Mode."""
if not self._allow_mutation_action():
return
if not self.edit_mode:
self.update_status(
"Cannot import Host Entries in Read-only Mode. Enter Privileged Mode first."
)
return
importers = {
ImportFormat.HOSTS: self.import_export_service.import_hosts_format,
ImportFormat.JSON: self.import_export_service.import_json_format,
ImportFormat.CSV: self.import_export_service.import_csv_format,
}
def import_file(format: ImportFormat, path):
return importers[format](path)
def apply_import(request: ImportRequest) -> None:
result = request.result
snapshot = self.capture_mutation_state()
if request.mode is ImportMode.ADD:
self.hosts_file = HostsFile(
entries=[*self.hosts_file.entries, *result.entries],
header_comments=list(self.hosts_file.header_comments),
footer_comments=list(self.hosts_file.footer_comments),
)
action = "Add imported Host Entries"
outcome = "Added and saved"
else:
self.hosts_file = HostsFile(entries=list(result.entries))
action = "Replace Hosts File from import"
outcome = "Replaced Hosts File with"
self.selected_entry_index = 0
if self.save_mutation(snapshot, action):
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
noun = (
"Host Entry"
if result.successfully_imported == 1
else "Host Entries"
)
self.update_status(
f"{outcome} {result.successfully_imported} "
f"{'imported ' if request.mode is ImportMode.REPLACE else ''}"
f"{noun} from {request.path}"
)
def handle_import(request: ImportRequest | None) -> None:
if request is None:
return
if request.mode is ImportMode.ADD:
apply_import(request)
return
def handle_replace_confirmation(confirmed: bool | None) -> None:
if confirmed:
apply_import(request)
self.push_screen(
ReplaceImportConfirmationModal(
len(self.hosts_file.entries), len(request.result.entries)
),
handle_replace_confirmation,
)
self.push_screen(
ImportModal(
self.import_export_service.get_supported_import_formats(), import_file
),
handle_import,
)
def action_add_entry(self) -> None:
"""Show the add entry modal."""
if not self._allow_add_entry_action():

View file

@ -48,6 +48,10 @@ class HelpModal(ModalScreen[None]):
"Type in Search for immediate filtering Ctrl+F Advanced filters",
classes="help-copy",
)
yield Static(
"Ctrl+X Export Host Entries Ctrl+O Import Host Entries",
classes="help-copy",
)
yield Static("Privileged Mode", classes="help-section")
yield Static(
"Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n"

View file

@ -0,0 +1,473 @@
"""Keyboard-first import and export forms for Host Entries."""
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from rich.text import Text
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, RadioButton, RadioSet, Static
from ..core.import_export import ExportFormat, ImportFormat, ImportResult
@dataclass(frozen=True)
class ExportRequest:
"""A validated request to export the displayed Hosts File."""
path: Path
format: ExportFormat
class ImportMode(Enum):
"""How imported Host Entries are applied to the current Hosts File."""
ADD = "add"
REPLACE = "replace"
@dataclass(frozen=True)
class ImportRequest:
"""A validated request to apply imported Host Entries."""
path: Path
format: ImportFormat
mode: ImportMode
result: ImportResult
class _FormatModal(ModalScreen):
"""Shared form mechanics for selecting a file and supported format."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
CSS = """
ExportModal, ImportModal { align: center middle; }
#format-container {
width: 72; height: auto; max-height: 90%; background: $surface;
border: thick $primary; padding: 1 2;
}
.format-title { text-align: center; text-style: bold; color: $primary; }
.format-copy { margin-top: 1; color: $text-muted; }
.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; }
.import-mode-section {
height: 4; margin-top: 1; padding: 0 1; border: round $primary;
}
#import-mode-select { height: 2; margin: 0; padding: 0; border: none; }
.button-row { margin-top: 1; height: 3; align: center middle; }
"""
def _selected_format(self, enum_type):
for member in enum_type:
if self.query_one(f"#format-{member.value}", RadioButton).value:
return member
return next(iter(enum_type))
def _path_or_error(self) -> Path | None:
value = self.query_one("#path-input", Input).value.strip()
if value:
self._show_message("#workflow-error", "")
return Path(value).expanduser()
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)
class ExportModal(_FormatModal):
"""Collect an export destination and require confirmation for overwrite."""
def __init__(self, formats: list[ExportFormat], validate_path):
super().__init__()
self._formats = formats
self._validate_path = validate_path
self._request: ExportRequest | None = None
def compose(self) -> ComposeResult:
with Vertical(id="format-container"):
yield Static("Export Host Entries", classes="format-title")
yield Static(
"Choose a destination and format. Existing files require confirmation.",
classes="format-copy",
)
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",
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", 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")
yield Button("Continue", id="continue-button", variant="primary")
yield Button("Overwrite export", id="confirm-button", variant="error")
def on_mount(self) -> None:
self.query_one("#path-input", Input).focus()
self.query_one("#confirm-button", Button).display = False
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel-button":
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)
def action_continue(self) -> None:
path = self._path_or_error()
if path is None:
return
request = ExportRequest(path, self._selected_format(ExportFormat))
warnings = self._validate_path(path, request.format)
self._request = request
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._show_message(
"#workflow-error",
"Correct the destination before exporting: "
+ "; ".join(blocking_warnings),
)
return
if warnings:
self.query_one("#confirm-button", Button).display = True
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, format, and application mode."""
def __init__(self, formats: list[ImportFormat], import_file):
super().__init__()
self._formats = formats
self._import_file = import_file
def compose(self) -> ComposeResult:
with Vertical(id="format-container"):
yield Static("Import Host Entries", classes="format-title")
yield Static(
"Choose a file, format, and how to apply its Host Entries.",
classes="format-copy",
)
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",
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", 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 Vertical(classes="import-mode-section") as modes:
modes.border_title = "Import action"
with RadioSet(id="import-mode-select", compact=True):
yield RadioButton(
"Add to Hosts File", id="mode-add", value=True, compact=True
)
yield RadioButton(
"Replace Hosts File", id="mode-replace", compact=True
)
with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button")
yield Button("Import and save", id="import-button", variant="primary")
def on_mount(self) -> None:
self.query_one("#path-input", Input).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel-button":
self.action_cancel()
elif event.button.id == "import-button":
path = self._path_or_error()
if path is not None:
format = self._selected_format(ImportFormat)
result = self._import_file(format, path)
if not result.success:
self._show_message("#workflow-error", "; ".join(result.errors))
return
mode = (
ImportMode.REPLACE
if self.query_one("#mode-replace", RadioButton).value
else ImportMode.ADD
)
self.dismiss(ImportRequest(path, format, mode, 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 ReplaceImportConfirmationModal(ModalScreen[bool]):
"""Warn before replacing every current Host Entry with imported entries."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
CSS = """
ReplaceImportConfirmationModal { align: center middle; }
#replace-import-confirmation-container {
width: 68; height: auto; max-width: 90%; background: $surface;
border: thick $error; padding: 1 2;
}
.replace-title {
text-align: center; text-style: bold; color: $error;
}
#replace-warning { height: auto; margin-top: 1; color: $warning; }
.replace-consequence { height: auto; margin-top: 1; }
.button-row { margin-top: 1; height: 3; align: center middle; }
"""
def __init__(self, current_count: int, imported_count: int):
super().__init__()
self._current_count = current_count
self._imported_count = imported_count
def compose(self) -> ComposeResult:
with Vertical(id="replace-import-confirmation-container"):
yield Static("Replace Hosts File?", classes="replace-title")
yield Static(
f"This will remove all {_entry_count(self._current_count, 'current')} "
f"and replace the Hosts File with "
f"{_entry_count(self._imported_count, 'imported')}.",
id="replace-warning",
)
yield Static(
"The current Hosts File will not be changed unless you confirm.",
classes="replace-consequence",
)
with Horizontal(classes="button-row"):
yield Button("Cancel", id="cancel-button")
yield Button("Replace Hosts File", id="replace-button", variant="error")
def on_mount(self) -> None:
self.query_one("#cancel-button", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "replace-button":
self.dismiss(True)
elif event.button.id == "cancel-button":
self.action_cancel()
def action_cancel(self) -> None:
self.dismiss(False)
class ParentDirectoryTree(DirectoryTree):
"""Directory tree with a conventional parent entry above its root."""
PARENT_ICON = ""
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,
)
def render_label(self, node, base_style, style) -> Text:
"""Render the parent entry as navigation rather than as a file."""
if str(node.label) != "..":
return super().render_label(node, base_style, style)
label = node.label.copy()
label.stylize(style)
label.stylize_before(
self.get_component_rich_style("directory-tree--folder", partial=True)
)
return Text.assemble((self.PARENT_ICON, base_style), label)
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._current_path = self._start_path
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. Select .. to go up; expand folders with Right.",
classes="browser-copy",
)
yield Static(str(self._start_path), id="file-browser-status")
yield ParentDirectoryTree(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:
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 == "cancel-button":
self.action_cancel()
elif event.button.id == "choose-directory-button":
self.dismiss(self._selected_directory or self._current_path)
def action_cancel(self) -> None:
self.dismiss(None)
def _format_label(format: ExportFormat | ImportFormat) -> str:
"""Give the core-supported formats concise, file-oriented labels."""
return {
"hosts": "Hosts file (.hosts)",
"json": "JSON (.json)",
"csv": "CSV (.csv)",
}[format.value]
def _entry_count(count: int, qualifier: str) -> str:
"""Describe a qualified number of Host Entries with correct grammar."""
noun = "Host Entry" if count == 1 else "Host Entries"
return f"{count} {qualifier} {noun}"
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

@ -34,6 +34,12 @@ HOSTS_MANAGER_BINDINGS = [
id="left:toggle_edit_mode",
),
Binding("c", "config", "Configuration", show=True, id="right:config"),
Binding(
"ctrl+x", "export_entries", "Export Host Entries", show=False, priority=True
),
Binding(
"ctrl+o", "import_entries", "Import Host Entries", show=False, priority=True
),
Binding(
"ctrl+f",
"show_filters",

View file

@ -0,0 +1,378 @@
"""User-visible import and export workflows."""
from unittest.mock import Mock
import pytest
from rich.style import Style
from textual.containers import Vertical
from textual.widgets import Button, DirectoryTree, Input, RadioButton, RadioSet, Static
from hosts.core.import_export import (
ExportFormat,
ExportResult,
ImportFormat,
ImportResult,
)
from hosts.core.models import HostEntry, HostsFile
from hosts.tui.app import HostsManagerApp
from hosts.tui.import_export_modal import (
ExportModal,
FileBrowserModal,
ImportModal,
ReplaceImportConfirmationModal,
)
@pytest.mark.asyncio
async def test_export_shortcut_opens_a_format_chooser_and_reports_validation():
"""Export presents every core format and keeps invalid input visible."""
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])]
)
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+x")
await pilot.pause()
assert isinstance(app.screen, ExportModal)
assert [button.label.plain for button in app.screen.query(RadioButton)] == [
"Hosts file (.hosts)",
"JSON (.json)",
"CSV (.csv)",
]
app.screen.query_one("#path-input", Input).value = ""
await pilot.click("#continue-button")
assert "Choose a file path" in str(
app.screen.query_one("#workflow-error", Static).render()
)
@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_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."""
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()
browse = app.screen.query_one("#browse-button", Button)
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
assert browse.region.bottom <= container.region.bottom
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_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()
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)
tree = browser.query_one("#file-browser-tree", DirectoryTree)
await pilot.pause()
assert str(tree.root.children[0].label) == ".."
assert tree.render_label(
tree.root.children[0], Style(), Style()
).plain.startswith("")
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
async def test_export_requires_overwrite_confirmation_before_calling_service(tmp_path):
"""An existing output file is not replaced until the user confirms it."""
target = tmp_path / "hosts.json"
target.write_text("existing", encoding="utf-8")
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["export.test"])]
)
app.import_export_service = Mock()
app.import_export_service.get_supported_export_formats.return_value = list(
ExportFormat
)
app.import_export_service.validate_export_path.return_value = [
f"File {target} already exists and will be overwritten"
]
app.import_export_service.export_json_format.return_value = ExportResult(
success=True,
file_path=target,
entries_exported=1,
errors=[],
format=ExportFormat.JSON,
)
async with app.run_test(size=(120, 40)) as pilot:
app.action_export_entries()
await pilot.pause()
app.screen.query_one("#path-input", Input).value = str(target)
app.screen.query_one("#format-json", RadioButton).value = True
await pilot.click("#continue-button")
assert "already exists" in str(
app.screen.query_one("#workflow-warning", Static).render()
)
app.import_export_service.export_json_format.assert_not_called()
await pilot.click("#confirm-button")
await pilot.pause()
await pilot.pause()
app.import_export_service.export_json_format.assert_called_once_with(
app.hosts_file, target
)
@pytest.mark.asyncio
async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_errors():
"""Import cannot mutate in Read-only Mode and reports parser failures."""
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])]
)
app.load_hosts_file = Mock()
async with app.run_test(size=(120, 40)) as pilot:
await pilot.press("ctrl+o")
await pilot.pause()
assert not isinstance(app.screen, ImportModal)
assert "Read-only Mode" in str(app.query_one("#message-rail", Static).render())
app.edit_mode = True
app.manager.edit_mode = True
app.import_export_service = Mock()
app.import_export_service.get_supported_import_formats.return_value = list(
ImportFormat
)
app.import_export_service.import_csv_format.return_value = Mock(
success=False, errors=["CSV column 'ip_address' is missing"]
)
app.action_import_entries()
await pilot.pause()
assert isinstance(app.screen, ImportModal)
app.screen.query_one("#path-input", Input).value = "/tmp/broken.csv"
csv_button = app.screen.query_one("#format-csv", RadioButton)
csv_button.value = True
await pilot.click("#import-button")
await pilot.pause()
assert "CSV column 'ip_address' is missing" in str(
app.screen.query_one("#workflow-error", Static).render()
)
@pytest.mark.asyncio
async def test_import_form_asks_how_to_apply_entries_at_minimum_supported_size():
"""Both import actions remain visible and reachable at 100 by 30."""
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.import_export_service = Mock()
app.import_export_service.get_supported_import_formats.return_value = list(
ImportFormat
)
async with app.run_test(size=(100, 30)) as pilot:
app.action_import_entries()
await pilot.pause()
assert isinstance(app.screen, ImportModal)
actions = app.screen.query_one("#import-mode-select", RadioSet)
assert [button.label.plain for button in actions.query(RadioButton)] == [
"Add to Hosts File",
"Replace Hosts File",
]
assert app.screen.query_one("#mode-add", RadioButton).value
assert not app.screen.query_one("#mode-replace", RadioButton).value
container = app.screen.query_one("#format-container")
submit = app.screen.query_one("#import-button", Button)
assert submit.region.bottom <= container.region.bottom
@pytest.mark.asyncio
async def test_successful_import_adds_entries_and_saves_through_the_manager():
"""The safe default appends imported Host Entries to the Hosts File."""
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])],
header_comments=["existing header"],
footer_comments=["existing footer"],
)
app.edit_mode = True
app.manager.edit_mode = True
app.load_hosts_file = Mock()
app.save_mutation = Mock(return_value=True)
app.import_export_service = Mock()
app.import_export_service.get_supported_import_formats.return_value = list(
ImportFormat
)
imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"])
app.import_export_service.import_json_format.return_value = ImportResult(
success=True,
entries=[imported],
errors=[],
warnings=[],
total_processed=1,
successfully_imported=1,
)
async with app.run_test(size=(120, 40)) as pilot:
app.action_import_entries()
await pilot.pause()
app.screen.query_one("#path-input", Input).value = "/tmp/entries.json"
app.screen.query_one("#format-json", RadioButton).value = True
await pilot.click("#import-button")
await pilot.pause()
await pilot.pause()
assert [entry.hostnames for entry in app.hosts_file.entries] == [
["original.test"],
["imported.test"],
]
assert app.hosts_file.header_comments == ["existing header"]
assert app.hosts_file.footer_comments == ["existing footer"]
app.save_mutation.assert_called_once()
assert "Added and saved 1 Host Entry" in str(
app.query_one("#message-rail", Static).render()
)
@pytest.mark.asyncio
async def test_replace_import_requires_explicit_destructive_confirmation():
"""Replacement cannot mutate until the warning modal is confirmed."""
app = HostsManagerApp()
original = HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])
imported = HostEntry(ip_address="198.51.100.3", hostnames=["imported.test"])
app.hosts_file = HostsFile(entries=[original])
app.edit_mode = True
app.manager.edit_mode = True
app.load_hosts_file = Mock()
app.save_mutation = Mock(return_value=True)
app.import_export_service = Mock()
app.import_export_service.get_supported_import_formats.return_value = list(
ImportFormat
)
app.import_export_service.import_json_format.return_value = ImportResult(
success=True,
entries=[imported],
errors=[],
warnings=[],
total_processed=1,
successfully_imported=1,
)
async with app.run_test(size=(120, 40)) as pilot:
async def request_replacement() -> None:
app.action_import_entries()
await pilot.pause()
app.screen.query_one("#path-input", Input).value = "/tmp/entries.json"
app.screen.query_one("#format-json", RadioButton).value = True
app.screen.query_one("#mode-replace", RadioButton).value = True
await pilot.click("#import-button")
await pilot.pause()
await request_replacement()
assert isinstance(app.screen, ReplaceImportConfirmationModal)
assert app.focused is app.screen.query_one("#cancel-button", Button)
assert "remove all 1 current Host Entry" in str(
app.screen.query_one("#replace-warning", Static).render()
)
assert app.hosts_file.entries == [original]
app.save_mutation.assert_not_called()
await pilot.click("#cancel-button")
await pilot.pause()
assert app.hosts_file.entries == [original]
app.save_mutation.assert_not_called()
await request_replacement()
await pilot.click("#replace-button")
await pilot.pause()
await pilot.pause()
assert app.hosts_file.entries == [imported]
app.save_mutation.assert_called_once()
assert "Replaced Hosts File with 1 imported Host Entry" in str(
app.query_one("#message-rail", Static).render()
)