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.
This commit is contained in:
		
							parent
							
								
									8b8c02c6da
								
							
						
					
					
						commit
						07e7e4f70f
					
				
					 8 changed files with 966 additions and 10 deletions
				
			
		| 
						 | 
					@ -275,6 +275,67 @@ class HostsManager:
 | 
				
			||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            return False, f"Error moving entry: {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(
 | 
					    def update_entry(
 | 
				
			||||||
        self,
 | 
					        self,
 | 
				
			||||||
        hosts_file: HostsFile,
 | 
					        hosts_file: HostsFile,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										238
									
								
								src/hosts/tui/add_entry_modal.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								src/hosts/tui/add_entry_modal.py
									
										
									
									
									
										Normal 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
 | 
				
			||||||
| 
						 | 
					@ -16,6 +16,10 @@ from ..core.config import Config
 | 
				
			||||||
from ..core.manager import HostsManager
 | 
					from ..core.manager import HostsManager
 | 
				
			||||||
from .config_modal import ConfigModal
 | 
					from .config_modal import ConfigModal
 | 
				
			||||||
from .password_modal import PasswordModal
 | 
					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 .styles import HOSTS_MANAGER_CSS
 | 
				
			||||||
from .keybindings import HOSTS_MANAGER_BINDINGS
 | 
					from .keybindings import HOSTS_MANAGER_BINDINGS
 | 
				
			||||||
from .table_handler import TableHandler
 | 
					from .table_handler import TableHandler
 | 
				
			||||||
| 
						 | 
					@ -212,11 +216,8 @@ class HostsManagerApp(App):
 | 
				
			||||||
        self.update_status("Hosts file reloaded")
 | 
					        self.update_status("Hosts file reloaded")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def action_help(self) -> None:
 | 
					    def action_help(self) -> None:
 | 
				
			||||||
        """Show help information."""
 | 
					        """Show help modal."""
 | 
				
			||||||
        # For now, just update the status with help info
 | 
					        self.push_screen(HelpModal())
 | 
				
			||||||
        self.update_status(
 | 
					 | 
				
			||||||
            "Help: ↑/↓ Navigate, r Reload, q Quit, h Help, c Config, e Edit"
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def action_config(self) -> None:
 | 
					    def action_config(self) -> None:
 | 
				
			||||||
        """Show configuration modal."""
 | 
					        """Show configuration modal."""
 | 
				
			||||||
| 
						 | 
					@ -355,6 +356,112 @@ class HostsManagerApp(App):
 | 
				
			||||||
        """Save the hosts file to disk."""
 | 
					        """Save the hosts file to disk."""
 | 
				
			||||||
        self.navigation_handler.save_hosts_file()
 | 
					        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:
 | 
					    def action_quit(self) -> None:
 | 
				
			||||||
        """Quit the application."""
 | 
					        """Quit the application."""
 | 
				
			||||||
        self.navigation_handler.quit_application()
 | 
					        self.navigation_handler.quit_application()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										125
									
								
								src/hosts/tui/delete_confirmation_modal.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/hosts/tui/delete_confirmation_modal.py
									
										
									
									
									
										Normal 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)
 | 
				
			||||||
							
								
								
									
										141
									
								
								src/hosts/tui/help_modal.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/hosts/tui/help_modal.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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()
 | 
				
			||||||
| 
						 | 
					@ -15,7 +15,10 @@ HOSTS_MANAGER_BINDINGS = [
 | 
				
			||||||
    Binding("i", "sort_by_ip", "Sort by IP"),
 | 
					    Binding("i", "sort_by_ip", "Sort by IP"),
 | 
				
			||||||
    Binding("n", "sort_by_hostname", "Sort by Hostname"),
 | 
					    Binding("n", "sort_by_hostname", "Sort by Hostname"),
 | 
				
			||||||
    Binding("c", "config", "Config"),
 | 
					    Binding("c", "config", "Config"),
 | 
				
			||||||
 | 
					    Binding("ctrl+f", "search", "Search"),
 | 
				
			||||||
    Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"),
 | 
					    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("e", "edit_entry", "Edit Entry", show=False),
 | 
				
			||||||
    Binding("space", "toggle_entry", "Toggle Entry", show=False),
 | 
					    Binding("space", "toggle_entry", "Toggle Entry", show=False),
 | 
				
			||||||
    Binding("ctrl+s", "save_file", "Save", show=False),
 | 
					    Binding("ctrl+s", "save_file", "Save", show=False),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										278
									
								
								src/hosts/tui/search_modal.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								src/hosts/tui/search_modal.py
									
										
									
									
									
										Normal 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
 | 
				
			||||||
| 
						 | 
					@ -324,14 +324,17 @@ class TestHostsManagerApp:
 | 
				
			||||||
            patch("hosts.tui.app.Config", return_value=mock_config),
 | 
					            patch("hosts.tui.app.Config", return_value=mock_config),
 | 
				
			||||||
        ):
 | 
					        ):
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.update_status = Mock()
 | 
					            app.push_screen = Mock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            app.action_help()
 | 
					            app.action_help()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Should update status with help message
 | 
					            # Should push the help modal screen
 | 
				
			||||||
            app.update_status.assert_called_once()
 | 
					            app.push_screen.assert_called_once()
 | 
				
			||||||
            call_args = app.update_status.call_args[0][0]
 | 
					            # Verify the modal is a HelpModal instance
 | 
				
			||||||
            assert "Help:" in call_args
 | 
					            from hosts.tui.help_modal import HelpModal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            args = app.push_screen.call_args[0]
 | 
				
			||||||
 | 
					            assert isinstance(args[0], HelpModal)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_action_config(self):
 | 
					    def test_action_config(self):
 | 
				
			||||||
        """Test config action opens modal."""
 | 
					        """Test config action opens modal."""
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue