Fix #7: persist undo and redo results immediately

This commit is contained in:
Philip Henning 2026-09-03 19:39:12 +02:00
parent 68a9696e7d
commit 89b37239b2
4 changed files with 149 additions and 22 deletions

View file

@ -74,8 +74,6 @@ sorting, DNS refresh, movement, undo, and redo.
## Safety and known limitations ## Safety and known limitations
- Most successful mutations save immediately, but undo and redo currently
change only the in-memory state until another save occurs.
- Reloading discards unsaved in-memory state. - Reloading discards unsaved in-memory state.
- Sorting currently reorders the in-memory model; a later save can write that - Sorting currently reorders the in-memory model; a later save can write that
order to `/etc/hosts`. Treat sorting as unsafe before another mutation. order to `/etc/hosts`. Treat sorting as unsafe before another mutation.

View file

@ -6,7 +6,7 @@ authorization before it can change `/etc/hosts`.
> [!WARNING] > [!WARNING]
> `hosts` is alpha software. Read [Persistence and recovery](#persistence-and-recovery) > `hosts` is alpha software. Read [Persistence and recovery](#persistence-and-recovery)
> before entering Privileged Mode. In particular, undo, redo, sorting, and save > before entering Privileged Mode. In particular, sorting and save
> failures can currently leave the interface and `/etc/hosts` out of sync. > failures can currently leave the interface and `/etc/hosts` out of sync.
## Before you begin ## Before you begin
@ -74,7 +74,7 @@ additional hostnames are Aliases.
Default Entries represent protected baseline localhost or broadcasthost 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.
Most 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. A message that reports a save failure means
the interface may have changed while the system file did not. the interface may have changed while the system file did not.
@ -99,9 +99,8 @@ stored mapping.
## 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.
except for the known persistence cases below. To discard in-memory state and To discard in-memory state and read `/etc/hosts` again, press `Ctrl+R`.
read `/etc/hosts` again, press `Ctrl+R`.
You can also inspect the file from another terminal: You can also inspect the file from another terminal:
@ -117,19 +116,15 @@ a management header while retaining the modeled Host Entries and comments.
### What saves immediately ### What saves immediately
Adding, editing, deleting, moving, activating, deactivating, and successfully Adding, editing, deleting, moving, activating, deactivating, undoing, redoing,
refreshing DNS Entries normally save immediately. `Ctrl+S` explicitly saves the and successfully refreshing DNS Entries save immediately. `Ctrl+S` explicitly
entire current in-memory Hosts File. saves the entire current in-memory Hosts File.
### What does not save immediately If saving an undo or redo result fails, the application restores the prior
in-memory state and undo/redo history. Reload with `Ctrl+R` to verify the
on-disk state before attempting another change.
Undo and redo currently update the in-memory state without saving it. After Other save failures can leave the interface changed while the file on disk is
`Ctrl+Z` or `Ctrl+Y`, use `Ctrl+S` if you want `/etc/hosts` to match the display.
Reloading or quitting discards that unsaved in-memory result. Leaving
Privileged Mode does not save it: the result remains displayed, but its
undo/redo history is cleared and `/etc/hosts` still differs from the display.
A save failure can also leave the interface changed while the file on disk is
unchanged. Reload with `Ctrl+R` to return the interface to the on-disk state unchanged. Reload with `Ctrl+R` to return the interface to the on-disk state
before attempting another change. before attempting another change.
@ -206,9 +201,9 @@ specific failure.
### The display differs from `/etc/hosts` ### The display differs from `/etc/hosts`
This can occur after undo, redo, a failed save, or sorting followed by other This can occur after a failed save or sorting followed by other actions. Press
actions. Press `Ctrl+R` to discard in-memory state and reload the file. If an `Ctrl+R` to discard in-memory state and reload the file. If an unwanted change
unwanted change reached disk, follow the manual restoration procedure above. reached disk, follow the manual restoration procedure above.
### DNS resolution fails ### DNS resolution fails
@ -245,7 +240,7 @@ continually retrying it.
| `Shift+Up` / `Shift+Down` | Move the selected Host Entry | | `Shift+Up` / `Shift+Down` | Move the selected Host Entry |
| `r` | Refresh the selected DNS Entry | | `r` | Refresh the selected DNS Entry |
| `Shift+R` | Refresh all DNS Entries | | `Shift+R` | Refresh all DNS Entries |
| `Ctrl+Z` / `Ctrl+Y` | Undo or redo in memory | | `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 |
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

View file

@ -5,6 +5,8 @@ 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 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
from textual.widgets import ( from textual.widgets import (
@ -20,6 +22,7 @@ from textual.reactive import reactive
from ..core.parser import HostsParser from ..core.parser import HostsParser
from ..core.models import HostsFile from ..core.models import 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
from ..core.dns import DNSService from ..core.dns import DNSService
@ -712,6 +715,25 @@ 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:
@ -728,6 +750,11 @@ class HostsManagerApp(App):
# 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(
"Undo", self.manager.redo_last_operation
):
return
# Refresh the table and update UI # Refresh the table and update UI
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
self.details_handler.update_entry_details() self.details_handler.update_entry_details()
@ -751,6 +778,11 @@ class HostsManagerApp(App):
# 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(
"Redo", self.manager.undo_last_operation
):
return
# Refresh the table and update UI # Refresh the table and update UI
self.table_handler.populate_entries_table() self.table_handler.populate_entries_table()
self.details_handler.update_entry_details() self.details_handler.update_entry_details()

View file

@ -986,6 +986,108 @@ class TestHostsManagerApp:
# Should start DNS resolution # Should start DNS resolution
app.run_worker.assert_called() app.run_worker.assert_called()
def test_undo_saves_the_reverted_hosts_file(self):
"""Undo persists its result before refreshing the interface."""
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="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.save_hosts_file = Mock(return_value=(True, "Hosts file saved"))
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.action_undo()
assert entry.is_active is True
app.manager.save_hosts_file.assert_called_once_with(app.hosts_file)
app.table_handler.populate_entries_table.assert_called_once()
app.details_handler.update_entry_details.assert_called_once()
def test_undo_save_failure_restores_the_persisted_state(self):
"""Undo failure leaves the display and undo history at the saved 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()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.save_hosts_file = Mock(
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()
assert entry.is_active is False
assert app.manager.can_undo()
assert not 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(
"❌ Undo save failed; previous state restored: Permission denied"
)
def test_redo_saves_the_reapplied_hosts_file(self):
"""Redo persists its result before refreshing the interface."""
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="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.undo_last_operation(app.hosts_file)
app.manager.save_hosts_file = Mock(return_value=(True, "Hosts file saved"))
app.table_handler.populate_entries_table = Mock()
app.details_handler.update_entry_details = Mock()
app.update_status = Mock()
app.action_redo()
assert entry.is_active is False
app.manager.save_hosts_file.assert_called_once_with(app.hosts_file)
app.table_handler.populate_entries_table.assert_called_once()
app.details_handler.update_entry_details.assert_called_once()
def test_redo_save_failure_restores_the_persisted_state(self):
"""Redo failure leaves the display and redo history at the saved 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()
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
app.hosts_file.add_entry(entry)
app.manager.execute_toggle_command(app.hosts_file, 0)
app.manager.undo_last_operation(app.hosts_file)
app.manager.save_hosts_file = Mock(
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_redo()
assert entry.is_active is True
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(
"❌ Redo save failed; previous state restored: Permission denied"
)
def test_main_function(self): def test_main_function(self):
"""Test main entry point function.""" """Test main entry point function."""
with patch("hosts.main.HostsManagerApp") as mock_app_class: with patch("hosts.main.HostsManagerApp") as mock_app_class: