Fix #6: Make advanced filtering reachable from the TUI
This commit is contained in:
parent
780d9c12fe
commit
6d4a4ccc19
6 changed files with 734 additions and 0 deletions
|
|
@ -13,6 +13,8 @@ on macOS and Linux.
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- Browses, searches, and sorts active and inactive Host Entries.
|
- 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.
|
- Hides or shows protected localhost and broadcasthost Default Entries.
|
||||||
- Adds, edits, deletes, activates, deactivates, and reorders Host Entries.
|
- Adds, edits, deletes, activates, deactivates, and reorders Host Entries.
|
||||||
- Creates DNS Entries and manually refreshes their resolved IP addresses.
|
- 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 |
|
| `d` | Delete the selected Host Entry |
|
||||||
| `Space` | Activate or deactivate the selected Host Entry |
|
| `Space` | Activate or deactivate the selected Host Entry |
|
||||||
| `Ctrl+S` | Save the current in-memory state |
|
| `Ctrl+S` | Save the current in-memory state |
|
||||||
|
| `Ctrl+F` | Open advanced filters |
|
||||||
| `?` | Show help |
|
| `?` | Show help |
|
||||||
| `q` or `Ctrl+C` | Quit |
|
| `q` or `Ctrl+C` | Quit |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
- select a Host Entry and inspect all of its hostnames, comment, state, and DNS
|
||||||
details;
|
details;
|
||||||
- search by hostname, IP address, or comment;
|
- 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;
|
- sort by IP address or Canonical Hostname;
|
||||||
- show or hide Default Entries from the configuration screen; and
|
- show or hide Default Entries from the configuration screen; and
|
||||||
- reload `/etc/hosts` from disk.
|
- reload `/etc/hosts` from disk.
|
||||||
|
|
@ -221,6 +223,7 @@ continually retrying it.
|
||||||
| `i` | Sort by IP address |
|
| `i` | Sort by IP address |
|
||||||
| `h` | Sort by Canonical Hostname |
|
| `h` | Sort by Canonical Hostname |
|
||||||
| `Ctrl+R` | Reload `/etc/hosts` |
|
| `Ctrl+R` | Reload `/etc/hosts` |
|
||||||
|
| `Ctrl+F` | Open advanced filters |
|
||||||
| `c` | Open configuration |
|
| `c` | Open configuration |
|
||||||
| `?` | Show help |
|
| `?` | Show help |
|
||||||
| `q` or `Ctrl+C` | Quit |
|
| `q` or `Ctrl+C` | Quit |
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from ..core.filters import EntryFilter, FilterOptions
|
||||||
from .config_modal import ConfigModal
|
from .config_modal import ConfigModal
|
||||||
from .add_entry_modal import AddEntryModal
|
from .add_entry_modal import AddEntryModal
|
||||||
from .delete_confirmation_modal import DeleteConfirmationModal
|
from .delete_confirmation_modal import DeleteConfirmationModal
|
||||||
|
from .filter_modal import FilterModal
|
||||||
from .custom_footer import CustomFooter
|
from .custom_footer import CustomFooter
|
||||||
from .styles import HOSTS_MANAGER_CSS
|
from .styles import HOSTS_MANAGER_CSS
|
||||||
from .keybindings import HOSTS_MANAGER_BINDINGS
|
from .keybindings import HOSTS_MANAGER_BINDINGS
|
||||||
|
|
@ -514,6 +515,41 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
self.push_screen(ConfigModal(self.config), handle_config_result)
|
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:
|
def action_sort_by_ip(self) -> None:
|
||||||
"""Sort entries by IP address, toggle ascending/descending."""
|
"""Sort entries by IP address, toggle ascending/descending."""
|
||||||
self.table_handler.sort_entries_by_ip()
|
self.table_handler.sort_entries_by_ip()
|
||||||
|
|
|
||||||
595
src/hosts/tui/filter_modal.py
Normal file
595
src/hosts/tui/filter_modal.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -27,6 +27,14 @@ HOSTS_MANAGER_BINDINGS = [
|
||||||
id="left:toggle_edit_mode",
|
id="left:toggle_edit_mode",
|
||||||
),
|
),
|
||||||
Binding("c", "config", "Configuration", show=True, id="right:config"),
|
Binding("c", "config", "Configuration", show=True, id="right:config"),
|
||||||
|
Binding(
|
||||||
|
"ctrl+f",
|
||||||
|
"show_filters",
|
||||||
|
"Filter entries",
|
||||||
|
show=True,
|
||||||
|
id="right:filters",
|
||||||
|
priority=True,
|
||||||
|
),
|
||||||
Binding(
|
Binding(
|
||||||
"question_mark",
|
"question_mark",
|
||||||
"help",
|
"help",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,15 @@
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from textual.app import SuspendNotSupported
|
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.app import HostsManagerApp
|
||||||
|
from src.hosts.tui.custom_footer import CustomFooter
|
||||||
|
from src.hosts.tui.filter_modal import FilterModal
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
@ -14,6 +20,89 @@ def suspended_tui():
|
||||||
yield
|
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:
|
class TestPrivilegedModeAuthorization:
|
||||||
"""Test the user-visible privileged-mode authorization flow."""
|
"""Test the user-visible privileged-mode authorization flow."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue