diff --git a/README.md b/README.md index 920a31b..68a7e35 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/user-guide.md b/docs/user-guide.md index da6bc7c..32ebf13 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 | diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 315c98c..4a669bd 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -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() diff --git a/src/hosts/tui/filter_modal.py b/src/hosts/tui/filter_modal.py new file mode 100644 index 0000000..0b7b0f5 --- /dev/null +++ b/src/hosts/tui/filter_modal.py @@ -0,0 +1,595 @@ +""" +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 import on +from textual.binding import Binding +from typing import Optional, Dict, List + +from ..core.filters import FilterOptions, EntryFilter + + +class FilterModal(ModalScreen[Optional[FilterOptions]]): + """Advanced filtering configuration modal.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + 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; + } + """ + + 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() + + 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.action_cancel() + + def action_cancel(self) -> None: + """Close the modal without changing the current filters.""" + 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 diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 9b6e752..0279815 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -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", diff --git a/tests/test_app.py b/tests/test_app.py index 3e74142..c7bc2d1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 HelpPanel, 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,89 @@ 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) + + app.action_help() + await pilot.pause() + assert isinstance(app.query_one(HelpPanel), HelpPanel) + assert any( + binding.action == "show_filters" and binding.description == "Filter entries" + for _, binding, _, _ in app.screen.active_bindings.values() + ) + app.action_help() + await pilot.pause() + + 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.press("escape") + 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."""