Make sorting view-only (#11)
This commit is contained in:
parent
89b37239b2
commit
1a686bd0ff
3 changed files with 124 additions and 34 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue