Fix #6: Make advanced filtering reachable from the TUI

This commit is contained in:
Philip Henning 2026-09-04 14:47:38 +02:00
parent 780d9c12fe
commit 97b4875aca
6 changed files with 723 additions and 0 deletions

View file

@ -13,6 +13,8 @@ on macOS and Linux.
## What it does
- Browses, searches, and sorts active and inactive Host Entries.
- Filters Host Entries by status, entry type, DNS resolution state, and search
fields.
- Hides or shows protected localhost and broadcasthost Default Entries.
- Adds, edits, deletes, activates, deactivates, and reorders Host Entries.
- Creates DNS Entries and manually refreshes their resolved IP addresses.
@ -65,6 +67,7 @@ including persistence behavior and manual recovery.
| `d` | Delete the selected Host Entry |
| `Space` | Activate or deactivate the selected Host Entry |
| `Ctrl+S` | Save the current in-memory state |
| `Ctrl+F` | Open advanced filters |
| `?` | Show help |
| `q` or `Ctrl+C` | Quit |

View file

@ -29,6 +29,8 @@ The application opens in Read-only Mode. In this mode you can:
- select a Host Entry and inspect all of its hostnames, comment, state, and DNS
details;
- search by hostname, IP address, or comment;
- use `Ctrl+F` to combine status, entry-type, DNS-resolution, and search
filters;
- sort by IP address or Canonical Hostname;
- show or hide Default Entries from the configuration screen; and
- reload `/etc/hosts` from disk.
@ -221,6 +223,7 @@ continually retrying it.
| `i` | Sort by IP address |
| `h` | Sort by Canonical Hostname |
| `Ctrl+R` | Reload `/etc/hosts` |
| `Ctrl+F` | Open advanced filters |
| `c` | Open configuration |
| `?` | Show help |
| `q` or `Ctrl+C` | Quit |

View file

@ -30,6 +30,7 @@ from ..core.filters import EntryFilter, FilterOptions
from .config_modal import ConfigModal
from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal
from .filter_modal import FilterModal
from .custom_footer import CustomFooter
from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS
@ -514,6 +515,41 @@ class HostsManagerApp(App):
self.push_screen(ConfigModal(self.config), handle_config_result)
def action_show_filters(self) -> None:
"""Open the advanced filter controls and apply their returned options."""
def handle_filter_result(filter_options: FilterOptions | None) -> None:
if filter_options is None:
self.update_status("Filtering cancelled")
return
self.current_filter_options = filter_options
self.search_term = filter_options.search_term or ""
try:
self.query_one("#search-input", Input).value = self.search_term
except Exception:
pass
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
counts = self.entry_filter.count_filtered_entries(
self.hosts_file.entries, filter_options
)
self.update_status(
f"Filter applied: showing {counts['filtered']} of {counts['total']} entries"
)
self.push_screen(
FilterModal(
initial_options=self.current_filter_options,
entries=self.hosts_file.entries,
entry_filter=self.entry_filter,
),
handle_filter_result,
)
def action_sort_by_ip(self) -> None:
"""Sort entries by IP address, toggle ascending/descending."""
self.table_handler.sort_entries_by_ip()

View file

