Fix #12: roll back mutations when saving fails

This commit is contained in:
Philip Henning 2026-09-04 09:28:37 +02:00
parent 2c5a8d969c
commit d04ab6feaf
9 changed files with 677 additions and 184 deletions

View file

@ -75,8 +75,6 @@ sorting, DNS refresh, movement, undo, and redo.
## Safety and known limitations ## Safety and known limitations
- Reloading discards unsaved in-memory state. - Reloading discards unsaved in-memory state.
- A failed save can leave the interface changed while `/etc/hosts` remains
unchanged.
- Pre-edit Backups are not listed or restored by the TUI and have no retention - Pre-edit Backups are not listed or restored by the TUI and have no retention
management. Manual recovery is documented in the user guide. management. Manual recovery is documented in the user guide.
- Leaving Privileged Mode clears the application's session state but does not - Leaving Privileged Mode clears the application's session state but does not

View file

@ -75,8 +75,9 @@ Default Entries represent protected baseline localhost or broadcasthost
mappings. They cannot be changed, deleted, activated, deactivated, or moved. mappings. They cannot be changed, deleted, activated, deactivated, or moved.
Successful mutations are saved to `/etc/hosts` immediately. Read the Successful mutations are saved to `/etc/hosts` immediately. Read the
status message after every action. A message that reports a save failure means status message after every action. If saving fails, the application restores
the interface may have changed while the system file did not. the Host Entries, visible selection, and undo/redo history from before the
action.
## Use a DNS Entry ## Use a DNS Entry
@ -120,13 +121,9 @@ Adding, editing, deleting, moving, activating, deactivating, undoing, redoing,
and successfully refreshing DNS Entries save immediately. `Ctrl+S` explicitly and successfully refreshing DNS Entries save immediately. `Ctrl+S` explicitly
saves the entire current in-memory Hosts File. saves the entire current in-memory Hosts File.
If saving an undo or redo result fails, the application restores the prior If an immediate save fails, the application restores the complete pre-action
in-memory state and undo/redo history. Reload with `Ctrl+R` to verify the in-memory state, visible selection, and undo/redo history. A later mutation
on-disk state before attempting another change. therefore cannot accidentally persist the failed change.
Other save failures can leave the interface changed while the file on disk is
unchanged. Reload with `Ctrl+R` to return the interface to the on-disk state
before attempting another change.
### Locate a Pre-edit Backup ### Locate a Pre-edit Backup

View file

@ -8,6 +8,8 @@ and safe file modifications with backup and validation.
import os import os
import subprocess import subprocess
import tempfile import tempfile
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from .models import HostEntry, HostsFile from .models import HostEntry, HostsFile
@ -23,6 +25,14 @@ from .commands import (
) )
@dataclass
class MutationState:
"""Hosts File and history state from before a persisted mutation."""
hosts_file: HostsFile
undo_redo_history: UndoRedoHistory
class PermissionManager: class PermissionManager:
""" """
Manages sudo permissions for hosts file editing. Manages sudo permissions for hosts file editing.
@ -591,6 +601,22 @@ class HostsManager:
"""Get description of the operation that would be redone.""" """Get description of the operation that would be redone."""
return self.undo_redo_history.get_redo_description() return self.undo_redo_history.get_redo_description()
def capture_mutation_state(self, hosts_file: HostsFile) -> MutationState:
"""Capture the Hosts File and history before a persisted mutation."""
saved_hosts_file, saved_history = deepcopy((hosts_file, self.undo_redo_history))
return MutationState(saved_hosts_file, saved_history)
def save_mutation(
self, hosts_file: HostsFile, state: MutationState
) -> Tuple[bool, str, HostsFile]:
"""Save a mutation or restore its pre-action model and history."""
success, message = self.save_hosts_file(hosts_file)
if success:
return True, message, hosts_file
self.undo_redo_history = state.undo_redo_history
return False, message, state.hosts_file
def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]: def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]:
""" """
Save the hosts file to disk with sudo permissions. Save the hosts file to disk with sudo permissions.

View file

