Make sorting view-only (#11)

This commit is contained in:
Philip Henning 2026-09-04 07:32:16 +02:00
parent 89b37239b2
commit 1a686bd0ff
3 changed files with 124 additions and 34 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.
- Sorting currently reorders the in-memory model; a later save can write that
order to `/etc/hosts`. Treat sorting as unsafe before another mutation.
- A failed save can leave the interface changed while `/etc/hosts` remains - A failed save can leave the interface changed while `/etc/hosts` remains
unchanged. 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

View file

@ -9,7 +9,7 @@ from rich.text import Text
from textual.widgets import DataTable from textual.widgets import DataTable
from typing import List from typing import List
from ..core.models import HostEntry from ..core.models import HostEntry, HostsFile
class TableHandler: class TableHandler:
@ -45,7 +45,18 @@ class TableHandler:
# Fallback to legacy search filtering for backward compatibility # Fallback to legacy search filtering for backward compatibility
filtered_entries = self._apply_legacy_search_filter(all_entries) filtered_entries = self._apply_legacy_search_filter(all_entries)
return filtered_entries return self._get_sorted_display_entries(filtered_entries)
def _get_sorted_display_entries(self, entries: List[HostEntry]) -> List[HostEntry]:
"""Return entries in the current display order without mutating the Hosts File."""
display_file = HostsFile(entries=list(entries))
if self.app.sort_column == "ip":
display_file.sort_by_ip(ascending=self.app.sort_ascending)
elif self.app.sort_column == "hostname":
display_file.sort_by_hostname(ascending=self.app.sort_ascending)
return display_file.entries
def _apply_legacy_search_filter(self, entries: List[HostEntry]) -> List[HostEntry]: def _apply_legacy_search_filter(self, entries: List[HostEntry]) -> List[HostEntry]:
"""Apply legacy search filter for backward compatibility.""" """Apply legacy search filter for backward compatibility."""
@ -238,11 +249,6 @@ class TableHandler:
): ):
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index] current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Sort the entries
self.app.hosts_file.entries.sort(
key=lambda entry: entry.ip_address, reverse=not self.app.sort_ascending
)
# Refresh the table and restore cursor position # Refresh the table and restore cursor position
self.populate_entries_table() self.populate_entries_table()
self.restore_cursor_position(current_entry) self.restore_cursor_position(current_entry)
@ -300,12 +306,6 @@ class TableHandler:
): ):
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index] current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Sort the entries
self.app.hosts_file.entries.sort(
key=lambda entry: entry.hostnames[0].lower() if entry.hostnames else "",
reverse=not self.app.sort_ascending,
)
# Refresh the table and restore cursor position # Refresh the table and restore cursor position
self.populate_entries_table() self.populate_entries_table()
self.restore_cursor_position(current_entry) self.restore_cursor_position(current_entry)

View file

@ -12,6 +12,7 @@ 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
class TestHostsManagerApp: class TestHostsManagerApp:
@ -394,8 +395,8 @@ class TestHostsManagerApp:
args = app.push_screen.call_args[0] args = app.push_screen.call_args[0]
assert len(args) >= 1 # ConfigModal instance assert len(args) >= 1 # ConfigModal instance
def test_action_sort_by_ip_ascending(self): def test_action_sort_by_ip_orders_display_without_changing_file_order(self):
"""Test sorting by IP address in ascending order.""" """Sorting by IP changes the display order without changing file order."""
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
@ -405,17 +406,26 @@ class TestHostsManagerApp:
): ):
app = HostsManagerApp() app = HostsManagerApp()
# Add test entries in reverse order mock_config.should_show_default_entries.return_value = True
# Keep default entries deliberately out of their fixed display order.
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
app.hosts_file.add_entry( app.hosts_file.add_entry(
HostEntry(ip_address="192.168.1.1", hostnames=["router"]) HostEntry(ip_address="192.168.1.1", hostnames=["router"])
) )
app.hosts_file.add_entry( app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) HostEntry(ip_address="::1", hostnames=["localhost"])
) )
app.hosts_file.add_entry( app.hosts_file.add_entry(
HostEntry(ip_address="10.0.0.1", hostnames=["test"]) HostEntry(ip_address="10.0.0.1", hostnames=["test"])
) )
app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="255.255.255.255", hostnames=["broadcasthost"])
)
original_order = [entry.ip_address for entry in app.hosts_file.entries]
# Mock the table_handler methods to avoid UI queries # Mock the table_handler methods to avoid UI queries
app.table_handler.populate_entries_table = Mock() app.table_handler.populate_entries_table = Mock()
@ -424,17 +434,30 @@ class TestHostsManagerApp:
app.action_sort_by_ip() app.action_sort_by_ip()
# Check that entries are sorted by IP address assert [
assert app.hosts_file.entries[0].ip_address == "10.0.0.1" # Sorted by IP entry.ip_address for entry in app.table_handler.get_visible_entries()
assert app.hosts_file.entries[1].ip_address == "127.0.0.1" ] == ["127.0.0.1", "255.255.255.255", "::1", "10.0.0.1", "192.168.1.1"]
assert app.hosts_file.entries[2].ip_address == "192.168.1.1" assert [
entry.ip_address for entry in app.hosts_file.entries
] == original_order
assert app.sort_column == "ip" assert app.sort_column == "ip"
assert app.sort_ascending is True assert app.sort_ascending is True
app.table_handler.populate_entries_table.assert_called_once() app.table_handler.populate_entries_table.assert_called_once()
def test_action_sort_by_hostname_ascending(self): app.action_sort_by_ip()
"""Test sorting by hostname in ascending order."""
assert [
entry.ip_address for entry in app.table_handler.get_visible_entries()
] == ["127.0.0.1", "255.255.255.255", "::1", "192.168.1.1", "10.0.0.1"]
assert [
entry.ip_address for entry in app.hosts_file.entries
] == original_order
def test_action_sort_by_hostname_keeps_default_entries_first_in_both_directions(
self,
):
"""Sorting by hostname leaves Default Entries first when toggled."""
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config) mock_config = Mock(spec=Config)
@ -444,17 +467,25 @@ class TestHostsManagerApp:
): ):
app = HostsManagerApp() app = HostsManagerApp()
# Add test entries in reverse alphabetical order mock_config.should_show_default_entries.return_value = True
app.hosts_file = HostsFile() app.hosts_file = HostsFile()
app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["zebra"])
)
app.hosts_file.add_entry( app.hosts_file.add_entry(
HostEntry(ip_address="192.168.1.1", hostnames=["alpha"]) HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])
) )
app.hosts_file.add_entry( app.hosts_file.add_entry(
HostEntry(ip_address="10.0.0.1", hostnames=["beta"]) HostEntry(ip_address="::1", hostnames=["localhost"])
) )
app.hosts_file.add_entry(
HostEntry(ip_address="10.0.0.1", hostnames=["zebra"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
)
app.hosts_file.add_entry(
HostEntry(ip_address="255.255.255.255", hostnames=["broadcasthost"])
)
original_order = [entry.hostnames[0] for entry in app.hosts_file.entries]
# Mock the table_handler methods to avoid UI queries # Mock the table_handler methods to avoid UI queries
app.table_handler.populate_entries_table = Mock() app.table_handler.populate_entries_table = Mock()
@ -463,15 +494,76 @@ class TestHostsManagerApp:
app.action_sort_by_hostname() app.action_sort_by_hostname()
# Check that entries are sorted alphabetically assert [
assert app.hosts_file.entries[0].hostnames[0] == "alpha" entry.ip_address for entry in app.table_handler.get_visible_entries()
assert app.hosts_file.entries[1].hostnames[0] == "beta" ] == ["127.0.0.1", "255.255.255.255", "::1", "192.168.1.1", "10.0.0.1"]
assert app.hosts_file.entries[2].hostnames[0] == "zebra"
assert app.sort_column == "hostname" assert app.sort_column == "hostname"
assert app.sort_ascending is True assert app.sort_ascending is True
app.table_handler.populate_entries_table.assert_called_once() app.table_handler.populate_entries_table.assert_called_once()
app.action_sort_by_hostname()
assert [
entry.ip_address for entry in app.table_handler.get_visible_entries()
] == ["127.0.0.1", "255.255.255.255", "::1", "10.0.0.1", "192.168.1.1"]
assert [
entry.hostnames[0] for entry in app.hosts_file.entries
] == original_order
def test_sorting_in_read_only_mode_preserves_file_order_when_mutation_autosaves(
self,
):
"""A privileged mutation saves file order after sorting in Read-only Mode."""
mock_parser = Mock(spec=HostsParser)
mock_config = Mock(spec=Config)
mock_config.should_show_default_entries.return_value = True
with (
patch("hosts.tui.app.HostsParser", return_value=mock_parser),
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.168.1.1", hostnames=["router"]),
HostEntry(ip_address="10.0.0.1", hostnames=["test"]),
]
)
app.table_handler.populate_entries_table = Mock()
app.table_handler.restore_cursor_position = Mock()
app.details_handler.update_entry_details = Mock()
app.set_timer = Mock()
app.update_status = Mock()
serialized_files = []
app.manager = Mock(spec=HostsManager)
app.manager.enter_edit_mode.return_value = (True, "Edit mode enabled")
def toggle_entry(hosts_file, index):
hosts_file.toggle_entry(index)
return Mock(success=True, message="Entry toggled")
def save_hosts_file(hosts_file):
serialized_files.append(HostsParser().serialize(hosts_file))
return True, "Hosts file saved successfully"
app.manager.execute_toggle_command.side_effect = toggle_entry
app.manager.save_hosts_file.side_effect = save_hosts_file
app.action_sort_by_ip()
assert app.edit_mode is False
app.action_toggle_edit_mode()
app.action_toggle_entry()
assert app.edit_mode is True
assert len(serialized_files) == 1
assert serialized_files[0].index("router") < serialized_files[0].index(
"test"
)
def test_data_table_row_highlighted_event(self): def test_data_table_row_highlighted_event(self):
"""Test DataTable row highlighting event handling.""" """Test DataTable row highlighting event handling."""
mock_parser = Mock(spec=HostsParser) mock_parser = Mock(spec=HostsParser)