From 07e7e4f70f7c8d3d2a8cc698bff3c59ab3726b8e Mon Sep 17 00:00:00 2001 From: phg Date: Thu, 14 Aug 2025 19:47:09 +0200 Subject: [PATCH 1/3] 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. --- src/hosts/core/manager.py | 61 +++++ src/hosts/tui/add_entry_modal.py | 238 ++++++++++++++++++ src/hosts/tui/app.py | 117 ++++++++- src/hosts/tui/delete_confirmation_modal.py | 125 +++++++++ src/hosts/tui/help_modal.py | 141 +++++++++++ src/hosts/tui/keybindings.py | 3 + src/hosts/tui/search_modal.py | 278 +++++++++++++++++++++ tests/test_main.py | 13 +- 8 files changed, 966 insertions(+), 10 deletions(-) create mode 100644 src/hosts/tui/add_entry_modal.py create mode 100644 src/hosts/tui/delete_confirmation_modal.py create mode 100644 src/hosts/tui/help_modal.py create mode 100644 src/hosts/tui/search_modal.py diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py index 1b44c38..5bbd280 100644 --- a/src/hosts/core/manager.py +++ b/src/hosts/core/manager.py @@ -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, diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py new file mode 100644 index 0000000..d5b1de7 --- /dev/null +++ b/src/hosts/tui/add_entry_modal.py @@ -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 diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index db3d161..ef25774 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -16,6 +16,10 @@ 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 .search_modal import SearchModal +from .help_modal import HelpModal from .styles import HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS from .table_handler import TableHandler @@ -212,11 +216,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 +356,112 @@ 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: + """Show the search modal.""" + if not self.hosts_file.entries: + self.update_status("No entries to search") + return + + def handle_search_result(selected_index) -> None: + if selected_index is None: + self.update_status("Search cancelled") + return + + if 0 <= selected_index < len(self.hosts_file.entries): + # Update selected entry and refresh display + self.selected_entry_index = selected_index + self.table_handler.populate_entries_table() + + # Move cursor to the found entry + display_index = self.table_handler.actual_index_to_display_index( + selected_index + ) + table = self.query_one("#entries-table", DataTable) + if display_index < table.row_count: + table.move_cursor(row=display_index) + + self.details_handler.update_entry_details() + entry = self.hosts_file.entries[selected_index] + self.update_status( + f"Found: {entry.canonical_hostname} ({entry.ip_address})" + ) + else: + self.update_status("Selected entry not found") + + self.push_screen(SearchModal(self.hosts_file.entries), handle_search_result) + def action_quit(self) -> None: """Quit the application.""" self.navigation_handler.quit_application() diff --git a/src/hosts/tui/delete_confirmation_modal.py b/src/hosts/tui/delete_confirmation_modal.py new file mode 100644 index 0000000..8e1f4d5 --- /dev/null +++ b/src/hosts/tui/delete_confirmation_modal.py @@ -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) diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py new file mode 100644 index 0000000..a070f94 --- /dev/null +++ b/src/hosts/tui/help_modal.py @@ -0,0 +1,141 @@ +""" +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() diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index ad4b72b..e9d4150 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -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", "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), diff --git a/src/hosts/tui/search_modal.py b/src/hosts/tui/search_modal.py new file mode 100644 index 0000000..88ba7fe --- /dev/null +++ b/src/hosts/tui/search_modal.py @@ -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 diff --git a/tests/test_main.py b/tests/test_main.py index dd35d64..aca63f8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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.""" From 5b768c004b31916a0097606b6a07b4311e84778a Mon Sep 17 00:00:00 2001 From: phg Date: Thu, 14 Aug 2025 20:27:48 +0200 Subject: [PATCH 2/3] Implement search functionality with input field and filtering in the hosts manager --- src/hosts/tui/app.py | 79 ++++++++++++++++++---------------- src/hosts/tui/help_modal.py | 63 +++++++++++++++++++++------ src/hosts/tui/keybindings.py | 2 +- src/hosts/tui/styles.py | 22 +++++++++- src/hosts/tui/table_handler.py | 26 +++++++++++ 5 files changed, 140 insertions(+), 52 deletions(-) diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index ef25774..ea1bff5 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -18,7 +18,6 @@ from .config_modal import ConfigModal from .password_modal import PasswordModal from .add_entry_modal import AddEntryModal from .delete_confirmation_modal import DeleteConfirmationModal -from .search_modal import SearchModal from .help_modal import HelpModal from .styles import HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS @@ -46,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__() @@ -71,6 +71,18 @@ class HostsManagerApp(App): yield Header() yield Footer() + # Spacer + yield Static("", classes="spacer") + + # 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: @@ -190,15 +202,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).""" @@ -429,38 +464,10 @@ class HostsManagerApp(App): self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation) def action_search(self) -> None: - """Show the search modal.""" - if not self.hosts_file.entries: - self.update_status("No entries to search") - return - - def handle_search_result(selected_index) -> None: - if selected_index is None: - self.update_status("Search cancelled") - return - - if 0 <= selected_index < len(self.hosts_file.entries): - # Update selected entry and refresh display - self.selected_entry_index = selected_index - self.table_handler.populate_entries_table() - - # Move cursor to the found entry - display_index = self.table_handler.actual_index_to_display_index( - selected_index - ) - table = self.query_one("#entries-table", DataTable) - if display_index < table.row_count: - table.move_cursor(row=display_index) - - self.details_handler.update_entry_details() - entry = self.hosts_file.entries[selected_index] - self.update_status( - f"Found: {entry.canonical_hostname} ({entry.ip_address})" - ) - else: - self.update_status("Selected entry not found") - - self.push_screen(SearchModal(self.hosts_file.entries), handle_search_result) + """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.""" diff --git a/src/hosts/tui/help_modal.py b/src/hosts/tui/help_modal.py index a070f94..3c018d7 100644 --- a/src/hosts/tui/help_modal.py +++ b/src/hosts/tui/help_modal.py @@ -93,33 +93,70 @@ class HelpModal(ModalScreen): # 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") + 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") + 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") + 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") + 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") + 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( diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index e9d4150..e333d73 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -15,7 +15,7 @@ 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", "Search"), + 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), diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index cfee74a..f977afb 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -7,6 +7,19 @@ across the application. # CSS styles for the hosts manager application HOSTS_MANAGER_CSS = """ +.search-container { + border: round $primary; + height: 3; + padding: 0 1; + margin-bottom: 1; +} + +.search-input { + width: 1fr; + height: 1; + border: none; +} + .hosts-container { height: 1fr; } @@ -105,6 +118,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 */ +} """ diff --git a/src/hosts/tui/table_handler.py b/src/hosts/tui/table_handler.py index 1d94362..58cfb44 100644 --- a/src/hosts/tui/table_handler.py +++ b/src/hosts/tui/table_handler.py @@ -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 From 8d884aeb65e4af35f8032ada9b3851e1094f8f65 Mon Sep 17 00:00:00 2001 From: phg Date: Thu, 14 Aug 2025 20:33:02 +0200 Subject: [PATCH 3/3] Remove unnecessary spacer and adjust margins in the hosts manager UI for improved layout consistency. --- src/hosts/tui/app.py | 3 --- src/hosts/tui/styles.py | 10 ++++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index ea1bff5..f5de683 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -71,9 +71,6 @@ class HostsManagerApp(App): yield Header() yield Footer() - # Spacer - yield Static("", classes="spacer") - # Search bar above the panes with Horizontal(classes="search-container") as search_container: search_container.border_title = "Search" diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index f977afb..ec61e37 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -11,7 +11,8 @@ HOSTS_MANAGER_CSS = """ border: round $primary; height: 3; padding: 0 1; - margin-bottom: 1; + margin-top: 1; + margin-bottom: 0; } .search-input { @@ -21,20 +22,21 @@ HOSTS_MANAGER_CSS = """ } .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; }