@ -5,7 +5,7 @@ This module contains the main application class that orchestrates
all the handlers and provides the primary user interface. all the handlers and provides the primary user interface.
""" """
from collections.abc import Callable from dataclasses import dataclass
from textual.app import App, ComposeResult, SuspendNotSupported from textual.app import App, ComposeResult, SuspendNotSupported
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
@ -21,10 +21,9 @@ from textual.widgets import (
from textual.reactive import reactive from textual.reactive import reactive
from ..core.parser import HostsParser from ..core.parser import HostsParser
from ..core.models import HostsFile from ..core.models import HostEntry, HostsFile
from ..core.commands import OperationResult
from ..core.config import Config from ..core.config import Config
from ..core.manager import HostsManager from ..core.manager import HostsManager, MutationState
from ..core.dns import DNSService from ..core.dns import DNSService
from ..core.filters import EntryFilter, FilterOptions from ..core.filters import EntryFilter, FilterOptions
from .config_modal import ConfigModal from .config_modal import ConfigModal
@ -40,6 +39,14 @@ from .edit_handler import EditHandler
from .navigation_handler import NavigationHandler from .navigation_handler import NavigationHandler
@dataclass
class MutationSnapshot:
"""TUI and core state that must survive a failed persistence attempt."""
manager_state: MutationState
selected_entry_index: int
class HostsManagerApp(App): class HostsManagerApp(App):
""" """
Main application class for the hosts TUI manager. Main application class for the hosts TUI manager.
@ -353,6 +360,44 @@ class HostsManagerApp(App):
except Exception: except Exception:
pass pass
def capture_mutation_state(self) -> MutationSnapshot:
"""Capture the model, selection, and history before a mutation."""
return MutationSnapshot(
manager_state=self.manager.capture_mutation_state(self.hosts_file),
selected_entry_index=self.selected_entry_index,
)
def save_mutation(self, snapshot: MutationSnapshot, action: str) -> bool:
"""Persist a mutation, restoring its complete pre-action state on failure."""
save_success, save_message, hosts_file = self.manager.save_mutation(
self.hosts_file, snapshot.manager_state
)
if save_success:
return True
self.hosts_file = hosts_file
self.selected_entry_index = snapshot.selected_entry_index
self.table_handler.populate_entries_table()
self.table_handler.move_cursor_to_entry_index(self.selected_entry_index)
self.details_handler.update_entry_details()
self.update_status(
f"{action} save failed; previous state restored: {save_message}"
)
return False
def get_pending_dns_entry(
self, entry_index: int, dns_name: str, hostnames: list[str]
) -> HostEntry | None:
"""Return the original DNS Entry if it is unchanged after an await."""
if not 0 <= entry_index < len(self.hosts_file.entries):
return None
entry = self.hosts_file.entries[entry_index]
if entry.dns_name != dns_name or entry.hostnames != hostnames:
return None
return entry
# Event handlers # Event handlers
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlighting (cursor movement) in the DataTable.""" """Handle row highlighting (cursor movement) in the DataTable."""
@ -624,14 +669,13 @@ class HostsManagerApp(App):
self.update_status("Entry creation cancelled") self.update_status("Entry creation cancelled")
return return
snapshot = self.capture_mutation_state()
# Add the entry using the command-based manager method # Add the entry using the command-based manager method
result = self.manager.execute_add_command(self.hosts_file, new_entry) result = self.manager.execute_add_command(self.hosts_file, new_entry)
if result.success: if result.success:
# Save the changes # Save the changes
save_success, save_message = self.manager.save_hosts_file( if self.save_mutation(snapshot, "Add"):
self.hosts_file
)
if save_success:
# Refresh the table # Refresh the table
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
# Move cursor to the newly added entry (last entry) # Move cursor to the newly added entry (last entry)
@ -649,8 +693,6 @@ class HostsManagerApp(App):
self.update_status( self.update_status(
f"{result.message} - Changes saved automatically" f"{result.message} - Changes saved automatically"
) )
else:
self.update_status(f"Entry added but save failed: {save_message}")
else: else:
self.update_status(f"{result.message}") self.update_status(f"{result.message}")
@ -682,16 +724,15 @@ class HostsManagerApp(App):
self.update_status("Entry deletion cancelled") self.update_status("Entry deletion cancelled")
return return
snapshot = self.capture_mutation_state()
# Delete the entry using the command-based manager method # Delete the entry using the command-based manager method
result = self.manager.execute_delete_command( result = self.manager.execute_delete_command(
self.hosts_file, self.selected_entry_index self.hosts_file, self.selected_entry_index
) )
if result.success: if result.success:
# Save the changes # Save the changes
save_success, save_message = self.manager.save_hosts_file( if self.save_mutation(snapshot, "Delete"):
self.hosts_file
)
if save_success:
# Adjust selected index if needed # Adjust selected index if needed
if self.selected_entry_index >= len(self.hosts_file.entries): if self.selected_entry_index >= len(self.hosts_file.entries):
self.selected_entry_index = max( self.selected_entry_index = max(
@ -704,8 +745,6 @@ class HostsManagerApp(App):
self.update_status( self.update_status(
f"{result.message} - Changes saved automatically" f"{result.message} - Changes saved automatically"
) )
else:
self.update_status(f"Entry deleted but save failed: {save_message}")
else: else:
self.update_status(f"{result.message}") self.update_status(f"{result.message}")
@ -715,25 +754,6 @@ class HostsManagerApp(App):
"""Quit the application.""" """Quit the application."""
self.navigation_handler.quit_application() self.navigation_handler.quit_application()
def _save_history_operation(
self, action: str, rollback: Callable[[HostsFile], OperationResult]
) -> bool:
"""Save a history change or restore the previous state after a failure."""
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
if save_success:
return True
rollback_result = rollback(self.hosts_file)
if rollback_result.success:
self.update_status(
f"{action} save failed; previous state restored: {save_message}"
)
else:
self.update_status(
f"{action} save failed and could not restore the previous state: {save_message}; {rollback_result.message}. Reload the Hosts File."
)
return False
def action_undo(self) -> None: def action_undo(self) -> None:
"""Undo the last operation.""" """Undo the last operation."""
if not self.edit_mode: if not self.edit_mode:
@ -746,13 +766,12 @@ class HostsManagerApp(App):
# Get description before undoing # Get description before undoing
description = self.manager.get_undo_description() description = self.manager.get_undo_description()
snapshot = self.capture_mutation_state()
# Perform undo # Perform undo
result = self.manager.undo_last_operation(self.hosts_file) result = self.manager.undo_last_operation(self.hosts_file)
if result.success: if result.success:
if not self._save_history_operation( if not self.save_mutation(snapshot, "Undo"):
"Undo", self.manager.redo_last_operation
):
return return
# Refresh the table and update UI # Refresh the table and update UI
@ -774,13 +793,12 @@ class HostsManagerApp(App):
# Get description before redoing # Get description before redoing
description = self.manager.get_redo_description() description = self.manager.get_redo_description()
snapshot = self.capture_mutation_state()
# Perform redo # Perform redo
result = self.manager.redo_last_operation(self.hosts_file) result = self.manager.redo_last_operation(self.hosts_file)
if result.success: if result.success:
if not self._save_history_operation( if not self.save_mutation(snapshot, "Redo"):
"Redo", self.manager.undo_last_operation
):
return return
# Refresh the table and update UI # Refresh the table and update UI
@ -808,13 +826,6 @@ class HostsManagerApp(App):
self.update_status("No entries with DNS names found") self.update_status("No entries with DNS names found")
return return
# Remember the currently selected entry before DNS update
current_entry = None
if self.hosts_file.entries and self.selected_entry_index < len(
self.hosts_file.entries
):
current_entry = self.hosts_file.entries[self.selected_entry_index]
async def refresh_dns(): async def refresh_dns():
try: try:
# Extract DNS names (not hostnames!) from entries # Extract DNS names (not hostnames!) from entries
@ -827,18 +838,31 @@ class HostsManagerApp(App):
resolved_count = 0 resolved_count = 0
failed_count = 0 failed_count = 0
# Resolve each DNS name and apply results back to entries resolutions = [
for dns_name in dns_names: (dns_name, await self.dns_service.resolve_entry_async(dns_name))
resolution = await self.dns_service.resolve_entry_async(dns_name) for dns_name in dns_names
]
snapshot = self.capture_mutation_state()
current_entry = (
self.hosts_file.entries[self.selected_entry_index]
if 0 <= self.selected_entry_index < len(self.hosts_file.entries)
else None
)
current_dns_entries = self.hosts_file.get_dns_entries()
# Apply each DNS result back to the current entries.
for dns_name, resolution in resolutions:
# Find the corresponding entry and update it # Find the corresponding entry and update it
for entry in dns_entries: for entry in current_dns_entries:
if entry.dns_name == dns_name: if entry.dns_name == dns_name:
# Apply resolution results to entry fields # Apply resolution results to entry fields
entry.last_resolved = resolution.resolved_at entry.last_resolved = resolution.resolved_at
entry.dns_resolution_status = resolution.status.value entry.dns_resolution_status = resolution.status.value
if resolution.is_success(): if (
resolution.is_success()
and resolution.resolved_ip is not None
):
# Update both resolved_ip and ip_address for the hosts file # Update both resolved_ip and ip_address for the hosts file
entry.ip_address = resolution.resolved_ip entry.ip_address = resolution.resolved_ip
entry.resolved_ip = resolution.resolved_ip entry.resolved_ip = resolution.resolved_ip
@ -849,13 +873,7 @@ class HostsManagerApp(App):
# Save hosts file with updated DNS information # Save hosts file with updated DNS information
if resolved_count > 0 or failed_count > 0: if resolved_count > 0 or failed_count > 0:
save_success, save_message = self.manager.save_hosts_file( if not self.save_mutation(snapshot, "DNS refresh"):
self.hosts_file
)
if not save_success:
self.update_status(
f"❌ DNS resolution completed but save failed: {save_message}"
)
return return
# Update the UI and restore cursor position # Update the UI and restore cursor position
@ -907,33 +925,36 @@ class HostsManagerApp(App):
self.update_status("❌ Selected entry has no DNS name to resolve") self.update_status("❌ Selected entry has no DNS name to resolve")
return return
# Remember the currently selected entry before DNS update dns_name = entry.dns_name
current_entry = entry entry_hostnames = entry.hostnames.copy()
entry_index = self.selected_entry_index
async def update_single_dns(): async def update_single_dns():
try: try:
dns_name = entry.dns_name
# Resolve the DNS name # Resolve the DNS name
resolution = await self.dns_service.resolve_entry_async(dns_name) resolution = await self.dns_service.resolve_entry_async(dns_name)
current_entry = self.get_pending_dns_entry(
entry_index, dns_name, entry_hostnames
)
if current_entry is None:
self.update_status(
f"❌ DNS entry changed before resolution completed: {dns_name}"
)
return
snapshot = self.capture_mutation_state()
# Apply resolution results to entry fields # Apply resolution results to entry fields
entry.last_resolved = resolution.resolved_at current_entry.last_resolved = resolution.resolved_at
entry.dns_resolution_status = resolution.status.value current_entry.dns_resolution_status = resolution.status.value
if resolution.is_success(): if resolution.is_success() and resolution.resolved_ip is not None:
# Update both resolved_ip and ip_address for the hosts file # Update both resolved_ip and ip_address for the hosts file
entry.ip_address = resolution.resolved_ip current_entry.ip_address = resolution.resolved_ip
entry.resolved_ip = resolution.resolved_ip current_entry.resolved_ip = resolution.resolved_ip
# Save hosts file with updated DNS information # Save hosts file with updated DNS information
save_success, save_message = self.manager.save_hosts_file( if not self.save_mutation(snapshot, "DNS refresh"):
self.hosts_file
)
if not save_success:
self.update_status(
f"❌ DNS resolution completed but save failed: {save_message}"
)
return return
# Update the UI and restore cursor position # Update the UI and restore cursor position
@ -946,14 +967,13 @@ class HostsManagerApp(App):
) )
else: else:
# Resolution failed, save the status update # Resolution failed, save the status update
save_success, save_message = self.manager.save_hosts_file( if not self.save_mutation(snapshot, "DNS refresh"):
self.hosts_file return
)
if save_success: # Update the UI to show failed status and restore cursor position
# Update the UI to show failed status and restore cursor position self.table_handler.populate_entries_table()
self.table_handler.populate_entries_table() self.table_handler.restore_cursor_position(current_entry)
self.table_handler.restore_cursor_position(current_entry) self.details_handler.update_entry_details()
self.details_handler.update_entry_details()
error_msg = resolution.error_message or "Unknown error" error_msg = resolution.error_message or "Unknown error"
self.update_status( self.update_status(
@ -1029,62 +1049,62 @@ class HostsManagerApp(App):
if not hasattr(entry, "dns_name") or not entry.dns_name: if not hasattr(entry, "dns_name") or not entry.dns_name:
return return
dns_name = entry.dns_name
entry_hostnames = entry.hostnames.copy()
entry_index = next(
(
index
for index, candidate in enumerate(self.hosts_file.entries)
if candidate is entry
),
-1,
)
async def resolve_and_activate(): async def resolve_and_activate():
try: try:
# Resolve the DNS name # Resolve the DNS name
resolution = await self.dns_service.resolve_entry_async(entry.dns_name) resolution = await self.dns_service.resolve_entry_async(dns_name)
current_entry = self.get_pending_dns_entry(
entry_index, dns_name, entry_hostnames
)
if current_entry is None:
self.update_status(
f"❌ DNS entry changed before resolution completed: {dns_name}"
)
return
if resolution.is_success(): snapshot = self.capture_mutation_state()
# Find the entry in the hosts file and update it
for hosts_entry in self.hosts_file.entries:
if (
hasattr(hosts_entry, "dns_name")
and hosts_entry.dns_name == entry.dns_name
and hosts_entry.hostnames == entry.hostnames
):
# Update the entry with resolved IP
hosts_entry.ip_address = resolution.resolved_ip
hosts_entry.resolved_ip = resolution.resolved_ip
hosts_entry.last_resolved = resolution.resolved_at
hosts_entry.dns_resolution_status = resolution.status.value
hosts_entry.is_active = True # Activate the entry
# Save the updated hosts file if resolution.is_success() and resolution.resolved_ip is not None:
save_success, save_message = self.manager.save_hosts_file( # Update the entry with resolved IP
self.hosts_file current_entry.ip_address = resolution.resolved_ip
) current_entry.resolved_ip = resolution.resolved_ip
if save_success: current_entry.last_resolved = resolution.resolved_at
# Update UI - use direct calls since we're in the same async context current_entry.dns_resolution_status = resolution.status.value
self.table_handler.populate_entries_table() current_entry.is_active = True # Activate the entry
self.details_handler.update_entry_details()
self.update_status( # Save the updated hosts file
f"✅ DNS resolved: {entry.dns_name}{resolution.resolved_ip} (entry activated)" if self.save_mutation(snapshot, "DNS activation"):
) # Update UI - use direct calls since we're in the same async context
else: self.table_handler.populate_entries_table()
self.update_status( self.details_handler.update_entry_details()
f"❌ DNS resolved but save failed: {save_message}" self.update_status(
) f"✅ DNS resolved: {dns_name}{resolution.resolved_ip} (entry activated)"
break )
else: else:
# Resolution failed, update status but keep entry inactive # Resolution failed, update status but keep entry inactive
for hosts_entry in self.hosts_file.entries: current_entry.dns_resolution_status = resolution.status.value
if ( current_entry.last_resolved = resolution.resolved_at
hasattr(hosts_entry, "dns_name")
and hosts_entry.dns_name == entry.dns_name if not self.save_mutation(snapshot, "DNS refresh"):
and hosts_entry.hostnames == entry.hostnames return
):
hosts_entry.dns_resolution_status = resolution.status.value
hosts_entry.last_resolved = resolution.resolved_at
break
self.update_status( self.update_status(
f"❌ DNS resolution failed for {entry.dns_name}: {resolution.error_message or 'Unknown error'}" f"❌ DNS resolution failed for {dns_name}: {resolution.error_message or 'Unknown error'}"
) )
except Exception as e: except Exception as e:
self.update_status( self.update_status(f"❌ DNS resolution error for {dns_name}: {str(e)}")
f"❌ DNS resolution error for {entry.dns_name}: {str(e)}"
)
# Start the resolution in background # Start the resolution in background
self.run_worker(resolve_and_activate(), exclusive=False) self.run_worker(resolve_and_activate(), exclusive=False)

View file

@ -355,6 +355,7 @@ class EditHandler:
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()] hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
comment = comment_input.value.strip() or None comment = comment_input.value.strip() or None
is_active = active_checkbox.value is_active = active_checkbox.value
snapshot = self.app.capture_mutation_state()
# Update entry based on type # Update entry based on type
if entry_type == "ip": if entry_type == "ip":
@ -390,8 +391,7 @@ class EditHandler:
entry.is_active = is_active entry.is_active = is_active
# Save to file # Save to file
success, message = self.app.manager.save_hosts_file(self.app.hosts_file) if self.app.save_mutation(snapshot, "Edit"):
if success:
# Update the table display # Update the table display
self.app.table_handler.populate_entries_table() self.app.table_handler.populate_entries_table()
# Restore cursor position # Restore cursor position
@ -410,9 +410,7 @@ class EditHandler:
else: else:
self.app.update_status("Entry saved successfully") self.app.update_status("Entry saved successfully")
return True return True
else: return False
self.app.update_status(f"❌ Error saving entry: {message}")
return False
def navigate_to_next_field(self) -> None: def navigate_to_next_field(self) -> None:
"""Move to the next field in edit mode.""" """Move to the next field in edit mode."""

View file

@ -29,6 +29,7 @@ class NavigationHandler:
# Remember current entry for cursor position restoration # Remember current entry for cursor position restoration
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index] current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
snapshot = self.app.capture_mutation_state()
# Use command-based method for undo/redo support # Use command-based method for undo/redo support
result = self.app.manager.execute_toggle_command( result = self.app.manager.execute_toggle_command(
@ -36,10 +37,7 @@ class NavigationHandler:
) )
if result.success: if result.success:
# Auto-save the changes immediately # Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file( if self.app.save_mutation(snapshot, "Toggle"):
self.app.hosts_file
)
if save_success:
self.app.table_handler.populate_entries_table() self.app.table_handler.populate_entries_table()
# Restore cursor position to the same entry # Restore cursor position to the same entry
self.app.set_timer( self.app.set_timer(
@ -52,8 +50,6 @@ class NavigationHandler:
self.app.update_status( self.app.update_status(
f"{result.message} - Changes saved automatically" f"{result.message} - Changes saved automatically"
) )
else:
self.app.update_status(f"Entry toggled but save failed: {save_message}")
else: else:
self.app.update_status(f"Error toggling entry: {result.message}") self.app.update_status(f"Error toggling entry: {result.message}")
@ -69,16 +65,15 @@ class NavigationHandler:
self.app.update_status("No entries to move") self.app.update_status("No entries to move")
return return
snapshot = self.app.capture_mutation_state()
# Use command-based method for undo/redo support # Use command-based method for undo/redo support
result = self.app.manager.execute_move_command( result = self.app.manager.execute_move_command(
self.app.hosts_file, self.app.selected_entry_index, "up" self.app.hosts_file, self.app.selected_entry_index, "up"
) )
if result.success: if result.success:
# Auto-save the changes immediately # Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file( if self.app.save_mutation(snapshot, "Move"):
self.app.hosts_file
)
if save_success:
# Update the selection index to follow the moved entry # Update the selection index to follow the moved entry
if self.app.selected_entry_index > 0: if self.app.selected_entry_index > 0:
self.app.selected_entry_index -= 1 self.app.selected_entry_index -= 1
@ -94,8 +89,6 @@ class NavigationHandler:
self.app.update_status( self.app.update_status(
f"{result.message} - Changes saved automatically" f"{result.message} - Changes saved automatically"
) )
else:
self.app.update_status(f"Entry moved but save failed: {save_message}")
else: else:
self.app.update_status(f"Error moving entry: {result.message}") self.app.update_status(f"Error moving entry: {result.message}")
@ -111,16 +104,15 @@ class NavigationHandler:
self.app.update_status("No entries to move") self.app.update_status("No entries to move")
return return
snapshot = self.app.capture_mutation_state()
# Use command-based method for undo/redo support # Use command-based method for undo/redo support
result = self.app.manager.execute_move_command( result = self.app.manager.execute_move_command(
self.app.hosts_file, self.app.selected_entry_index, "down" self.app.hosts_file, self.app.selected_entry_index, "down"
) )
if result.success: if result.success:
# Auto-save the changes immediately # Auto-save the changes immediately
save_success, save_message = self.app.manager.save_hosts_file( if self.app.save_mutation(snapshot, "Move"):
self.app.hosts_file
)
if save_success:
# Update the selection index to follow the moved entry # Update the selection index to follow the moved entry
if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1: if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1:
self.app.selected_entry_index += 1 self.app.selected_entry_index += 1
@ -136,8 +128,6 @@ class NavigationHandler:
self.app.update_status( self.app.update_status(
f"{result.message} - Changes saved automatically" f"{result.message} - Changes saved automatically"
) )
else:
self.app.update_status(f"Entry moved but save failed: {save_message}")
else: else:
self.app.update_status(f"Error moving entry: {result.message}") self.app.update_status(f"Error moving entry: {result.message}")

View file

@ -123,7 +123,7 @@ class TableHandler:
def actual_index_to_display_index(self, actual_index: int) -> int: def actual_index_to_display_index(self, actual_index: int) -> int:
"""Convert an actual hosts file entry index to a display table index.""" """Convert an actual hosts file entry index to a display table index."""
if actual_index >= len(self.app.hosts_file.entries): if actual_index < 0 or actual_index >= len(self.app.hosts_file.entries):
return 0 return 0
target_entry = self.app.hosts_file.entries[actual_index] target_entry = self.app.hosts_file.entries[actual_index]
@ -136,6 +136,14 @@ class TableHandler:
return 0 return 0
def move_cursor_to_entry_index(self, actual_index: int) -> None:
"""Move the table cursor to an exact Hosts File entry index."""
table = self.app.query_one("#entries-table", DataTable)
display_index = self.actual_index_to_display_index(actual_index)
if table.row_count > 0 and display_index < table.row_count:
table.move_cursor(row=display_index)
table.focus()
def populate_entries_table(self) -> None: def populate_entries_table(self) -> None:
"""Populate the left pane with hosts entries using DataTable.""" """Populate the left pane with hosts entries using DataTable."""
table = self.app.query_one("#entries-table", DataTable) table = self.app.query_one("#entries-table", DataTable)

View file

@ -5,19 +5,31 @@ This module contains unit tests for the HostsManagerApp class,
validating application behavior, navigation, and user interactions. validating application behavior, navigation, and user interactions.
""" """
from unittest.mock import Mock, patch from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
import pytest
from hosts.tui.app import HostsManagerApp from hosts.tui.app import HostsManagerApp
from hosts.core.models import HostEntry, HostsFile from hosts.core.models import HostEntry, HostsFile
from hosts.core.parser import HostsParser from hosts.core.parser import HostsParser
from hosts.core.config import Config from hosts.core.config import Config
from hosts.core.manager import HostsManager from hosts.core.dns import DNSResolution, DNSResolutionStatus
class TestHostsManagerApp: class TestHostsManagerApp:
"""Test cases for the HostsManagerApp class.""" """Test cases for the HostsManagerApp class."""
@staticmethod
def fail_saves(app: HostsManagerApp) -> None:
"""Configure a TUI app with an isolated failed-persistence boundary."""
app.manager.save_hosts_file = Mock(return_value=(False, "Permission denied"))
app.table_handler.populate_entries_table = Mock()
app.table_handler.move_cursor_to_entry_index = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
def test_app_initialization(self): def test_app_initialization(self):
"""Test application initialization.""" """Test application initialization."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"): with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
@ -537,8 +549,7 @@ class TestHostsManagerApp:
app.update_status = Mock() app.update_status = Mock()
serialized_files = [] serialized_files = []
app.manager = Mock(spec=HostsManager) app.manager.enter_edit_mode = Mock(return_value=(True, "Edit mode enabled"))
app.manager.enter_edit_mode.return_value = (True, "Edit mode enabled")
def toggle_entry(hosts_file, index): def toggle_entry(hosts_file, index):
hosts_file.toggle_entry(index) hosts_file.toggle_entry(index)
@ -548,8 +559,8 @@ class TestHostsManagerApp:
serialized_files.append(HostsParser().serialize(hosts_file)) serialized_files.append(HostsParser().serialize(hosts_file))
return True, "Hosts file saved successfully" return True, "Hosts file saved successfully"
app.manager.execute_toggle_command.side_effect = toggle_entry app.manager.execute_toggle_command = Mock(side_effect=toggle_entry)
app.manager.save_hosts_file.side_effect = save_hosts_file app.manager.save_hosts_file = Mock(side_effect=save_hosts_file)
app.action_sort_by_ip() app.action_sort_by_ip()
assert app.edit_mode is False assert app.edit_mode is False
@ -1138,20 +1149,16 @@ class TestHostsManagerApp:
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry) app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0) app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.save_hosts_file = Mock( self.fail_saves(app)
return_value=(False, "Permission denied")
)
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.action_undo() app.action_undo()
assert entry.is_active is False assert app.hosts_file.entries[0].is_active is False
assert app.manager.can_undo() assert app.manager.can_undo()
assert not app.manager.can_redo() assert not app.manager.can_redo()
app.table_handler.populate_entries_table.assert_not_called() app.table_handler.populate_entries_table.assert_called_once()
app.details_handler.update_entry_details.assert_not_called() app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0)
app.details_handler.update_entry_details.assert_called_once()
app.update_status.assert_called_once_with( app.update_status.assert_called_once_with(
"❌ Undo save failed; previous state restored: Permission denied" "❌ Undo save failed; previous state restored: Permission denied"
) )
@ -1190,22 +1197,449 @@ class TestHostsManagerApp:
app.hosts_file.add_entry(entry) app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0) app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.undo_last_operation(app.hosts_file) app.manager.undo_last_operation(app.hosts_file)
self.fail_saves(app)
app.action_redo()
assert app.hosts_file.entries[0].is_active is True
assert not app.manager.can_undo()
assert app.manager.can_redo()
app.table_handler.populate_entries_table.assert_called_once()
app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0)
app.details_handler.update_entry_details.assert_called_once()
app.update_status.assert_called_once_with(
"❌ Redo save failed; previous state restored: Permission denied"
)
def test_toggle_save_failure_restores_model_selection_and_history(self):
"""A failed toggle restores all user-visible and undoable state."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
]
)
app.selected_entry_index = 0
app.manager.execute_toggle_command(app.hosts_file, 1)
app.manager.undo_last_operation(app.hosts_file)
self.fail_saves(app)
app.action_toggle_entry()
assert app.hosts_file.entries[0].is_active is True
assert app.selected_entry_index == 0
assert not app.manager.can_undo()
assert app.manager.can_redo()
app.update_status.assert_called_once_with(
"❌ Toggle save failed; previous state restored: Permission denied"
)
def test_move_save_failure_restores_entry_order_and_selection(self):
"""A failed move leaves the selected Host Entry in its original position."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
]
)
app.selected_entry_index = 1
self.fail_saves(app)
app.action_move_entry_up()
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
"one.test",
"two.test",
]
assert app.selected_entry_index == 1
assert not app.manager.can_undo()
app.update_status.assert_called_once_with(
"❌ Move save failed; previous state restored: Permission denied"
)
def test_add_save_failure_removes_entry_and_restores_history(self):
"""A failed add restores the prior entries and history."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
existing = HostEntry(ip_address="192.0.2.1", hostnames=["one.test"])
added = HostEntry(ip_address="192.0.2.2", hostnames=["two.test"])
app.hosts_file = HostsFile(entries=[existing])
app.selected_entry_index = 0
self.fail_saves(app)
app.push_screen = Mock(
side_effect=lambda _screen, callback: callback(added)
)
app.action_add_entry()
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
"one.test"
]
assert app.selected_entry_index == 0
assert not app.manager.can_undo()
app.update_status.assert_called_once_with(
"❌ Add save failed; previous state restored: Permission denied"
)
def test_later_mutation_does_not_persist_failed_add(self):
"""A later successful save excludes a previously failed addition."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
existing = HostEntry(ip_address="192.0.2.1", hostnames=["one.test"])
added = HostEntry(ip_address="192.0.2.2", hostnames=["two.test"])
app.hosts_file = HostsFile(entries=[existing])
app.manager.save_hosts_file = Mock( app.manager.save_hosts_file = Mock(
return_value=(False, "Permission denied") return_value=(False, "Permission denied")
) )
app.table_handler.populate_entries_table = Mock() app.table_handler.populate_entries_table = Mock()
app.table_handler.move_cursor_to_entry_index = Mock()
app.table_handler.restore_cursor_position = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.set_timer = Mock()
app.push_screen = Mock(
side_effect=lambda _screen, callback: callback(added)
)
app.action_add_entry()
persisted = []
def save_hosts_file(hosts_file):
persisted.append(HostsParser().serialize(hosts_file))
return True, "Hosts file saved"
app.manager.save_hosts_file = Mock(side_effect=save_hosts_file)
app.action_toggle_entry()
assert len(persisted) == 1
assert "one.test" in persisted[0]
assert "two.test" not in persisted[0]
def test_save_failure_restores_exact_duplicate_selection(self):
"""Rollback keeps the second of two identical Host Entries selected."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["same.test"]),
HostEntry(ip_address="192.0.2.1", hostnames=["same.test"]),
]
)
app.selected_entry_index = 1
app.manager.save_hosts_file = Mock(
return_value=(False, "Permission denied")
)
table = Mock(row_count=2)
app.query_one = Mock(return_value=table)
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock() app.details_handler.update_entry_details = Mock()
app.update_status = Mock() app.update_status = Mock()
app.action_redo() app.action_toggle_entry()
assert entry.is_active is True assert app.selected_entry_index == 1
table.move_cursor.assert_called_once_with(row=1)
def test_delete_save_failure_restores_entry_and_selection(self):
"""A failed delete restores the removed Host Entry and selection."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["one.test"]),
HostEntry(ip_address="192.0.2.2", hostnames=["two.test"]),
]
)
app.selected_entry_index = 1
self.fail_saves(app)
app.push_screen = Mock(side_effect=lambda _screen, callback: callback(True))
app.action_delete_entry()
assert [entry.hostnames[0] for entry in app.hosts_file.entries] == [
"one.test",
"two.test",
]
assert app.selected_entry_index == 1
assert not app.manager.can_undo() assert not app.manager.can_undo()
assert app.manager.can_redo()
app.table_handler.populate_entries_table.assert_not_called()
app.details_handler.update_entry_details.assert_not_called()
app.update_status.assert_called_once_with( app.update_status.assert_called_once_with(
"❌ Redo save failed; previous state restored: Permission denied" "❌ Delete save failed; previous state restored: Permission denied"
)
def test_entry_editor_save_failure_restores_all_entry_fields(self):
"""A failed editor save restores ordinary fields and DNS metadata."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
resolved_at = datetime(2026, 9, 4, 10, 30)
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["one.test", "alias.test"],
comment="original",
is_active=False,
dns_name="source.test",
resolved_ip="192.0.2.1",
last_resolved=resolved_at,
dns_resolution_status="resolved",
)
]
)
app.selected_entry_index = 0
app.entry_edit_mode = True
app.manager.save_hosts_file = Mock(
return_value=(False, "Permission denied")
)
app.table_handler.populate_entries_table = Mock()
app.table_handler.move_cursor_to_entry_index = Mock()
app.update_status = Mock()
ip_input = Mock(value="198.51.100.8")
hostname_input = Mock(value="changed.test")
comment_input = Mock(value="changed")
active_checkbox = Mock(value=True)
dns_input = Mock(value="")
ip_radio = Mock(id="edit-ip-entry-radio", value=True)
dns_radio = Mock(id="edit-dns-entry-radio", value=False)
radio_set = Mock(pressed_button=ip_radio)
widgets = {
"#entry-details-display": Mock(),
"#entry-edit-form": Mock(),
"#ip-input": ip_input,
"#hostname-input": hostname_input,
"#comment-input": comment_input,
"#active-checkbox": active_checkbox,
"#dns-name-input": dns_input,
"#edit-entry-type-radio": radio_set,
"#edit-ip-entry-radio": ip_radio,
"#edit-dns-entry-radio": dns_radio,
"#edit-ip-section": Mock(),
"#edit-dns-section": Mock(),
}
app.query_one = Mock(side_effect=lambda selector, *_args: widgets[selector])
app.set_timer = Mock(side_effect=lambda _delay, callback: callback())
saved = app.edit_handler.validate_and_save_entry_changes()
entry = app.hosts_file.entries[0]
assert saved is False
assert entry.ip_address == "192.0.2.1"
assert entry.hostnames == ["one.test", "alias.test"]
assert entry.comment == "original"
assert entry.is_active is False
assert entry.dns_name == "source.test"
assert entry.resolved_ip == "192.0.2.1"
assert entry.last_resolved == resolved_at
assert entry.dns_resolution_status == "resolved"
assert ip_input.value == "192.0.2.1"
assert hostname_input.value == "one.test, alias.test"
assert comment_input.value == "original"
assert active_checkbox.value is False
assert dns_input.value == "source.test"
app.update_status.assert_called_once_with(
"❌ Edit save failed; previous state restored: Permission denied"
)
@pytest.mark.asyncio
async def test_batch_dns_save_failure_restores_mapping_and_metadata(self):
"""A failed batch DNS refresh restores every changed DNS field."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
original_time = datetime(2026, 9, 3, 8, 0)
resolved_time = datetime(2026, 9, 4, 11, 0)
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["one.test"],
dns_name="source.test",
resolved_ip="192.0.2.1",
last_resolved=original_time,
dns_resolution_status="match",
)
]
)
app.selected_entry_index = 0
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=resolved_time,
)
)
self.fail_saves(app)
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app.action_refresh_dns()
await workers[0]
entry = app.hosts_file.entries[0]
assert entry.ip_address == "192.0.2.1"
assert entry.resolved_ip == "192.0.2.1"
assert entry.last_resolved == original_time
assert entry.dns_resolution_status == "match"
assert app.selected_entry_index == 0
app.update_status.assert_any_call(
"❌ DNS refresh save failed; previous state restored: Permission denied"
)
@pytest.mark.asyncio
async def test_single_dns_save_failure_restores_mapping_and_metadata(self):
"""A failed selected-entry DNS refresh restores every changed field."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
original_time = datetime(2026, 9, 3, 8, 0)
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["one.test"],
dns_name="source.test",
resolved_ip="192.0.2.1",
last_resolved=original_time,
dns_resolution_status="match",
)
]
)
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime(2026, 9, 4, 11, 0),
)
)
self.fail_saves(app)
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app.action_update_single_dns()
await workers[0]
entry = app.hosts_file.entries[0]
assert entry.ip_address == "192.0.2.1"
assert entry.resolved_ip == "192.0.2.1"
assert entry.last_resolved == original_time
assert entry.dns_resolution_status == "match"
app.update_status.assert_any_call(
"❌ DNS refresh save failed; previous state restored: Permission denied"
)
@pytest.mark.asyncio
async def test_single_dns_refresh_updates_selected_duplicate(self):
"""DNS refresh retains the selected entry identity across its await."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile(
entries=[
HostEntry(
ip_address="192.0.2.1",
hostnames=["same.test"],
dns_name="source.test",
),
HostEntry(
ip_address="192.0.2.2",
hostnames=["same.test"],
dns_name="source.test",
),
]
)
app.selected_entry_index = 1
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime(2026, 9, 4, 11, 0),
)
)
app.manager.save_hosts_file = Mock(return_value=(True, "Saved"))
app.table_handler.populate_entries_table = Mock()
app.table_handler.restore_cursor_position = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app.action_update_single_dns()
await workers[0]
assert app.hosts_file.entries[0].ip_address == "192.0.2.1"
assert app.hosts_file.entries[1].ip_address == "198.51.100.9"
@pytest.mark.asyncio
async def test_new_entry_dns_save_failure_keeps_saved_placeholder(self):
"""Failed post-add DNS persistence keeps the already-saved DNS placeholder."""
with patch("hosts.tui.app.HostsParser"), patch("hosts.tui.app.Config"):
app = HostsManagerApp()
app.edit_mode = True
app.manager.edit_mode = True
app.hosts_file = HostsFile()
entry = HostEntry(
ip_address="0.0.0.0",
hostnames=["one.test"],
is_active=False,
dns_name="source.test",
)
app.manager.execute_add_command(app.hosts_file, entry)
app.dns_service.resolve_entry_async = AsyncMock(
return_value=DNSResolution(
hostname="source.test",
resolved_ip="198.51.100.9",
status=DNSResolutionStatus.RESOLVED,
resolved_at=datetime(2026, 9, 4, 11, 0),
)
)
self.fail_saves(app)
workers = []
app.run_worker = Mock(
side_effect=lambda worker, **_kwargs: workers.append(worker)
)
app._resolve_new_dns_entry(entry)
await workers[0]
restored = app.hosts_file.entries[0]
assert restored.ip_address == "0.0.0.0"
assert restored.resolved_ip is None
assert restored.last_resolved is None
assert restored.dns_resolution_status is None
assert restored.is_active is False
assert app.manager.can_undo()
app.update_status.assert_any_call(
"❌ DNS activation save failed; previous state restored: Permission denied"
) )
def test_main_function(self): def test_main_function(self):

View file

@ -556,6 +556,28 @@ class TestHostsManager:
assert not success assert not success
assert "No sudo permissions" in message assert "No sudo permissions" in message
def test_save_mutation_failure_restores_hosts_file_and_history(self):
"""The manager owns rollback of model and undo/redo state."""
manager = HostsManager()
manager.edit_mode = True
hosts_file = HostsFile(
entries=[HostEntry("192.0.2.1", ["one.test"], is_active=True)]
)
manager.execute_toggle_command(hosts_file, 0)
manager.undo_last_operation(hosts_file)
state = manager.capture_mutation_state(hosts_file)
manager.execute_toggle_command(hosts_file, 0)
manager.save_hosts_file = Mock(return_value=(False, "Permission denied"))
success, message, restored_hosts_file = manager.save_mutation(hosts_file, state)
assert success is False
assert message == "Permission denied"
assert restored_hosts_file.entries[0].is_active is True
assert not manager.can_undo()
assert manager.can_redo()
@patch("subprocess.run") @patch("subprocess.run")
def test_restore_backup_success(self, mock_run): def test_restore_backup_success(self, mock_run):
"""Test restoring backup successfully.""" """Test restoring backup successfully."""