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
|
||||
|
||||
- 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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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",
|
||||
),
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue