Compare commits

...

3 commits

Author SHA1 Message Date
phg
8d884aeb65 Remove unnecessary spacer and adjust margins in the hosts manager UI for improved layout consistency. 2025-08-14 20:33:02 +02:00
phg
5b768c004b Implement search functionality with input field and filtering in the hosts manager 2025-08-14 20:27:48 +02:00
phg
07e7e4f70f Add entry and delete entry functionality with modals in TUI application
- Implemented add_entry method in HostsManager for adding new entries.
- Created AddEntryModal for user input when adding entries.
- Implemented delete_entry method in HostsManager for removing entries.
- Created DeleteConfirmationModal for user confirmation before deletion.
- Integrated modals into HostsManagerApp for adding and deleting entries.
- Added search functionality with SearchModal for finding entries by hostname or IP address.
- Updated keybindings to include shortcuts for adding and deleting entries.
- Added HelpModal to provide keyboard shortcuts and usage information.
2025-08-14 19:47:09 +02:00
10 changed files with 1061 additions and 18 deletions

View file

@ -275,6 +275,67 @@ class HostsManager:
except Exception as e:
return False, f"Error moving entry: {e}"
def add_entry(
self,
hosts_file: HostsFile,
entry: HostEntry,
) -> Tuple[bool, str]:
"""
Add a new entry to the hosts file.
Args:
hosts_file: The hosts file to modify
entry: The new entry to add
Returns:
Tuple of (success, message)
"""
if not self.edit_mode:
return False, "Not in edit mode"
try:
# Add the new entry at the end
hosts_file.entries.append(entry)
return True, "Entry added successfully"
except Exception as e:
return False, f"Error adding entry: {e}"
def delete_entry(
self,
hosts_file: HostsFile,
index: int,
) -> Tuple[bool, str]:
"""
Delete an entry from the hosts file.
Args:
hosts_file: The hosts file to modify
index: Index of the entry to delete
Returns:
Tuple of (success, message)
"""
if not self.edit_mode:
return False, "Not in edit mode"
if not (0 <= index < len(hosts_file.entries)):
return False, "Invalid entry index"
try:
entry = hosts_file.entries[index]
# Prevent deletion of default system entries
if entry.is_default_entry():
return False, "Cannot delete default system entries"
# Remove the entry
deleted_entry = hosts_file.entries.pop(index)
return True, f"Entry deleted: {deleted_entry.canonical_hostname}"
except Exception as e:
return False, f"Error deleting entry: {e}"
def update_entry(
self,
hosts_file: HostsFile,

View file

@ -0,0 +1,238 @@
"""
Add Entry modal window for the hosts TUI application.
This module provides a floating modal window for creating new host entries.
"""
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button, Input, Checkbox, Label
from textual.screen import ModalScreen
from textual.binding import Binding
from ..core.models import HostEntry
class AddEntryModal(ModalScreen):
"""
Modal screen for adding new host entries.
Provides a floating window with input fields for creating new entries.
"""
CSS = """
AddEntryModal {
align: center middle;
}
.add-entry-container {
width: 80;
height: 25;
background: $surface;
border: thick $primary;
padding: 1;
}
.add-entry-title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.add-entry-section {
margin: 1 0;
}
.add-entry-input {
margin: 0 2;
width: 1fr;
}
.button-row {
margin-top: 2;
align: center middle;
}
.add-entry-button {
margin: 0 1;
min-width: 10;
}
.validation-error {
color: $error;
margin: 0 2;
text-style: italic;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("ctrl+s", "save", "Save"),
]
def __init__(self):
super().__init__()
def compose(self) -> ComposeResult:
"""Create the add entry modal layout."""
with Vertical(classes="add-entry-container"):
yield Static("Add New Host Entry", classes="add-entry-title")
with Vertical(classes="add-entry-section"):
yield Label("IP Address:")
yield Input(
placeholder="e.g., 192.168.1.1 or 2001:db8::1",
id="ip-address-input",
classes="add-entry-input",
)
yield Static("", id="ip-error", classes="validation-error")
with Vertical(classes="add-entry-section"):
yield Label("Hostnames (comma-separated):")
yield Input(
placeholder="e.g., example.com, www.example.com",
id="hostnames-input",
classes="add-entry-input",
)
yield Static("", id="hostnames-error", classes="validation-error")
with Vertical(classes="add-entry-section"):
yield Label("Comment (optional):")
yield Input(
placeholder="e.g., Development server",
id="comment-input",
classes="add-entry-input",
)
with Vertical(classes="add-entry-section"):
yield Checkbox("Active (enabled)", value=True, id="active-checkbox")
with Horizontal(classes="button-row"):
yield Button(
"Add Entry",
variant="primary",
id="add-button",
classes="add-entry-button",
)
yield Button(
"Cancel",
variant="default",
id="cancel-button",
classes="add-entry-button",
)
def on_mount(self) -> None:
"""Focus IP address input when modal opens."""
ip_input = self.query_one("#ip-address-input", Input)
ip_input.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "add-button":
self.action_save()
elif event.button.id == "cancel-button":
self.action_cancel()
def action_save(self) -> None:
"""Validate and save new entry."""
# Clear previous errors
self._clear_errors()
# Get form values
ip_address = self.query_one("#ip-address-input", Input).value.strip()
hostnames_str = self.query_one("#hostnames-input", Input).value.strip()
comment = self.query_one("#comment-input", Input).value.strip()
is_active = self.query_one("#active-checkbox", Checkbox).value
# Validate input
if not self._validate_input(ip_address, hostnames_str):
return
try:
# Parse hostnames
hostnames = [h.strip() for h in hostnames_str.split(",") if h.strip()]
# Create new entry
new_entry = HostEntry(
ip_address=ip_address,
hostnames=hostnames,
comment=comment if comment else None,
is_active=is_active,
)
# Close modal and return the new entry
self.dismiss(new_entry)
except ValueError as e:
# Display validation error
if "IP address" in str(e).lower():
self._show_error("ip-error", str(e))
else:
self._show_error("hostnames-error", str(e))
def action_cancel(self) -> None:
"""Cancel entry creation and close modal."""
self.dismiss(None)
def _validate_input(self, ip_address: str, hostnames_str: str) -> bool:
"""
Validate user input.
Args:
ip_address: IP address to validate
hostnames_str: Comma-separated hostnames to validate
Returns:
True if input is valid, False otherwise
"""
valid = True
# Validate IP address
if not ip_address:
self._show_error("ip-error", "IP address is required")
valid = False
# Validate hostnames
if not hostnames_str:
self._show_error("hostnames-error", "At least one hostname is required")
valid = False
else:
hostnames = [h.strip() for h in hostnames_str.split(",") if h.strip()]
if not hostnames:
self._show_error("hostnames-error", "At least one hostname is required")
valid = False
else:
# Basic hostname validation
for hostname in hostnames:
if (
" " in hostname
or not hostname.replace(".", "")
.replace("-", "")
.replace("_", "")
.isalnum()
):
self._show_error(
"hostnames-error", f"Invalid hostname format: {hostname}"
)
valid = False
break
return valid
def _show_error(self, error_id: str, message: str) -> None:
"""Show validation error message."""
try:
error_widget = self.query_one(f"#{error_id}", Static)
error_widget.update(message)
except Exception:
pass
def _clear_errors(self) -> None:
"""Clear all validation error messages."""
for error_id in ["ip-error", "hostnames-error"]:
try:
error_widget = self.query_one(f"#{error_id}", Static)
error_widget.update("")
except Exception:
pass

View file

@ -16,6 +16,9 @@ from ..core.config import Config
from ..core.manager import HostsManager
from .config_modal import ConfigModal
from .password_modal import PasswordModal
from .add_entry_modal import AddEntryModal
from .delete_confirmation_modal import DeleteConfirmationModal
from .help_modal import HelpModal
from .styles import HOSTS_MANAGER_CSS
from .keybindings import HOSTS_MANAGER_BINDINGS
from .table_handler import TableHandler
@ -42,6 +45,7 @@ class HostsManagerApp(App):
entry_edit_mode: reactive[bool] = reactive(False)
sort_column: reactive[str] = reactive("") # "ip" or "hostname"
sort_ascending: reactive[bool] = reactive(True)
search_term: reactive[str] = reactive("")
def __init__(self):
super().__init__()
@ -67,6 +71,15 @@ class HostsManagerApp(App):
yield Header()
yield Footer()
# Search bar above the panes
with Horizontal(classes="search-container") as search_container:
search_container.border_title = "Search"
yield Input(
placeholder="Filter by hostname, IP address, or comment...",
id="search-input",
classes="search-input",
)
with Horizontal(classes="hosts-container"):
# Left pane - entries table
with Vertical(classes="left-pane") as left_pane:
@ -186,15 +199,38 @@ class HostsManagerApp(App):
def on_key(self, event) -> None:
"""Handle key events to override default tab behavior in edit mode."""
# Handle tab navigation for search bar and data table
if event.key == "tab" and not self.entry_edit_mode:
search_input = self.query_one("#search-input", Input)
entries_table = self.query_one("#entries-table", DataTable)
# Check which widget currently has focus
if self.focused == search_input:
# Focus on entries table
entries_table.focus()
event.prevent_default()
return
elif self.focused == entries_table:
# Focus on search input
search_input.focus()
event.prevent_default()
return
# Delegate to edit handler for edit mode navigation
if self.edit_handler.handle_entry_edit_key_event(event):
return # Event was handled by edit handler
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle input field changes (no auto-save - changes saved on exit)."""
# Input changes are tracked but not automatically saved
# Changes will be validated and saved when exiting edit mode
pass
if event.input.id == "search-input":
# Update search term and filter entries
self.search_term = event.value.strip()
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
else:
# Edit form input changes are tracked but not automatically saved
# Changes will be validated and saved when exiting edit mode
pass
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
"""Handle checkbox changes (no auto-save - changes saved on exit)."""
@ -212,11 +248,8 @@ class HostsManagerApp(App):
self.update_status("Hosts file reloaded")
def action_help(self) -> None:
"""Show help information."""
# For now, just update the status with help info
self.update_status(
"Help: ↑/↓ Navigate, r Reload, q Quit, h Help, c Config, e Edit"
)
"""Show help modal."""
self.push_screen(HelpModal())
def action_config(self) -> None:
"""Show configuration modal."""
@ -355,6 +388,84 @@ class HostsManagerApp(App):
"""Save the hosts file to disk."""
self.navigation_handler.save_hosts_file()
def action_add_entry(self) -> None:
"""Show the add entry modal."""
if not self.edit_mode:
self.update_status(
"❌ Cannot add entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
return
def handle_add_entry_result(new_entry) -> None:
if new_entry is None:
# User cancelled
self.update_status("Entry creation cancelled")
return
# Add the entry using the manager
success, message = self.manager.add_entry(self.hosts_file, new_entry)
if success:
# Refresh the table
self.table_handler.populate_entries_table()
# Move cursor to the newly added entry (last entry)
self.selected_entry_index = len(self.hosts_file.entries) - 1
self.table_handler.restore_cursor_position(new_entry)
self.update_status(f"{message}")
else:
self.update_status(f"{message}")
self.push_screen(AddEntryModal(), handle_add_entry_result)
def action_delete_entry(self) -> None:
"""Show the delete confirmation modal for the selected entry."""
if not self.edit_mode:
self.update_status(
"❌ Cannot delete entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
)
return
if not self.hosts_file.entries:
self.update_status("No entries to delete")
return
if self.selected_entry_index >= len(self.hosts_file.entries):
self.update_status("Invalid entry selected")
return
entry = self.hosts_file.entries[self.selected_entry_index]
if entry.is_default_entry():
self.update_status("❌ Cannot delete system default entry")
return
def handle_delete_confirmation(confirmed: bool) -> None:
if not confirmed:
self.update_status("Entry deletion cancelled")
return
# Delete the entry using the manager
success, message = self.manager.delete_entry(
self.hosts_file, self.selected_entry_index
)
if success:
# Adjust selected index if needed
if self.selected_entry_index >= len(self.hosts_file.entries):
self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1)
# Refresh the table
self.table_handler.populate_entries_table()
self.details_handler.update_entry_details()
self.update_status(f"{message}")
else:
self.update_status(f"{message}")
self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation)
def action_search(self) -> None:
"""Focus the search bar for filtering entries."""
search_input = self.query_one("#search-input", Input)
search_input.focus()
self.update_status("Use the search bar to filter entries")
def action_quit(self) -> None:
"""Quit the application."""
self.navigation_handler.quit_application()

View file

@ -0,0 +1,125 @@
"""
Delete confirmation modal window for the hosts TUI application.
This module provides a confirmation dialog for deleting host entries.
"""
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button
from textual.screen import ModalScreen
from textual.binding import Binding
from ..core.models import HostEntry
class DeleteConfirmationModal(ModalScreen):
"""
Modal screen for confirming entry deletion.
Provides a confirmation dialog before deleting host entries.
"""
CSS = """
DeleteConfirmationModal {
align: center middle;
}
.delete-container {
width: 60;
height: 15;
background: $surface;
border: thick $error;
padding: 1;
}
.delete-title {
text-align: center;
text-style: bold;
color: $error;
margin-bottom: 1;
}
.delete-message {
text-align: center;
margin: 1 0;
}
.entry-info {
text-align: center;
text-style: bold;
color: $primary;
margin: 1 0;
}
.button-row {
margin-top: 2;
align: center middle;
}
.delete-button {
margin: 0 1;
min-width: 10;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "confirm", "Delete"),
]
def __init__(self, entry: HostEntry):
super().__init__()
self.entry = entry
def compose(self) -> ComposeResult:
"""Create the delete confirmation modal layout."""
with Vertical(classes="delete-container"):
yield Static("Delete Entry", classes="delete-title")
yield Static(
"Are you sure you want to delete this entry?", classes="delete-message"
)
# Show entry details
hostnames_str = ", ".join(self.entry.hostnames)
yield Static(
f"{self.entry.ip_address}{hostnames_str}", classes="entry-info"
)
if self.entry.comment:
yield Static(f"Comment: {self.entry.comment}", classes="delete-message")
with Horizontal(classes="button-row"):
yield Button(
"Delete",
variant="error",
id="delete-button",
classes="delete-button",
)
yield Button(
"Cancel",
variant="default",
id="cancel-button",
classes="delete-button",
)
def on_mount(self) -> None:
"""Focus cancel button by default for safety."""
cancel_button = self.query_one("#cancel-button", Button)
cancel_button.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "delete-button":
self.action_confirm()
elif event.button.id == "cancel-button":
self.action_cancel()
def action_confirm(self) -> None:
"""Confirm deletion and close modal."""
self.dismiss(True)
def action_cancel(self) -> None:
"""Cancel deletion and close modal."""
self.dismiss(False)

178
src/hosts/tui/help_modal.py Normal file
View file

@ -0,0 +1,178 @@
"""
Help modal window for the hosts TUI application.
This module provides a help dialog showing keyboard shortcuts and usage information.
"""
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal, ScrollableContainer
from textual.widgets import Static, Button
from textual.screen import ModalScreen
from textual.binding import Binding
class HelpModal(ModalScreen):
"""
Modal screen showing help and keyboard shortcuts.
Provides comprehensive help information for using the application.
"""
CSS = """
HelpModal {
align: center middle;
}
.help-container {
width: 90;
height: 40;
background: $surface;
border: thick $primary;
padding: 1;
}
.help-title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.help-content {
height: 35;
margin: 1 0;
}
.help-section {
margin-bottom: 1;
}
.help-section-title {
text-style: bold;
color: $primary;
margin-bottom: 0;
}
.help-item {
margin: 0 2;
}
.keyboard-shortcut {
text-style: bold;
color: $accent;
}
.button-row {
margin-top: 1;
align: center middle;
}
.help-button {
min-width: 10;
}
"""
BINDINGS = [
Binding("escape", "close", "Close"),
Binding("enter", "close", "Close"),
]
def compose(self) -> ComposeResult:
"""Create the help modal layout."""
with Vertical(classes="help-container"):
yield Static("/etc/hosts Manager - Help", classes="help-title")
with ScrollableContainer(classes="help-content"):
# Navigation section
with Vertical(classes="help-section"):
yield Static("Navigation", classes="help-section-title")
yield Static("↑ ↓ - Navigate entries", classes="help-item")
yield Static("Enter - Select entry", classes="help-item")
yield Static("Tab - Navigate between panes", classes="help-item")
# Main Commands section
with Vertical(classes="help-section"):
yield Static("Main Commands", classes="help-section-title")
yield Static(
"[bold]r[/bold] Reload [bold]h[/bold] Help [bold]c[/bold] Config [bold]Ctrl+F[/bold] Search [bold]q[/bold] Quit",
classes="help-item",
)
yield Static(
"[bold]i[/bold] Sort by IP [bold]n[/bold] Sort by hostname",
classes="help-item",
)
# Edit Mode section
with Vertical(classes="help-section"):
yield Static("Edit Mode Commands", classes="help-section-title")
yield Static(
"[bold]Ctrl+E[/bold] - Toggle edit mode (requires sudo)",
classes="help-item",
)
yield Static(
"[italic]Edit mode commands:[/italic] [bold]Space[/bold] Toggle [bold]a[/bold] Add [bold]d[/bold] Delete [bold]e[/bold] Edit",
classes="help-item",
)
yield Static(
"[bold]Shift+↑/↓[/bold] Move entry [bold]Ctrl+S[/bold] Save file",
classes="help-item",
)
# Form Navigation section
with Vertical(classes="help-section"):
yield Static(
"Form & Modal Navigation", classes="help-section-title"
)
yield Static(
"[bold]Tab/Shift+Tab[/bold] Navigate fields [bold]Enter[/bold] Confirm/Save [bold]Escape[/bold] Cancel/Exit",
classes="help-item",
)
# Special Commands section
with Vertical(classes="help-section"):
yield Static(
"Special Dialog Commands", classes="help-section-title"
)
yield Static(
"[bold]F3[/bold] Search in search dialog [bold]s[/bold] Save changes [bold]d[/bold] Discard changes",
classes="help-item",
)
# Status and Tips section
with Vertical(classes="help-section"):
yield Static("Entry Status & Tips", classes="help-section-title")
yield Static(
"✓ Active (enabled) ✗ Inactive (commented out)",
classes="help-item",
)
yield Static(
"• Edit mode commands require [bold]Ctrl+E[/bold] first",
classes="help-item",
)
yield Static(
"• Search supports partial matches in IP, hostname, or comment",
classes="help-item",
)
yield Static(
"• Edit mode creates automatic backups • System entries cannot be modified",
classes="help-item",
)
with Horizontal(classes="button-row"):
yield Button(
"Close", variant="primary", id="close-button", classes="help-button"
)
def on_mount(self) -> None:
"""Focus close button when modal opens."""
close_button = self.query_one("#close-button", Button)
close_button.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "close-button":
self.action_close()
def action_close(self) -> None:
"""Close the help modal."""
self.dismiss()

View file

@ -15,7 +15,10 @@ HOSTS_MANAGER_BINDINGS = [
Binding("i", "sort_by_ip", "Sort by IP"),
Binding("n", "sort_by_hostname", "Sort by Hostname"),
Binding("c", "config", "Config"),
Binding("ctrl+f", "search", "Focus Search"),
Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"),
Binding("a", "add_entry", "Add Entry", show=False),
Binding("d", "delete_entry", "Delete Entry", show=False),
Binding("e", "edit_entry", "Edit Entry", show=False),
Binding("space", "toggle_entry", "Toggle Entry", show=False),
Binding("ctrl+s", "save_file", "Save", show=False),

View file

@ -0,0 +1,278 @@
"""
Search modal window for the hosts TUI application.
This module provides a floating search window for finding entries by hostname or IP address.
"""
from textual.app import ComposeResult
from textual.containers import Vertical, Horizontal
from textual.widgets import Static, Button, Input, DataTable, Label
from textual.screen import ModalScreen
from textual.binding import Binding
from ..core.models import HostEntry
class SearchModal(ModalScreen):
"""
Modal screen for searching host entries.
Provides a search interface and displays matching results.
"""
CSS = """
SearchModal {
align: center middle;
}
.search-container {
width: 90;
height: 30;
background: $surface;
border: thick $primary;
padding: 1;
}
.search-title {
text-align: center;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.search-section {
margin: 1 0;
}
.search-input {
margin: 0 2;
width: 1fr;
}
.results-section {
margin: 1 0;
height: 15;
}
.search-results {
margin: 0 2;
height: 13;
}
.button-row {
margin-top: 1;
align: center middle;
}
.search-button {
margin: 0 1;
min-width: 10;
}
.results-info {
margin: 0 2;
color: $text-muted;
text-style: italic;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "select", "Select"),
Binding("f3", "search", "Search"),
]
def __init__(self, entries):
super().__init__()
self.entries = entries
self.search_results = []
def compose(self) -> ComposeResult:
"""Create the search modal layout."""
with Vertical(classes="search-container"):
yield Static("Search Host Entries", classes="search-title")
with Vertical(classes="search-section"):
yield Label("Search term (hostname or IP address):")
yield Input(
placeholder="e.g., example.com or 192.168.1.1",
id="search-input",
classes="search-input",
)
with Vertical(classes="results-section"):
yield Static("Search Results:", classes="results-info")
yield DataTable(
id="search-results-table",
classes="search-results",
show_header=True,
zebra_stripes=True,
)
with Horizontal(classes="button-row"):
yield Button(
"Search",
variant="primary",
id="search-button",
classes="search-button",
)
yield Button(
"Select",
variant="success",
id="select-button",
classes="search-button",
)
yield Button(
"Close",
variant="default",
id="close-button",
classes="search-button",
)
def on_mount(self) -> None:
"""Initialize the search results table and focus search input."""
# Set up the results table
results_table = self.query_one("#search-results-table", DataTable)
results_table.add_column("IP Address", key="ip")
results_table.add_column("Canonical Hostname", key="hostname")
results_table.add_column("Status", key="status")
results_table.add_column("Comment", key="comment")
# Focus search input
search_input = self.query_one("#search-input", Input)
search_input.focus()
# Disable select button initially
select_button = self.query_one("#select-button", Button)
select_button.disabled = True
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "search-button":
self.action_search()
elif event.button.id == "select-button":
self.action_select()
elif event.button.id == "close-button":
self.action_cancel()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle enter key in search input."""
if event.input.id == "search-input":
self.action_search()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in results table."""
if event.data_table.id == "search-results-table":
# Enable select button when a row is selected
select_button = self.query_one("#select-button", Button)
select_button.disabled = False
def action_search(self) -> None:
"""Perform search based on input."""
search_input = self.query_one("#search-input", Input)
search_term = search_input.value.strip().lower()
if not search_term:
self._update_results_info("Please enter a search term")
return
# Perform search
self.search_results = self._search_entries(search_term)
# Update results table
results_table = self.query_one("#search-results-table", DataTable)
results_table.clear()
if not self.search_results:
self._update_results_info(f"No entries found matching '{search_term}'")
select_button = self.query_one("#select-button", Button)
select_button.disabled = True
return
# Add results to table
for entry in self.search_results:
status = "✓ Active" if entry.is_active else "✗ Inactive"
comment = entry.comment or ""
results_table.add_row(
entry.ip_address,
entry.canonical_hostname,
status,
comment,
key=str(id(entry)),
)
self._update_results_info(f"Found {len(self.search_results)} matching entries")
def action_select(self) -> None:
"""Select the currently highlighted entry and close modal."""
results_table = self.query_one("#search-results-table", DataTable)
if results_table.cursor_row is None or not self.search_results:
return
# Get the selected entry
cursor_row = results_table.cursor_row
if 0 <= cursor_row < len(self.search_results):
selected_entry = self.search_results[cursor_row]
# Find the original index of this entry
original_index = self._find_entry_index(selected_entry)
self.dismiss(original_index)
else:
self.dismiss(None)
def action_cancel(self) -> None:
"""Cancel search and close modal."""
self.dismiss(None)
def _search_entries(self, search_term: str) -> list[HostEntry]:
"""
Search entries by hostname or IP address.
Args:
search_term: The search term to match against
Returns:
List of matching entries
"""
results = []
for entry in self.entries:
# Search in IP address
if search_term in entry.ip_address.lower():
results.append(entry)
continue
# Search in hostnames
for hostname in entry.hostnames:
if search_term in hostname.lower():
results.append(entry)
break
else:
# Search in comment
if entry.comment and search_term in entry.comment.lower():
results.append(entry)
return results
def _find_entry_index(self, target_entry: HostEntry) -> int:
"""
Find the index of an entry in the original entries list.
Args:
target_entry: Entry to find
Returns:
Index of the entry, or -1 if not found
"""
for i, entry in enumerate(self.entries):
if entry is target_entry:
return i
return -1
def _update_results_info(self, message: str) -> None:
"""Update the results info label."""
try:
results_info = self.query_one(".results-info", Static)
results_info.update(message)
except Exception:
pass

View file

@ -7,21 +7,36 @@ across the application.
# CSS styles for the hosts manager application
HOSTS_MANAGER_CSS = """
.search-container {
border: round $primary;
height: 3;
padding: 0 1;
margin-top: 1;
margin-bottom: 0;
}
.search-input {
width: 1fr;
height: 1;
border: none;
}
.hosts-container {
height: 1fr;
# height: 1fr;
margin-top: 0;
}
.left-pane {
width: 60%;
border: round $primary;
margin: 1;
margin: 0;
padding: 1;
}
.right-pane {
width: 40%;
border: round $primary;
margin: 1;
margin: 0;
padding: 1;
}
@ -105,6 +120,11 @@ HOSTS_MANAGER_CSS = """
background: $surface;
}
Header { height: 1; }
Header.-tall { height: 1; } /* Fix tall header also to height 1 */
Header {
height: 1;
}
Header.-tall {
height: 1; /* Fix tall header also to height 1 */
}
"""

View file

@ -28,6 +28,32 @@ class TableHandler:
entry.ip_address, canonical_hostname
):
continue
# Apply search filter if search term is provided
if self.app.search_term:
search_term_lower = self.app.search_term.lower()
matches_search = False
# Search in IP address
if search_term_lower in entry.ip_address.lower():
matches_search = True
# Search in hostnames
if not matches_search:
for hostname in entry.hostnames:
if search_term_lower in hostname.lower():
matches_search = True
break
# Search in comment
if not matches_search and entry.comment:
if search_term_lower in entry.comment.lower():
matches_search = True
# Skip entry if it doesn't match search term
if not matches_search:
continue
visible_entries.append(entry)
return visible_entries

View file

@ -324,14 +324,17 @@ class TestHostsManagerApp:
patch("hosts.tui.app.Config", return_value=mock_config),
):
app = HostsManagerApp()
app.update_status = Mock()
app.push_screen = Mock()
app.action_help()
# Should update status with help message
app.update_status.assert_called_once()
call_args = app.update_status.call_args[0][0]
assert "Help:" in call_args
# Should push the help modal screen
app.push_screen.assert_called_once()
# Verify the modal is a HelpModal instance
from hosts.tui.help_modal import HelpModal
args = app.push_screen.call_args[0]
assert isinstance(args[0], HelpModal)
def test_action_config(self):
"""Test config action opens modal."""