@ -0,0 +1,594 @@
"""
Filter modal for advanced entry filtering configuration.
This module provides a professional modal dialog for configuring comprehensive
filtering options including status, type, resolution status, and search filtering.
"""
from textual.app import ComposeResult
from textual.containers import Grid, Horizontal, Container
from textual.widgets import (
Static,
Button,
Checkbox,
Input,
Select,
Label,
RadioSet,
RadioButton,
Collapsible,
)
from textual.screen import ModalScreen
from textual.reactive import reactive
from textual import on
from typing import Optional, Dict, List
from ..core.filters import FilterOptions, EntryFilter
class FilterModal(ModalScreen[Optional[FilterOptions]]):
"""Advanced filtering configuration modal."""
DEFAULT_CSS = """
FilterModal {
align: center middle;
}
#filter-dialog {
grid-size: 1;
grid-gutter: 1 2;
grid-rows: auto 1fr auto;
padding: 0 1;
width: 80;
height: auto;
border: thick $background 80%;
background: $surface;
max-height: 90%;
}
#filter-header {
dock: top;
width: 1fr;
height: 3;
content-align: center middle;
text-style: bold;
background: $primary;
color: $text;
}
#filter-content {
layout: vertical;
overflow-y: auto;
height: auto;
max-height: 70vh;
padding: 1;
}
#filter-actions {
dock: bottom;
layout: horizontal;
width: 1fr;
height: 3;
align: center middle;
padding: 0 1;
background: $panel;
}
.filter-section {
margin: 1 0;
padding: 1;
border: round $primary 20%;
background: $panel;
}
.filter-section-title {
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.filter-checkboxes {
layout: vertical;
margin: 0 2;
}
.filter-radios {
layout: vertical;
margin: 0 2;
}
.filter-input-row {
layout: horizontal;
margin: 0 2;
height: 3;
align: left middle;
}
.filter-input-label {
width: 20;
content-align: left middle;
margin-right: 1;
}
.filter-input {
width: 30;
}
.preset-row {
layout: horizontal;
margin: 1 2;
height: 3;
align: left middle;
}
.preset-select {
width: 30;
margin-right: 2;
}
Button {
margin: 0 1;
min-width: 12;
}
Checkbox {
margin: 0 1;
}
RadioButton {
margin: 0 1;
}
.count-display {
text-style: italic;
color: $text-muted;
content-align: center middle;
height: 1;
margin: 1 0;
}
"""
# Reactive properties for real-time updates
current_options: reactive[FilterOptions] = reactive(FilterOptions())
entry_counts: reactive[Dict[str, int]] = reactive({})
def __init__(
self,
initial_options: Optional[FilterOptions] = None,
entries: Optional[List] = None,
entry_filter: Optional[EntryFilter] = None,
):
"""
Initialize filter modal.
Args:
initial_options: Current filter options to display
entries: List of entries for count preview
entry_filter: EntryFilter instance for applying filters
"""
super().__init__()
self.current_options = initial_options or FilterOptions()
self.entries = entries or []
self.entry_filter = entry_filter or EntryFilter()
self.entry_counts = self._calculate_counts()
def compose(self) -> ComposeResult:
"""Compose the filter modal interface."""
with Grid(id="filter-dialog"):
yield Static("Advanced Filtering", id="filter-header")
with Container(id="filter-content"):
# Filter presets section
with Collapsible(title="Filter Presets", collapsed=False):
with Container(classes="filter-section"):
with Horizontal(classes="preset-row"):
yield Label("Preset:", classes="filter-input-label")
yield Select(
[
(name, name)
for name in self.entry_filter.get_preset_names()
],
value=self.current_options.preset_name or Select.BLANK,
id="preset-select",
classes="preset-select",
)
yield Button("Load", id="load-preset", variant="primary")
yield Button("Save", id="save-preset")
yield Button("Delete", id="delete-preset", variant="error")
# Status filtering section
with Collapsible(title="Status Filtering", collapsed=False):
with Container(classes="filter-section"):
yield Static("Status Filtering", classes="filter-section-title")
with RadioSet(id="status-filter-type"):
yield RadioButton("Show All", id="status-all")
yield RadioButton("Active Only", id="status-active")
yield RadioButton("Inactive Only", id="status-inactive")
yield RadioButton("Custom", id="status-custom")
with Container(
classes="filter-checkboxes", id="status-custom-options"
):
yield Checkbox(
"Show Active Entries", value=True, id="show-active"
)
yield Checkbox(
"Show Inactive Entries", value=True, id="show-inactive"
)
# DNS type filtering section
with Collapsible(title="Entry Type Filtering", collapsed=False):
with Container(classes="filter-section"):
yield Static(
"Entry Type Filtering", classes="filter-section-title"
)
with RadioSet(id="type-filter-type"):
yield RadioButton("Show All", id="type-all")
yield RadioButton("DNS Entries Only", id="type-dns")
yield RadioButton("IP Entries Only", id="type-ip")
yield RadioButton("Custom", id="type-custom")
with Container(
classes="filter-checkboxes", id="type-custom-options"
):
yield Checkbox(
"Show DNS Entries", value=True, id="show-dns"
)
yield Checkbox("Show IP Entries", value=True, id="show-ip")
# DNS resolution status filtering section
with Collapsible(title="Resolution Status Filtering", collapsed=False):
with Container(classes="filter-section"):
yield Static(
"Resolution Status Filtering",
classes="filter-section-title",
)
with RadioSet(id="resolution-filter-type"):
yield RadioButton("Show All", id="resolution-all")
yield RadioButton(
"Resolved Only",
id="resolution-resolved",
)
yield RadioButton(
"Mismatches Only",
id="resolution-mismatch",
)
yield RadioButton("Custom", id="resolution-custom")
with Container(
classes="filter-checkboxes", id="resolution-custom-options"
):
yield Checkbox(
"Show Resolved", value=True, id="show-resolved"
)
yield Checkbox(
"Show Unresolved", value=True, id="show-unresolved"
)
yield Checkbox(
"Show Resolving", value=True, id="show-resolving"
)
yield Checkbox("Show Failed", value=True, id="show-failed")
yield Checkbox(
"Show Mismatched", value=True, id="show-mismatched"
)
# Search filtering section
with Collapsible(title="Search Filtering", collapsed=True):
with Container(classes="filter-section"):
yield Static("Search Filtering", classes="filter-section-title")
with Horizontal(classes="filter-input-row"):
yield Label("Search term:", classes="filter-input-label")
yield Input(
placeholder="Enter search term...",
value=self.current_options.search_term or "",
id="search-term",
classes="filter-input",
)
with Container(classes="filter-checkboxes"):
yield Checkbox(
"Search in hostnames", value=True, id="search-hostnames"
)
yield Checkbox(
"Search in comments", value=True, id="search-comments"
)
yield Checkbox(
"Search in IP addresses", value=True, id="search-ips"
)
yield Checkbox(
"Case sensitive",
value=False,
id="search-case-sensitive",
)
# Entry count display
yield Static("", id="count-display", classes="count-display")
with Horizontal(id="filter-actions"):
yield Button("Apply", id="apply", variant="primary")
yield Button("Reset", id="reset")
yield Button("Cancel", id="cancel")
def on_mount(self) -> None:
"""Initialize the modal with current options."""
self._update_ui_from_options()
self._update_count_display()
def _update_ui_from_options(self) -> None:
"""Update UI controls to reflect current options."""
options = self.current_options
# Status filtering
if options.active_only:
self.query_one("#status-active", RadioButton).value = True
elif options.inactive_only:
self.query_one("#status-inactive", RadioButton).value = True
elif options.show_active and options.show_inactive:
self.query_one("#status-all", RadioButton).value = True
else:
self.query_one("#status-custom", RadioButton).value = True
self.query_one("#show-active", Checkbox).value = options.show_active
self.query_one("#show-inactive", Checkbox).value = options.show_inactive
# Type filtering
if options.dns_only:
self.query_one("#type-dns", RadioButton).value = True
elif options.ip_only:
self.query_one("#type-ip", RadioButton).value = True
elif options.show_dns_entries and options.show_ip_entries:
self.query_one("#type-all", RadioButton).value = True
else:
self.query_one("#type-custom", RadioButton).value = True
self.query_one("#show-dns", Checkbox).value = options.show_dns_entries
self.query_one("#show-ip", Checkbox).value = options.show_ip_entries
# Resolution status filtering
if options.resolved_only:
self.query_one("#resolution-resolved", RadioButton).value = True
elif options.mismatch_only:
self.query_one("#resolution-mismatch", RadioButton).value = True
elif (
options.show_resolved
and options.show_unresolved
and options.show_resolving
and options.show_failed
and options.show_mismatched
):
self.query_one("#resolution-all", RadioButton).value = True
else:
self.query_one("#resolution-custom", RadioButton).value = True
self.query_one("#show-resolved", Checkbox).value = options.show_resolved
self.query_one("#show-unresolved", Checkbox).value = options.show_unresolved
self.query_one("#show-resolving", Checkbox).value = options.show_resolving
self.query_one("#show-failed", Checkbox).value = options.show_failed
self.query_one("#show-mismatched", Checkbox).value = options.show_mismatched
# Search filtering
if options.search_term:
self.query_one("#search-term", Input).value = options.search_term
self.query_one(
"#search-hostnames", Checkbox
).value = options.search_in_hostnames
self.query_one("#search-comments", Checkbox).value = options.search_in_comments
self.query_one("#search-ips", Checkbox).value = options.search_in_ips
self.query_one(
"#search-case-sensitive", Checkbox
).value = options.case_sensitive
self._update_custom_options_visibility()
def _update_custom_options_visibility(self) -> None:
"""Show/hide custom option containers based on radio selections."""
# Status custom options
status_custom = self.query_one("#status-custom", RadioButton).value
status_container = self.query_one("#status-custom-options")
status_container.display = status_custom
# Type custom options
type_custom = self.query_one("#type-custom", RadioButton).value
type_container = self.query_one("#type-custom-options")
type_container.display = type_custom
# Resolution custom options
resolution_custom = self.query_one("#resolution-custom", RadioButton).value
resolution_container = self.query_one("#resolution-custom-options")
resolution_container.display = resolution_custom
def _calculate_counts(self) -> Dict[str, int]:
"""Calculate entry counts for current filter options."""
if not self.entries:
return {}
return self.entry_filter.count_filtered_entries(
self.entries, self.current_options
)
def _update_count_display(self) -> None:
"""Update the count display with current filter results."""
counts = self._calculate_counts()
if counts:
count_text = (
f"Showing {counts['filtered']} of {counts['total']} entries "
f"({counts['active']} active, {counts['inactive']} inactive)"
)
else:
count_text = "No entries to filter"
self.query_one("#count-display", Static).update(count_text)
def _get_current_options_from_ui(self) -> FilterOptions:
"""Extract current filter options from UI controls."""
# Status filtering
status_type = self.query_one("#status-filter-type", RadioSet).pressed_button
if status_type and status_type.id == "status-active":
show_active, show_inactive = True, False
active_only, inactive_only = True, False
elif status_type and status_type.id == "status-inactive":
show_active, show_inactive = False, True
active_only, inactive_only = False, True
elif status_type and status_type.id == "status-all":
show_active, show_inactive = True, True
active_only, inactive_only = False, False
else: # custom
show_active = self.query_one("#show-active", Checkbox).value
show_inactive = self.query_one("#show-inactive", Checkbox).value
active_only, inactive_only = False, False
# Type filtering
type_type = self.query_one("#type-filter-type", RadioSet).pressed_button
if type_type and type_type.id == "type-dns":
show_dns_entries, show_ip_entries = True, False
dns_only, ip_only = True, False
elif type_type and type_type.id == "type-ip":
show_dns_entries, show_ip_entries = False, True
dns_only, ip_only = False, True
elif type_type and type_type.id == "type-all":
show_dns_entries, show_ip_entries = True, True
dns_only, ip_only = False, False
else: # custom
show_dns_entries = self.query_one("#show-dns", Checkbox).value
show_ip_entries = self.query_one("#show-ip", Checkbox).value
dns_only, ip_only = False, False
# Resolution status filtering
resolution_type = self.query_one(
"#resolution-filter-type", RadioSet
).pressed_button
if resolution_type and resolution_type.id == "resolution-resolved":
resolved_only, mismatch_only = True, False
(
show_resolved,
show_unresolved,
show_resolving,
show_failed,
show_mismatched,
) = True, False, False, False, False
elif resolution_type and resolution_type.id == "resolution-mismatch":
resolved_only, mismatch_only = False, True
(
show_resolved,
show_unresolved,
show_resolving,
show_failed,
show_mismatched,
) = False, False, False, False, True
elif resolution_type and resolution_type.id == "resolution-all":
resolved_only, mismatch_only = False, False
(
show_resolved,
show_unresolved,
show_resolving,
show_failed,
show_mismatched,
) = True, True, True, True, True
else: # custom
resolved_only, mismatch_only = False, False
show_resolved = self.query_one("#show-resolved", Checkbox).value
show_unresolved = self.query_one("#show-unresolved", Checkbox).value
show_resolving = self.query_one("#show-resolving", Checkbox).value
show_failed = self.query_one("#show-failed", Checkbox).value
show_mismatched = self.query_one("#show-mismatched", Checkbox).value
# Search filtering
search_term = self.query_one("#search-term", Input).value or None
search_hostnames = self.query_one("#search-hostnames", Checkbox).value
search_comments = self.query_one("#search-comments", Checkbox).value
search_ips = self.query_one("#search-ips", Checkbox).value
case_sensitive = self.query_one("#search-case-sensitive", Checkbox).value
return FilterOptions(
show_active=show_active,
show_inactive=show_inactive,
active_only=active_only,
inactive_only=inactive_only,
show_dns_entries=show_dns_entries,
show_ip_entries=show_ip_entries,
dns_only=dns_only,
ip_only=ip_only,
show_resolved=show_resolved,
show_unresolved=show_unresolved,
show_resolving=show_resolving,
show_failed=show_failed,
show_mismatched=show_mismatched,
mismatch_only=mismatch_only,
resolved_only=resolved_only,
search_term=search_term,
search_in_hostnames=search_hostnames,
search_in_comments=search_comments,
search_in_ips=search_ips,
case_sensitive=case_sensitive,
)
@on(RadioSet.Changed)
def on_radio_changed(self, event: RadioSet.Changed) -> None:
"""Handle radio button changes."""
self._update_custom_options_visibility()
self.current_options = self._get_current_options_from_ui()
self._update_count_display()
@on(Checkbox.Changed)
@on(Input.Changed)
def on_input_changed(self) -> None:
"""Handle input changes for real-time preview."""
self.current_options = self._get_current_options_from_ui()
self._update_count_display()
@on(Button.Pressed, "#apply")
def on_apply_pressed(self) -> None:
"""Handle apply button press."""
self.dismiss(self._get_current_options_from_ui())
@on(Button.Pressed, "#cancel")
def on_cancel_pressed(self) -> None:
"""Handle cancel button press."""
self.dismiss(None)
@on(Button.Pressed, "#reset")
def on_reset_pressed(self) -> None:
"""Handle reset button press."""
self.current_options = FilterOptions()
self._update_ui_from_options()
self._update_count_display()
@on(Button.Pressed, "#load-preset")
def on_load_preset_pressed(self) -> None:
"""Handle load preset button press."""
preset_select = self.query_one("#preset-select", Select)
if preset_select.value != Select.BLANK:
preset_options = self.entry_filter.load_preset(str(preset_select.value))
if preset_options:
self.current_options = preset_options
self._update_ui_from_options()
self._update_count_display()
@on(Button.Pressed, "#save-preset")
def on_save_preset_pressed(self) -> None:
"""Handle save preset button press."""
# TODO: Implement preset name input dialog
# For now, just save with a generic name
current_options = self._get_current_options_from_ui()
preset_name = f"Custom Preset {len(self.entry_filter.presets) + 1}"
self.entry_filter.save_preset(preset_name, current_options)
# Update preset select with new preset
preset_select = self.query_one("#preset-select", Select)
preset_select.set_options(
[(name, name) for name in self.entry_filter.get_preset_names()]
)
preset_select.value = preset_name
@on(Button.Pressed, "#delete-preset")
def on_delete_preset_pressed(self) -> None:
"""Handle delete preset button press."""
preset_select = self.query_one("#preset-select", Select)
if preset_select.value != Select.BLANK:
preset_name = str(preset_select.value)
if self.entry_filter.delete_preset(preset_name):
# Update preset select options
preset_select.set_options(
[(name, name) for name in self.entry_filter.get_preset_names()]
)
preset_select.value = Select.BLANK

View file

@ -27,6 +27,14 @@ HOSTS_MANAGER_BINDINGS = [
id="left:toggle_edit_mode",
),
Binding("c", "config", "Configuration", show=True, id="right:config"),
Binding(
"ctrl+f",
"show_filters",
"Filter entries",
show=True,
id="right:filters",
priority=True,
),
Binding(
"question_mark",
"help",

View file

@ -3,9 +3,15 @@
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
from textual.app import SuspendNotSupported
from textual.widgets import RadioButton, Static
from src.hosts.core.filters import FilterOptions
from src.hosts.core.models import HostEntry, HostsFile
from src.hosts.tui.app import HostsManagerApp
from src.hosts.tui.custom_footer import CustomFooter
from src.hosts.tui.filter_modal import FilterModal
@contextmanager
@ -14,6 +20,79 @@ def suspended_tui():
yield
def app_with_filterable_entries() -> HostsManagerApp:
"""Create an app whose filter workflow cannot access the system Hosts File."""
app = HostsManagerApp()
app.hosts_file = HostsFile(
entries=[
HostEntry(ip_address="192.0.2.1", hostnames=["active.test"]),
HostEntry(
ip_address="192.0.2.2",
hostnames=["inactive.test"],
is_active=False,
),
]
)
app.load_hosts_file = Mock()
return app
@pytest.mark.asyncio
async def test_filter_shortcut_opens_modal_and_applies_the_selected_filter():
"""The filter shortcut opens a modal whose Apply action changes the view state."""
app = app_with_filterable_entries()
async with app.run_test() as pilot:
footer = app.query_one("#custom-footer", CustomFooter)
footer_text = footer.query_one("#footer-right", Static).render()
assert "ctrl+f Filter entries" in str(footer_text)
await pilot.press("ctrl+f")
assert isinstance(app.screen, FilterModal)
app.screen.query_one("#status-active", RadioButton).value = True
await pilot.pause()
await pilot.click("#apply")
await pilot.pause()
assert app.current_filter_options.active_only
assert not app.current_filter_options.show_inactive
assert [entry.hostnames[0] for entry in app.get_visible_entries()] == [
"active.test"
]
@pytest.mark.asyncio
async def test_filter_reset_clears_filters_and_cancel_preserves_applied_filters():
"""Reset clears the form on Apply, while Cancel leaves the applied filter alone."""
app = app_with_filterable_entries()
app.current_filter_options = FilterOptions(
active_only=True,
show_inactive=False,
)
async with app.run_test() as pilot:
await pilot.press("ctrl+f")
await pilot.click("#reset")
await pilot.click("#apply")
await pilot.pause()
assert app.current_filter_options.is_empty()
await pilot.press("ctrl+f")
assert isinstance(app.screen, FilterModal)
app.screen.query_one("#status-active", RadioButton).value = True
await pilot.pause()
await pilot.click("#cancel")
await pilot.pause()
assert app.current_filter_options.is_empty()
assert [entry.hostnames[0] for entry in app.get_visible_entries()] == [
"active.test",
"inactive.test",
]
class TestPrivilegedModeAuthorization:
"""Test the user-visible privileged-mode authorization flow."""