t3code/implement-issue-five #20
7 changed files with 992 additions and 0 deletions
|
|
@ -22,6 +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. 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.
|
||||||
|
|
@ -76,6 +78,8 @@ including persistence behavior and manual recovery.
|
||||||
| `Space` | Activate or deactivate the selected Host Entry |
|
| `Space` | Activate or deactivate the selected Host Entry |
|
||||||
| `Ctrl+S` | Save the current in-memory state |
|
| `Ctrl+S` | Save the current in-memory state |
|
||||||
| `Ctrl+F` | Open advanced filters |
|
| `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 |
|
| `?` | Show help |
|
||||||
| `q` or `Ctrl+C` | Quit |
|
| `q` or `Ctrl+C` | Quit |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
`/etc/hosts`. A failed lookup reports the failure and keeps the previously
|
||||||
stored mapping.
|
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
|
## Verify what is on disk
|
||||||
|
|
||||||
The details shown by the application normally match the last successful save.
|
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+R` | Reload `/etc/hosts` |
|
||||||
| `Ctrl+F` | Open advanced filters |
|
| `Ctrl+F` | Open advanced filters |
|
||||||
| `c` | Open configuration |
|
| `c` | Open configuration |
|
||||||
|
| `Ctrl+X` | Export Host Entries |
|
||||||
| `?` | Show help |
|
| `?` | Show help |
|
||||||
| `q` or `Ctrl+C` | Quit |
|
| `q` or `Ctrl+C` | Quit |
|
||||||
| `Ctrl+E` | Enter or leave Privileged Mode |
|
| `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+Z` / `Ctrl+Y` | Undo or redo and save the result |
|
||||||
| `Ctrl+S` | Save the current in-memory state |
|
| `Ctrl+S` | Save the current in-memory state |
|
||||||
| `b` | Review session backup changes and confirm restoration |
|
| `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
|
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
|
`Escape` to leave the form. If values changed, the application asks whether to
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,21 @@ from ..core.manager import HostsManager, MutationState
|
||||||
from ..core.restore_preview import RestorePreviewError, create_restore_preview
|
from ..core.restore_preview import RestorePreviewError, create_restore_preview
|
||||||
from ..core.dns import DNSService
|
from ..core.dns import DNSService
|
||||||
from ..core.filters import EntryFilter, FilterOptions
|
from ..core.filters import EntryFilter, FilterOptions
|
||||||
|
from ..core.import_export import ImportExportService, ExportFormat, ImportFormat
|
||||||
from .config_modal import ConfigModal
|
from .config_modal import ConfigModal
|
||||||
from .add_entry_modal import AddEntryModal
|
from .add_entry_modal import AddEntryModal
|
||||||
from .backup_restore_modal import BackupRestoreModal
|
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,
|
||||||
|
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
|
||||||
|
|
@ -85,6 +94,7 @@ class HostsManagerApp(App):
|
||||||
self.parser = HostsParser()
|
self.parser = HostsParser()
|
||||||
self.config = Config()
|
self.config = Config()
|
||||||
self.manager = HostsManager()
|
self.manager = HostsManager()
|
||||||
|
self.import_export_service = ImportExportService()
|
||||||
|
|
||||||
# Initialize DNS service
|
# Initialize DNS service
|
||||||
dns_config = self.config.get("dns_resolution", {})
|
dns_config = self.config.get("dns_resolution", {})
|
||||||
|
|
@ -904,6 +914,109 @@ class HostsManagerApp(App):
|
||||||
return
|
return
|
||||||
self.navigation_handler.save_hosts_file()
|
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:
|
def action_add_entry(self) -> None:
|
||||||
"""Show the add entry modal."""
|
"""Show the add entry modal."""
|
||||||
if not self._allow_add_entry_action():
|
if not self._allow_add_entry_action():
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ class HelpModal(ModalScreen[None]):
|
||||||
"Type in Search for immediate filtering Ctrl+F Advanced filters",
|
"Type in Search for immediate filtering Ctrl+F Advanced filters",
|
||||||
classes="help-copy",
|
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("Privileged Mode", classes="help-section")
|
||||||
yield Static(
|
yield Static(
|
||||||
"Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n"
|
"Ctrl+E Enter or leave Privileged Mode n Add Host Entry\n"
|
||||||
|
|
|
||||||
473
src/hosts/tui/import_export_modal.py
Normal file
473
src/hosts/tui/import_export_modal.py
Normal 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]
|
||||||
|
|
@ -34,6 +34,12 @@ HOSTS_MANAGER_BINDINGS = [
|
||||||
id="left:toggle_edit_mode",
|
id="left:toggle_edit_mode",
|
||||||
),
|
),
|
||||||
Binding("c", "config", "Configuration", show=True, id="right:config"),
|
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(
|
Binding(
|
||||||
"ctrl+f",
|
"ctrl+f",
|
||||||
"show_filters",
|
"show_filters",
|
||||||
|
|
|
||||||
378
tests/test_import_export_workflow.py
Normal file
378
tests/test_import_export_workflow.py
Normal 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()
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue