Feat #5: choose additive or replacement import

This commit is contained in:
Philip Henning 2026-09-08 18:20:50 +02:00
parent 48c9760e3c
commit 9040cf72a8
4 changed files with 251 additions and 22 deletions

View file

@ -22,7 +22,8 @@ on macOS and Linux.
- Creates a timestamped Pre-edit Backup before Privileged Mode begins. - Creates a timestamped Pre-edit Backup before Privileged Mode begins.
- Restores the current session's Pre-edit Backup after explicit confirmation. - Restores the current session's Pre-edit Backup after explicit confirmation.
- Supports undo and redo during the current Privileged Mode session. - Supports undo and redo during the current Privileged Mode session.
- Exports Host Entries as hosts, JSON, or CSV files, and imports those formats in Privileged Mode. - 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, The application starts in Read-only Mode. Default Entries cannot be edited,
deleted, activated, deactivated, or moved. deleted, activated, deactivated, or moved.

View file

@ -36,7 +36,14 @@ from .backup_restore_modal import BackupRestoreModal
from .delete_confirmation_modal import DeleteConfirmationModal from .delete_confirmation_modal import DeleteConfirmationModal
from .filter_modal import FilterModal from .filter_modal import FilterModal
from .help_modal import HelpModal from .help_modal import HelpModal
from .import_export_modal import ExportModal, ImportModal, ExportRequest, ImportRequest from .import_export_modal import (
ExportModal,
ImportModal,
ExportRequest,
ImportMode,
ImportRequest,
ReplaceImportConfirmationModal,
)
from .custom_footer import CustomFooter from .custom_footer import CustomFooter
from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS from .styles import HOSTS_DARK_THEME, HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS from .keybindings import HOSTS_MANAGER_BINDINGS
@ -955,19 +962,53 @@ class HostsManagerApp(App):
def import_file(format: ImportFormat, path): def import_file(format: ImportFormat, path):
return importers[format](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: def handle_import(request: ImportRequest | None) -> None:
if request is None: if request is None:
return return
result = request.result if request.mode is ImportMode.ADD:
snapshot = self.capture_mutation_state() apply_import(request)
self.hosts_file = HostsFile(entries=result.entries) return
self.selected_entry_index = 0
if self.save_mutation(snapshot, "Import"): def handle_replace_confirmation(confirmed: bool | None) -> None:
self.table_handler.populate_entries_table() if confirmed:
self.details_handler.update_entry_details() apply_import(request)
self.update_status(
f"✓ Imported and saved {result.successfully_imported} Host Entries from {request.path}" self.push_screen(
) ReplaceImportConfirmationModal(
len(self.hosts_file.entries), len(request.result.entries)
),
handle_replace_confirmation,
)
self.push_screen( self.push_screen(
ImportModal( ImportModal(

View file

@ -1,6 +1,7 @@
"""Keyboard-first import and export forms for Host Entries.""" """Keyboard-first import and export forms for Host Entries."""
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from pathlib import Path from pathlib import Path
from rich.text import Text from rich.text import Text
@ -21,12 +22,20 @@ class ExportRequest:
format: ExportFormat format: ExportFormat
class ImportMode(Enum):
"""How imported Host Entries are applied to the current Hosts File."""
ADD = "add"
REPLACE = "replace"
@dataclass(frozen=True) @dataclass(frozen=True)
class ImportRequest: class ImportRequest:
"""A validated request to replace the Hosts File from an import.""" """A validated request to apply imported Host Entries."""
path: Path path: Path
format: ImportFormat format: ImportFormat
mode: ImportMode
result: ImportResult result: ImportResult
@ -55,6 +64,10 @@ class _FormatModal(ModalScreen):
height: 5; margin-top: 1; padding: 0 1; border: round $primary; height: 5; margin-top: 1; padding: 0 1; border: round $primary;
} }
#format-select { height: 3; margin: 0; padding: 0; border: none; } #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; } .button-row { margin-top: 1; height: 3; align: center middle; }
""" """
@ -181,7 +194,7 @@ class ExportModal(_FormatModal):
class ImportModal(_FormatModal): class ImportModal(_FormatModal):
"""Collect an import file and format before replacing Host Entries.""" """Collect an import file, format, and application mode."""
def __init__(self, formats: list[ImportFormat], import_file): def __init__(self, formats: list[ImportFormat], import_file):
super().__init__() super().__init__()
@ -192,7 +205,7 @@ class ImportModal(_FormatModal):
with Vertical(id="format-container"): with Vertical(id="format-container"):
yield Static("Import Host Entries", classes="format-title") yield Static("Import Host Entries", classes="format-title")
yield Static( yield Static(
"Import replaces the current Host Entries and saves the Hosts File.", "Choose a file, format, and how to apply its Host Entries.",
classes="format-copy", classes="format-copy",
) )
with Vertical(classes="path-section") as location: with Vertical(classes="path-section") as location:
@ -220,6 +233,15 @@ class ImportModal(_FormatModal):
value=index == 0, value=index == 0,
compact=True, 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"): 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")
@ -238,7 +260,12 @@ class ImportModal(_FormatModal):
if not result.success: if not result.success:
self._show_message("#workflow-error", "; ".join(result.errors)) self._show_message("#workflow-error", "; ".join(result.errors))
return return
self.dismiss(ImportRequest(path, format, result)) 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": elif event.button.id == "browse-button":
self.action_browse() self.action_browse()
@ -256,6 +283,59 @@ class ImportModal(_FormatModal):
self.app.push_screen(FileBrowserModal(start, choose_directory=False), selected) 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): class ParentDirectoryTree(DirectoryTree):
"""Directory tree with a conventional parent entry above its root.""" """Directory tree with a conventional parent entry above its root."""
@ -378,6 +458,12 @@ def _format_label(format: ExportFormat | ImportFormat) -> str:
}[format.value] }[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: def _format_suffix(format: ExportFormat) -> str:
"""Provide an editable default filename after choosing an export folder.""" """Provide an editable default filename after choosing an export folder."""
return { return {

View file

@ -15,7 +15,12 @@ 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, FileBrowserModal, ImportModal from hosts.tui.import_export_modal import (
ExportModal,
FileBrowserModal,
ImportModal,
ReplaceImportConfirmationModal,
)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -236,14 +241,45 @@ async def test_import_shortcut_requires_privileged_mode_and_surfaces_service_err
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_successful_import_replaces_entries_and_saves_through_the_manager(): async def test_import_form_asks_how_to_apply_entries_at_minimum_supported_size():
"""A valid import reaches the persistence seam without a real Hosts File.""" """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 = HostsManagerApp()
app.hosts_file = HostsFile( app.hosts_file = HostsFile(
entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])] entries=[HostEntry(ip_address="192.0.2.10", hostnames=["original.test"])],
header_comments=["existing header"],
footer_comments=["existing footer"],
) )
app.edit_mode = True app.edit_mode = True
app.manager.edit_mode = True app.manager.edit_mode = True
app.load_hosts_file = Mock()
app.save_mutation = Mock(return_value=True) app.save_mutation = Mock(return_value=True)
app.import_export_service = Mock() app.import_export_service = Mock()
app.import_export_service.get_supported_import_formats.return_value = list( app.import_export_service.get_supported_import_formats.return_value = list(
@ -269,9 +305,74 @@ async def test_successful_import_replaces_entries_and_saves_through_the_manager(
await pilot.pause() await pilot.pause()
assert [entry.hostnames for entry in app.hosts_file.entries] == [ assert [entry.hostnames for entry in app.hosts_file.entries] == [
["imported.test"] ["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() app.save_mutation.assert_called_once()
assert "Imported and saved 1 Host Entries" in str( 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() app.query_one("#message-rail", Static).render()
) )