Add TUI components for hosts management
- Implement DetailsHandler for managing entry details display and edit forms. - Create EditHandler to handle edit mode operations, including validation and saving of entry changes. - Introduce NavigationHandler for entry movement and action operations. - Define key bindings for various application actions in keybindings.py. - Add TableHandler for managing the data table, including sorting and filtering of entries. - Establish CSS styles for consistent theming across the application. - Update tests to reflect changes in module structure and ensure proper functionality.
This commit is contained in:
		
							parent
							
								
									8b1c01c894
								
							
						
					
					
						commit
						4dbf200c5f
					
				
					 12 changed files with 2259 additions and 1029 deletions
				
			
		
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										1007
									
								
								src/hosts/main_backup.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1007
									
								
								src/hosts/main_backup.py
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										17
									
								
								src/hosts/main_new.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/hosts/main_new.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,17 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Main entry point for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module contains the main application entry point function.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .tui.app import HostsManagerApp
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main():
 | 
				
			||||||
 | 
					    """Main entry point for the hosts application."""
 | 
				
			||||||
 | 
					    app = HostsManagerApp()
 | 
				
			||||||
 | 
					    app.run()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
							
								
								
									
										368
									
								
								src/hosts/tui/app.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										368
									
								
								src/hosts/tui/app.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,368 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Main application class for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module contains the main application class that orchestrates
 | 
				
			||||||
 | 
					all the handlers and provides the primary user interface.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from textual.app import App, ComposeResult
 | 
				
			||||||
 | 
					from textual.containers import Horizontal, Vertical
 | 
				
			||||||
 | 
					from textual.widgets import Header, Footer, Static, DataTable, Input, Checkbox, Label
 | 
				
			||||||
 | 
					from textual.reactive import reactive
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ..core.parser import HostsParser
 | 
				
			||||||
 | 
					from ..core.models import HostsFile
 | 
				
			||||||
 | 
					from ..core.config import Config
 | 
				
			||||||
 | 
					from ..core.manager import HostsManager
 | 
				
			||||||
 | 
					from .config_modal import ConfigModal
 | 
				
			||||||
 | 
					from .styles import HOSTS_MANAGER_CSS
 | 
				
			||||||
 | 
					from .keybindings import HOSTS_MANAGER_BINDINGS
 | 
				
			||||||
 | 
					from .table_handler import TableHandler
 | 
				
			||||||
 | 
					from .details_handler import DetailsHandler
 | 
				
			||||||
 | 
					from .edit_handler import EditHandler
 | 
				
			||||||
 | 
					from .navigation_handler import NavigationHandler
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class HostsManagerApp(App):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Main application class for the hosts TUI manager.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Provides a two-pane interface for managing hosts file entries
 | 
				
			||||||
 | 
					    with read-only mode by default and explicit edit mode.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    CSS = HOSTS_MANAGER_CSS
 | 
				
			||||||
 | 
					    BINDINGS = HOSTS_MANAGER_BINDINGS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Reactive attributes
 | 
				
			||||||
 | 
					    hosts_file: reactive[HostsFile] = reactive(HostsFile())
 | 
				
			||||||
 | 
					    selected_entry_index: reactive[int] = reactive(0)
 | 
				
			||||||
 | 
					    edit_mode: reactive[bool] = reactive(False)
 | 
				
			||||||
 | 
					    entry_edit_mode: reactive[bool] = reactive(False)
 | 
				
			||||||
 | 
					    sort_column: reactive[str] = reactive("")  # "ip" or "hostname"
 | 
				
			||||||
 | 
					    sort_ascending: reactive[bool] = reactive(True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        super().__init__()
 | 
				
			||||||
 | 
					        self.title = "Hosts Manager"
 | 
				
			||||||
 | 
					        self.sub_title = "Read-only mode"
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # Initialize core components
 | 
				
			||||||
 | 
					        self.parser = HostsParser()
 | 
				
			||||||
 | 
					        self.config = Config()
 | 
				
			||||||
 | 
					        self.manager = HostsManager()
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # Initialize handlers
 | 
				
			||||||
 | 
					        self.table_handler = TableHandler(self)
 | 
				
			||||||
 | 
					        self.details_handler = DetailsHandler(self)
 | 
				
			||||||
 | 
					        self.edit_handler = EditHandler(self)
 | 
				
			||||||
 | 
					        self.navigation_handler = NavigationHandler(self)
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        # State for edit mode
 | 
				
			||||||
 | 
					        self.original_entry_values = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def compose(self) -> ComposeResult:
 | 
				
			||||||
 | 
					        """Create child widgets for the app."""
 | 
				
			||||||
 | 
					        yield Header()
 | 
				
			||||||
 | 
					        yield Footer()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with Horizontal(classes="hosts-container"):
 | 
				
			||||||
 | 
					            # Left pane - entries table
 | 
				
			||||||
 | 
					            with Vertical(classes="left-pane"):
 | 
				
			||||||
 | 
					                yield Static("Host Entries", id="entries-title")
 | 
				
			||||||
 | 
					                yield DataTable(id="entries-table")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Right pane - entry details or edit form
 | 
				
			||||||
 | 
					            with Vertical(classes="right-pane"):
 | 
				
			||||||
 | 
					                yield Static("Entry Details", id="details-title")
 | 
				
			||||||
 | 
					                yield Static("Select an entry to view details", id="entry-details")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                # Edit form (initially hidden)
 | 
				
			||||||
 | 
					                with Vertical(id="entry-edit-form", classes="hidden"):
 | 
				
			||||||
 | 
					                    yield Label("IP Address:")
 | 
				
			||||||
 | 
					                    yield Input(placeholder="Enter IP address", id="ip-input")
 | 
				
			||||||
 | 
					                    yield Label("Hostnames (comma-separated):")
 | 
				
			||||||
 | 
					                    yield Input(placeholder="Enter hostnames", id="hostname-input")
 | 
				
			||||||
 | 
					                    yield Label("Comment:")
 | 
				
			||||||
 | 
					                    yield Input(placeholder="Enter comment (optional)", id="comment-input")
 | 
				
			||||||
 | 
					                    yield Checkbox("Active", id="active-checkbox")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Status bar
 | 
				
			||||||
 | 
					        yield Static("", id="status", classes="status-bar")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_ready(self) -> None:
 | 
				
			||||||
 | 
					        """Called when the app is ready."""
 | 
				
			||||||
 | 
					        self.load_hosts_file()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def load_hosts_file(self) -> None:
 | 
				
			||||||
 | 
					        """Load the hosts file and populate the table."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            # Remember the currently selected entry before reload
 | 
				
			||||||
 | 
					            previous_entry = None
 | 
				
			||||||
 | 
					            if (
 | 
				
			||||||
 | 
					                self.hosts_file.entries
 | 
				
			||||||
 | 
					                and self.selected_entry_index < len(self.hosts_file.entries)
 | 
				
			||||||
 | 
					            ):
 | 
				
			||||||
 | 
					                previous_entry = self.hosts_file.entries[self.selected_entry_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Load the hosts file
 | 
				
			||||||
 | 
					            self.hosts_file = self.parser.parse()
 | 
				
			||||||
 | 
					            self.table_handler.populate_entries_table()
 | 
				
			||||||
 | 
					            self.table_handler.restore_cursor_position(previous_entry)
 | 
				
			||||||
 | 
					            self.update_status()
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            self.update_status(f"❌ Error loading hosts file: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def update_status(self, message: str = "") -> None:
 | 
				
			||||||
 | 
					        """Update the status bar."""
 | 
				
			||||||
 | 
					        status_widget = self.query_one("#status", Static)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if message:
 | 
				
			||||||
 | 
					            # Check if this is an error message (starts with ❌)
 | 
				
			||||||
 | 
					            if message.startswith("❌"):
 | 
				
			||||||
 | 
					                # Use error styling for error messages
 | 
				
			||||||
 | 
					                status_widget.remove_class("status-bar")
 | 
				
			||||||
 | 
					                status_widget.add_class("status-error")
 | 
				
			||||||
 | 
					                status_widget.update(message)
 | 
				
			||||||
 | 
					                # Auto-clear error message after 5 seconds
 | 
				
			||||||
 | 
					                self.set_timer(5.0, lambda: self.update_status())
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                # Use normal styling for regular messages
 | 
				
			||||||
 | 
					                status_widget.remove_class("status-error")
 | 
				
			||||||
 | 
					                status_widget.add_class("status-bar")
 | 
				
			||||||
 | 
					                status_widget.update(message)
 | 
				
			||||||
 | 
					                # Auto-clear regular message after 3 seconds
 | 
				
			||||||
 | 
					                self.set_timer(3.0, lambda: self.update_status())
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Reset to normal status display
 | 
				
			||||||
 | 
					            status_widget.remove_class("status-error")
 | 
				
			||||||
 | 
					            status_widget.add_class("status-bar")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            mode = "Edit mode" if self.edit_mode else "Read-only mode"
 | 
				
			||||||
 | 
					            entry_count = len(self.hosts_file.entries)
 | 
				
			||||||
 | 
					            active_count = len(self.hosts_file.get_active_entries())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            status_text = f"{mode} | {entry_count} entries ({active_count} active)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Add file info
 | 
				
			||||||
 | 
					            file_info = self.parser.get_file_info()
 | 
				
			||||||
 | 
					            if file_info["exists"]:
 | 
				
			||||||
 | 
					                status_text += f" | {file_info['path']}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            status_widget.update(status_text)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Event handlers
 | 
				
			||||||
 | 
					    def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
 | 
				
			||||||
 | 
					        """Handle row highlighting (cursor movement) in the DataTable."""
 | 
				
			||||||
 | 
					        if event.data_table.id == "entries-table":
 | 
				
			||||||
 | 
					            # Convert display index to actual index
 | 
				
			||||||
 | 
					            self.selected_entry_index = self.table_handler.display_index_to_actual_index(
 | 
				
			||||||
 | 
					                event.cursor_row
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
 | 
				
			||||||
 | 
					        """Handle row selection in the DataTable."""
 | 
				
			||||||
 | 
					        if event.data_table.id == "entries-table":
 | 
				
			||||||
 | 
					            # Convert display index to actual index
 | 
				
			||||||
 | 
					            self.selected_entry_index = self.table_handler.display_index_to_actual_index(
 | 
				
			||||||
 | 
					                event.cursor_row
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
 | 
				
			||||||
 | 
					        """Handle column header clicks for sorting."""
 | 
				
			||||||
 | 
					        if event.data_table.id == "entries-table":
 | 
				
			||||||
 | 
					            # Check if the column key contains "IP Address" (handles sort indicators)
 | 
				
			||||||
 | 
					            if "IP Address" in str(event.column_key):
 | 
				
			||||||
 | 
					                self.action_sort_by_ip()
 | 
				
			||||||
 | 
					            elif "Canonical Hostname" in str(event.column_key):
 | 
				
			||||||
 | 
					                self.action_sort_by_hostname()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_key(self, event) -> None:
 | 
				
			||||||
 | 
					        """Handle key events to override default tab behavior in edit mode."""
 | 
				
			||||||
 | 
					        # 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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
 | 
				
			||||||
 | 
					        """Handle checkbox changes (no auto-save - changes saved on exit)."""
 | 
				
			||||||
 | 
					        # Checkbox changes are tracked but not automatically saved
 | 
				
			||||||
 | 
					        # Changes will be validated and saved when exiting edit mode
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Action handlers
 | 
				
			||||||
 | 
					    def action_reload(self) -> None:
 | 
				
			||||||
 | 
					        """Reload the hosts file."""
 | 
				
			||||||
 | 
					        # Reset sort state on reload
 | 
				
			||||||
 | 
					        self.sort_column = ""
 | 
				
			||||||
 | 
					        self.sort_ascending = True
 | 
				
			||||||
 | 
					        self.load_hosts_file()
 | 
				
			||||||
 | 
					        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"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_config(self) -> None:
 | 
				
			||||||
 | 
					        """Show configuration modal."""
 | 
				
			||||||
 | 
					        def handle_config_result(config_changed: bool) -> None:
 | 
				
			||||||
 | 
					            if config_changed:
 | 
				
			||||||
 | 
					                # Reload the table to apply new filtering
 | 
				
			||||||
 | 
					                self.table_handler.populate_entries_table()
 | 
				
			||||||
 | 
					                self.update_status("Configuration saved")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.push_screen(ConfigModal(self.config), handle_config_result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_sort_by_ip(self) -> None:
 | 
				
			||||||
 | 
					        """Sort entries by IP address, toggle ascending/descending."""
 | 
				
			||||||
 | 
					        self.table_handler.sort_entries_by_ip()
 | 
				
			||||||
 | 
					        direction = "ascending" if self.sort_ascending else "descending"
 | 
				
			||||||
 | 
					        self.update_status(f"Sorted by IP address ({direction})")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_sort_by_hostname(self) -> None:
 | 
				
			||||||
 | 
					        """Sort entries by canonical hostname, toggle ascending/descending."""
 | 
				
			||||||
 | 
					        self.table_handler.sort_entries_by_hostname()
 | 
				
			||||||
 | 
					        direction = "ascending" if self.sort_ascending else "descending"
 | 
				
			||||||
 | 
					        self.update_status(f"Sorted by hostname ({direction})")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_toggle_edit_mode(self) -> None:
 | 
				
			||||||
 | 
					        """Toggle between read-only and edit mode."""
 | 
				
			||||||
 | 
					        if self.edit_mode:
 | 
				
			||||||
 | 
					            # Exit edit mode
 | 
				
			||||||
 | 
					            success, message = self.manager.exit_edit_mode()
 | 
				
			||||||
 | 
					            if success:
 | 
				
			||||||
 | 
					                self.edit_mode = False
 | 
				
			||||||
 | 
					                self.sub_title = "Read-only mode"
 | 
				
			||||||
 | 
					                self.update_status(message)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.update_status(f"Error exiting edit mode: {message}")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Enter edit mode
 | 
				
			||||||
 | 
					            success, message = self.manager.enter_edit_mode()
 | 
				
			||||||
 | 
					            if success:
 | 
				
			||||||
 | 
					                self.edit_mode = True
 | 
				
			||||||
 | 
					                self.sub_title = "Edit mode"
 | 
				
			||||||
 | 
					                self.update_status(message)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.update_status(f"Error entering edit mode: {message}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_edit_entry(self) -> None:
 | 
				
			||||||
 | 
					        """Enter edit mode for the selected entry."""
 | 
				
			||||||
 | 
					        if not self.edit_mode:
 | 
				
			||||||
 | 
					            self.update_status(
 | 
				
			||||||
 | 
					                "❌ Cannot edit 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 edit")
 | 
				
			||||||
 | 
					            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 edit system default entry")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Store original values for change detection
 | 
				
			||||||
 | 
					        self.original_entry_values = {
 | 
				
			||||||
 | 
					            "ip_address": entry.ip_address,
 | 
				
			||||||
 | 
					            "hostnames": entry.hostnames.copy(),
 | 
				
			||||||
 | 
					            "comment": entry.comment,
 | 
				
			||||||
 | 
					            "is_active": entry.is_active,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.entry_edit_mode = True
 | 
				
			||||||
 | 
					        self.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Focus on the IP address input field
 | 
				
			||||||
 | 
					        ip_input = self.query_one("#ip-input", Input)
 | 
				
			||||||
 | 
					        ip_input.focus()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.update_status("Editing entry - Use Tab/Shift+Tab to navigate, ESC to exit")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_exit_edit_entry(self) -> None:
 | 
				
			||||||
 | 
					        """Exit entry edit mode and return focus to the entries table."""
 | 
				
			||||||
 | 
					        self.edit_handler.exit_edit_entry_with_confirmation()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_next_field(self) -> None:
 | 
				
			||||||
 | 
					        """Move to the next field in edit mode."""
 | 
				
			||||||
 | 
					        self.edit_handler.navigate_to_next_field()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_prev_field(self) -> None:
 | 
				
			||||||
 | 
					        """Move to the previous field in edit mode."""
 | 
				
			||||||
 | 
					        self.edit_handler.navigate_to_prev_field()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_toggle_entry(self) -> None:
 | 
				
			||||||
 | 
					        """Toggle the active state of the selected entry."""
 | 
				
			||||||
 | 
					        self.navigation_handler.toggle_entry()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_move_entry_up(self) -> None:
 | 
				
			||||||
 | 
					        """Move the selected entry up in the list."""
 | 
				
			||||||
 | 
					        self.navigation_handler.move_entry_up()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_move_entry_down(self) -> None:
 | 
				
			||||||
 | 
					        """Move the selected entry down in the list."""
 | 
				
			||||||
 | 
					        self.navigation_handler.move_entry_down()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_save_file(self) -> None:
 | 
				
			||||||
 | 
					        """Save the hosts file to disk."""
 | 
				
			||||||
 | 
					        self.navigation_handler.save_hosts_file()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def action_quit(self) -> None:
 | 
				
			||||||
 | 
					        """Quit the application."""
 | 
				
			||||||
 | 
					        self.navigation_handler.quit_application()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Delegated methods for backward compatibility with tests
 | 
				
			||||||
 | 
					    def has_entry_changes(self) -> bool:
 | 
				
			||||||
 | 
					        """Check if the current entry has been modified from its original values."""
 | 
				
			||||||
 | 
					        return self.edit_handler.has_entry_changes()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def exit_edit_entry_mode(self) -> None:
 | 
				
			||||||
 | 
					        """Helper method to exit entry edit mode and clean up."""
 | 
				
			||||||
 | 
					        self.edit_handler.exit_edit_entry_mode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_entries_table(self) -> None:
 | 
				
			||||||
 | 
					        """Populate the left pane with hosts entries using DataTable."""
 | 
				
			||||||
 | 
					        self.table_handler.populate_entries_table()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def restore_cursor_position(self, previous_entry) -> None:
 | 
				
			||||||
 | 
					        """Restore cursor position after reload, maintaining selection if possible."""
 | 
				
			||||||
 | 
					        self.table_handler.restore_cursor_position(previous_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_visible_entries(self) -> list:
 | 
				
			||||||
 | 
					        """Get the list of entries that are visible in the table (after filtering)."""
 | 
				
			||||||
 | 
					        return self.table_handler.get_visible_entries()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def display_index_to_actual_index(self, display_index: int) -> int:
 | 
				
			||||||
 | 
					        """Convert a display table index to the actual hosts file entry index."""
 | 
				
			||||||
 | 
					        return self.table_handler.display_index_to_actual_index(display_index)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def actual_index_to_display_index(self, actual_index: int) -> int:
 | 
				
			||||||
 | 
					        """Convert an actual hosts file entry index to a display table index."""
 | 
				
			||||||
 | 
					        return self.table_handler.actual_index_to_display_index(actual_index)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def update_entry_details(self) -> None:
 | 
				
			||||||
 | 
					        """Update the right pane with selected entry details."""
 | 
				
			||||||
 | 
					        self.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def update_details_display(self) -> None:
 | 
				
			||||||
 | 
					        """Update the static details display."""
 | 
				
			||||||
 | 
					        self.details_handler.update_details_display()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def update_edit_form(self) -> None:
 | 
				
			||||||
 | 
					        """Update the edit form with current entry values."""
 | 
				
			||||||
 | 
					        self.details_handler.update_edit_form()
 | 
				
			||||||
							
								
								
									
										115
									
								
								src/hosts/tui/details_handler.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/hosts/tui/details_handler.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,115 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Details pane management for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module handles the display and updating of entry details
 | 
				
			||||||
 | 
					and edit forms in the right pane.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from textual.widgets import Static, Input, Checkbox
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DetailsHandler:
 | 
				
			||||||
 | 
					    """Handles all details pane operations for the hosts manager."""
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def __init__(self, app):
 | 
				
			||||||
 | 
					        """Initialize the details handler with reference to the main app."""
 | 
				
			||||||
 | 
					        self.app = app
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def update_entry_details(self) -> None:
 | 
				
			||||||
 | 
					        """Update the right pane with selected entry details."""
 | 
				
			||||||
 | 
					        if self.app.entry_edit_mode:
 | 
				
			||||||
 | 
					            self.update_edit_form()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.update_details_display()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def update_details_display(self) -> None:
 | 
				
			||||||
 | 
					        """Update the static details display."""
 | 
				
			||||||
 | 
					        details_widget = self.app.query_one("#entry-details", Static)
 | 
				
			||||||
 | 
					        edit_form = self.app.query_one("#entry-edit-form")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Show details, hide edit form
 | 
				
			||||||
 | 
					        details_widget.remove_class("hidden")
 | 
				
			||||||
 | 
					        edit_form.add_class("hidden")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.app.hosts_file.entries:
 | 
				
			||||||
 | 
					            details_widget.update("No entries loaded")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get visible entries to check if we need to adjust selection
 | 
				
			||||||
 | 
					        visible_entries = self.app.table_handler.get_visible_entries()
 | 
				
			||||||
 | 
					        if not visible_entries:
 | 
				
			||||||
 | 
					            details_widget.update("No visible entries")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # If default entries are hidden and selected_entry_index points to a hidden entry,
 | 
				
			||||||
 | 
					        # we need to find the corresponding visible entry
 | 
				
			||||||
 | 
					        show_defaults = self.app.config.should_show_default_entries()
 | 
				
			||||||
 | 
					        if not show_defaults:
 | 
				
			||||||
 | 
					            # Check if the currently selected entry is a default entry (hidden)
 | 
				
			||||||
 | 
					            if (
 | 
				
			||||||
 | 
					                self.app.selected_entry_index < len(self.app.hosts_file.entries)
 | 
				
			||||||
 | 
					                and self.app.hosts_file.entries[
 | 
				
			||||||
 | 
					                    self.app.selected_entry_index
 | 
				
			||||||
 | 
					                ].is_default_entry()
 | 
				
			||||||
 | 
					            ):
 | 
				
			||||||
 | 
					                # The selected entry is hidden, so we should show the first visible entry instead
 | 
				
			||||||
 | 
					                if visible_entries:
 | 
				
			||||||
 | 
					                    # Find the first visible entry in the hosts file
 | 
				
			||||||
 | 
					                    for i, entry in enumerate(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					                        if not entry.is_default_entry():
 | 
				
			||||||
 | 
					                            self.app.selected_entry_index = i
 | 
				
			||||||
 | 
					                            break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.app.selected_entry_index >= len(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					            self.app.selected_entry_index = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        entry = self.app.hosts_file.entries[self.app.selected_entry_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        details_lines = [
 | 
				
			||||||
 | 
					            f"IP Address: {entry.ip_address}",
 | 
				
			||||||
 | 
					            f"Hostnames: {', '.join(entry.hostnames)}",
 | 
				
			||||||
 | 
					            f"Status: {'Active' if entry.is_active else 'Inactive'}",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add notice for default system entries
 | 
				
			||||||
 | 
					        if entry.is_default_entry():
 | 
				
			||||||
 | 
					            details_lines.append("")
 | 
				
			||||||
 | 
					            details_lines.append("⚠️  SYSTEM DEFAULT ENTRY")
 | 
				
			||||||
 | 
					            details_lines.append(
 | 
				
			||||||
 | 
					                "This is a default system entry and cannot be modified."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if entry.comment:
 | 
				
			||||||
 | 
					            details_lines.append(f"Comment: {entry.comment}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if entry.dns_name:
 | 
				
			||||||
 | 
					            details_lines.append(f"DNS Name: {entry.dns_name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        details_widget.update("\n".join(details_lines))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def update_edit_form(self) -> None:
 | 
				
			||||||
 | 
					        """Update the edit form with current entry values."""
 | 
				
			||||||
 | 
					        details_widget = self.app.query_one("#entry-details", Static)
 | 
				
			||||||
 | 
					        edit_form = self.app.query_one("#entry-edit-form")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Hide details, show edit form
 | 
				
			||||||
 | 
					        details_widget.add_class("hidden")
 | 
				
			||||||
 | 
					        edit_form.remove_class("hidden")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.app.hosts_file.entries or self.app.selected_entry_index >= len(
 | 
				
			||||||
 | 
					            self.app.hosts_file.entries
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        entry = self.app.hosts_file.entries[self.app.selected_entry_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Update form fields with current entry values
 | 
				
			||||||
 | 
					        ip_input = self.app.query_one("#ip-input", Input)
 | 
				
			||||||
 | 
					        hostname_input = self.app.query_one("#hostname-input", Input)
 | 
				
			||||||
 | 
					        comment_input = self.app.query_one("#comment-input", Input)
 | 
				
			||||||
 | 
					        active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ip_input.value = entry.ip_address
 | 
				
			||||||
 | 
					        hostname_input.value = ", ".join(entry.hostnames)
 | 
				
			||||||
 | 
					        comment_input.value = entry.comment or ""
 | 
				
			||||||
 | 
					        active_checkbox.value = entry.is_active
 | 
				
			||||||
							
								
								
									
										222
									
								
								src/hosts/tui/edit_handler.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								src/hosts/tui/edit_handler.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,222 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Edit mode operations for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module handles all edit mode functionality including
 | 
				
			||||||
 | 
					entry validation, saving, form management, and change detection.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import ipaddress
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					from textual.widgets import Input, Checkbox, DataTable
 | 
				
			||||||
 | 
					from .save_confirmation_modal import SaveConfirmationModal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EditHandler:
 | 
				
			||||||
 | 
					    """Handles all edit mode operations for the hosts manager."""
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def __init__(self, app):
 | 
				
			||||||
 | 
					        """Initialize the edit handler with reference to the main app."""
 | 
				
			||||||
 | 
					        self.app = app
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def has_entry_changes(self) -> bool:
 | 
				
			||||||
 | 
					        """Check if the current entry has been modified from its original values."""
 | 
				
			||||||
 | 
					        if not self.app.original_entry_values or not self.app.entry_edit_mode:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get current values from form fields
 | 
				
			||||||
 | 
					        ip_input = self.app.query_one("#ip-input", Input)
 | 
				
			||||||
 | 
					        hostname_input = self.app.query_one("#hostname-input", Input)
 | 
				
			||||||
 | 
					        comment_input = self.app.query_one("#comment-input", Input)
 | 
				
			||||||
 | 
					        active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        current_hostnames = [
 | 
				
			||||||
 | 
					            h.strip() for h in hostname_input.value.split(",") if h.strip()
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        current_comment = comment_input.value.strip() or None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Compare with original values
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            ip_input.value.strip() != self.app.original_entry_values["ip_address"]
 | 
				
			||||||
 | 
					            or current_hostnames != self.app.original_entry_values["hostnames"]
 | 
				
			||||||
 | 
					            or current_comment != self.app.original_entry_values["comment"]
 | 
				
			||||||
 | 
					            or active_checkbox.value != self.app.original_entry_values["is_active"]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def exit_edit_entry_with_confirmation(self) -> None:
 | 
				
			||||||
 | 
					        """Exit entry edit mode and return focus to the entries table."""
 | 
				
			||||||
 | 
					        if not self.app.entry_edit_mode:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check if there are unsaved changes
 | 
				
			||||||
 | 
					        if self.has_entry_changes():
 | 
				
			||||||
 | 
					            # Show save confirmation modal
 | 
				
			||||||
 | 
					            def handle_save_confirmation(result):
 | 
				
			||||||
 | 
					                if result == "save":
 | 
				
			||||||
 | 
					                    # Validate and save changes
 | 
				
			||||||
 | 
					                    if self.validate_and_save_entry_changes():
 | 
				
			||||||
 | 
					                        self.exit_edit_entry_mode()
 | 
				
			||||||
 | 
					                elif result == "discard":
 | 
				
			||||||
 | 
					                    # Restore original values and exit
 | 
				
			||||||
 | 
					                    self.restore_original_entry_values()
 | 
				
			||||||
 | 
					                    self.exit_edit_entry_mode()
 | 
				
			||||||
 | 
					                elif result == "cancel":
 | 
				
			||||||
 | 
					                    # Do nothing, stay in edit mode
 | 
				
			||||||
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.app.push_screen(SaveConfirmationModal(), handle_save_confirmation)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # No changes, exit directly
 | 
				
			||||||
 | 
					            self.exit_edit_entry_mode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def exit_edit_entry_mode(self) -> None:
 | 
				
			||||||
 | 
					        """Helper method to exit entry edit mode and clean up."""
 | 
				
			||||||
 | 
					        self.app.entry_edit_mode = False
 | 
				
			||||||
 | 
					        self.app.original_entry_values = None
 | 
				
			||||||
 | 
					        self.app.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Return focus to the entries table
 | 
				
			||||||
 | 
					        table = self.app.query_one("#entries-table", DataTable)
 | 
				
			||||||
 | 
					        table.focus()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.app.update_status("Exited entry edit mode")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def restore_original_entry_values(self) -> None:
 | 
				
			||||||
 | 
					        """Restore the original values to the form fields."""
 | 
				
			||||||
 | 
					        if not self.app.original_entry_values:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Update form fields with original values
 | 
				
			||||||
 | 
					        ip_input = self.app.query_one("#ip-input", Input)
 | 
				
			||||||
 | 
					        hostname_input = self.app.query_one("#hostname-input", Input)
 | 
				
			||||||
 | 
					        comment_input = self.app.query_one("#comment-input", Input)
 | 
				
			||||||
 | 
					        active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ip_input.value = self.app.original_entry_values["ip_address"]
 | 
				
			||||||
 | 
					        hostname_input.value = ", ".join(self.app.original_entry_values["hostnames"])
 | 
				
			||||||
 | 
					        comment_input.value = self.app.original_entry_values["comment"] or ""
 | 
				
			||||||
 | 
					        active_checkbox.value = self.app.original_entry_values["is_active"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate_and_save_entry_changes(self) -> bool:
 | 
				
			||||||
 | 
					        """Validate current entry values and save if valid."""
 | 
				
			||||||
 | 
					        if not self.app.hosts_file.entries or self.app.selected_entry_index >= len(
 | 
				
			||||||
 | 
					            self.app.hosts_file.entries
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        entry = self.app.hosts_file.entries[self.app.selected_entry_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get values from form fields
 | 
				
			||||||
 | 
					        ip_input = self.app.query_one("#ip-input", Input)
 | 
				
			||||||
 | 
					        hostname_input = self.app.query_one("#hostname-input", Input)
 | 
				
			||||||
 | 
					        comment_input = self.app.query_one("#comment-input", Input)
 | 
				
			||||||
 | 
					        active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Validate IP address
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            ipaddress.ip_address(ip_input.value.strip())
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            self.app.update_status("❌ Invalid IP address - changes not saved")
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Validate hostname(s)
 | 
				
			||||||
 | 
					        hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
 | 
				
			||||||
 | 
					        if not hostnames:
 | 
				
			||||||
 | 
					            self.app.update_status(
 | 
				
			||||||
 | 
					                "❌ At least one hostname is required - changes not saved"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        hostname_pattern = re.compile(
 | 
				
			||||||
 | 
					            r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for hostname in hostnames:
 | 
				
			||||||
 | 
					            if not hostname_pattern.match(hostname):
 | 
				
			||||||
 | 
					                self.app.update_status(
 | 
				
			||||||
 | 
					                    f"❌ Invalid hostname: {hostname} - changes not saved"
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Update the entry
 | 
				
			||||||
 | 
					        entry.ip_address = ip_input.value.strip()
 | 
				
			||||||
 | 
					        entry.hostnames = hostnames
 | 
				
			||||||
 | 
					        entry.comment = comment_input.value.strip() or None
 | 
				
			||||||
 | 
					        entry.is_active = active_checkbox.value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Save to file
 | 
				
			||||||
 | 
					        success, message = self.app.manager.save_hosts_file(self.app.hosts_file)
 | 
				
			||||||
 | 
					        if success:
 | 
				
			||||||
 | 
					            # Update the table display
 | 
				
			||||||
 | 
					            self.app.table_handler.populate_entries_table()
 | 
				
			||||||
 | 
					            # Restore cursor position
 | 
				
			||||||
 | 
					            table = self.app.query_one("#entries-table", DataTable)
 | 
				
			||||||
 | 
					            display_index = self.app.table_handler.actual_index_to_display_index(
 | 
				
			||||||
 | 
					                self.app.selected_entry_index
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            if table.row_count > 0 and display_index < table.row_count:
 | 
				
			||||||
 | 
					                table.move_cursor(row=display_index)
 | 
				
			||||||
 | 
					            self.app.update_status("Entry saved successfully")
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.app.update_status(f"❌ Error saving entry: {message}")
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def navigate_to_next_field(self) -> None:
 | 
				
			||||||
 | 
					        """Move to the next field in edit mode."""
 | 
				
			||||||
 | 
					        if not self.app.entry_edit_mode:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get all input fields in order
 | 
				
			||||||
 | 
					        fields = [
 | 
				
			||||||
 | 
					            self.app.query_one("#ip-input", Input),
 | 
				
			||||||
 | 
					            self.app.query_one("#hostname-input", Input),
 | 
				
			||||||
 | 
					            self.app.query_one("#comment-input", Input),
 | 
				
			||||||
 | 
					            self.app.query_one("#active-checkbox", Checkbox),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Find currently focused field and move to next
 | 
				
			||||||
 | 
					        for i, field in enumerate(fields):
 | 
				
			||||||
 | 
					            if field.has_focus:
 | 
				
			||||||
 | 
					                next_field = fields[(i + 1) % len(fields)]
 | 
				
			||||||
 | 
					                next_field.focus()
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def navigate_to_prev_field(self) -> None:
 | 
				
			||||||
 | 
					        """Move to the previous field in edit mode."""
 | 
				
			||||||
 | 
					        if not self.app.entry_edit_mode:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get all input fields in order
 | 
				
			||||||
 | 
					        fields = [
 | 
				
			||||||
 | 
					            self.app.query_one("#ip-input", Input),
 | 
				
			||||||
 | 
					            self.app.query_one("#hostname-input", Input),
 | 
				
			||||||
 | 
					            self.app.query_one("#comment-input", Input),
 | 
				
			||||||
 | 
					            self.app.query_one("#active-checkbox", Checkbox),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Find currently focused field and move to previous
 | 
				
			||||||
 | 
					        for i, field in enumerate(fields):
 | 
				
			||||||
 | 
					            if field.has_focus:
 | 
				
			||||||
 | 
					                prev_field = fields[(i - 1) % len(fields)]
 | 
				
			||||||
 | 
					                prev_field.focus()
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_entry_edit_key_event(self, event) -> bool:
 | 
				
			||||||
 | 
					        """Handle key events for entry edit mode navigation.
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        Returns True if the event was handled, False otherwise.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # Only handle custom tab navigation if in entry edit mode AND no modal is open
 | 
				
			||||||
 | 
					        if self.app.entry_edit_mode and len(self.app.screen_stack) == 1:
 | 
				
			||||||
 | 
					            if event.key == "tab":
 | 
				
			||||||
 | 
					                # Prevent default tab behavior and use our custom navigation
 | 
				
			||||||
 | 
					                event.prevent_default()
 | 
				
			||||||
 | 
					                self.navigate_to_next_field()
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					            elif event.key == "shift+tab":
 | 
				
			||||||
 | 
					                # Prevent default shift+tab behavior and use our custom navigation
 | 
				
			||||||
 | 
					                event.prevent_default()
 | 
				
			||||||
 | 
					                self.navigate_to_prev_field()
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
							
								
								
									
										28
									
								
								src/hosts/tui/keybindings.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/hosts/tui/keybindings.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,28 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Key bindings configuration for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module defines all keyboard shortcuts and bindings used
 | 
				
			||||||
 | 
					throughout the application.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from textual.binding import Binding
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Key bindings for the hosts manager application
 | 
				
			||||||
 | 
					HOSTS_MANAGER_BINDINGS = [
 | 
				
			||||||
 | 
					    Binding("q", "quit", "Quit"),
 | 
				
			||||||
 | 
					    Binding("r", "reload", "Reload"),
 | 
				
			||||||
 | 
					    Binding("h", "help", "Help"),
 | 
				
			||||||
 | 
					    Binding("i", "sort_by_ip", "Sort by IP"),
 | 
				
			||||||
 | 
					    Binding("n", "sort_by_hostname", "Sort by Hostname"),
 | 
				
			||||||
 | 
					    Binding("c", "config", "Config"),
 | 
				
			||||||
 | 
					    Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"),
 | 
				
			||||||
 | 
					    Binding("e", "edit_entry", "Edit Entry", show=False),
 | 
				
			||||||
 | 
					    Binding("space", "toggle_entry", "Toggle Entry", show=False),
 | 
				
			||||||
 | 
					    Binding("ctrl+s", "save_file", "Save", show=False),
 | 
				
			||||||
 | 
					    Binding("shift+up", "move_entry_up", "Move Up", show=False),
 | 
				
			||||||
 | 
					    Binding("shift+down", "move_entry_down", "Move Down", show=False),
 | 
				
			||||||
 | 
					    Binding("escape", "exit_edit_entry", "Exit Edit", show=False),
 | 
				
			||||||
 | 
					    Binding("tab", "next_field", "Next Field", show=False),
 | 
				
			||||||
 | 
					    Binding("shift+tab", "prev_field", "Prev Field", show=False),
 | 
				
			||||||
 | 
					    ("ctrl+c", "quit", "Quit"),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
							
								
								
									
										149
									
								
								src/hosts/tui/navigation_handler.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								src/hosts/tui/navigation_handler.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,149 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Navigation and action operations for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module handles entry movement, toggling, file operations,
 | 
				
			||||||
 | 
					and other navigation-related functionality.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from textual.widgets import DataTable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NavigationHandler:
 | 
				
			||||||
 | 
					    """Handles all navigation and action operations for the hosts manager."""
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def __init__(self, app):
 | 
				
			||||||
 | 
					        """Initialize the navigation handler with reference to the main app."""
 | 
				
			||||||
 | 
					        self.app = app
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def toggle_entry(self) -> None:
 | 
				
			||||||
 | 
					        """Toggle the active state of the selected entry."""
 | 
				
			||||||
 | 
					        if not self.app.edit_mode:
 | 
				
			||||||
 | 
					            self.app.update_status(
 | 
				
			||||||
 | 
					                "❌ Cannot toggle entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.app.hosts_file.entries:
 | 
				
			||||||
 | 
					            self.app.update_status("No entries to toggle")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Remember current entry for cursor position restoration
 | 
				
			||||||
 | 
					        current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        success, message = self.app.manager.toggle_entry(
 | 
				
			||||||
 | 
					            self.app.hosts_file, self.app.selected_entry_index
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if success:
 | 
				
			||||||
 | 
					            # Auto-save the changes immediately
 | 
				
			||||||
 | 
					            save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file)
 | 
				
			||||||
 | 
					            if save_success:
 | 
				
			||||||
 | 
					                self.app.table_handler.populate_entries_table()
 | 
				
			||||||
 | 
					                # Restore cursor position to the same entry
 | 
				
			||||||
 | 
					                self.app.set_timer(0.1, lambda: self.app.table_handler.restore_cursor_position(current_entry))
 | 
				
			||||||
 | 
					                self.app.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					                self.app.update_status(f"{message} - Changes saved automatically")
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.app.update_status(f"Entry toggled but save failed: {save_message}")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.app.update_status(f"Error toggling entry: {message}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def move_entry_up(self) -> None:
 | 
				
			||||||
 | 
					        """Move the selected entry up in the list."""
 | 
				
			||||||
 | 
					        if not self.app.edit_mode:
 | 
				
			||||||
 | 
					            self.app.update_status(
 | 
				
			||||||
 | 
					                "❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.app.hosts_file.entries:
 | 
				
			||||||
 | 
					            self.app.update_status("No entries to move")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        success, message = self.app.manager.move_entry_up(
 | 
				
			||||||
 | 
					            self.app.hosts_file, self.app.selected_entry_index
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if success:
 | 
				
			||||||
 | 
					            # Auto-save the changes immediately
 | 
				
			||||||
 | 
					            save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file)
 | 
				
			||||||
 | 
					            if save_success:
 | 
				
			||||||
 | 
					                # Update the selection index to follow the moved entry
 | 
				
			||||||
 | 
					                if self.app.selected_entry_index > 0:
 | 
				
			||||||
 | 
					                    self.app.selected_entry_index -= 1
 | 
				
			||||||
 | 
					                self.app.table_handler.populate_entries_table()
 | 
				
			||||||
 | 
					                # Update the DataTable cursor position to follow the moved entry
 | 
				
			||||||
 | 
					                table = self.app.query_one("#entries-table", DataTable)
 | 
				
			||||||
 | 
					                display_index = self.app.table_handler.actual_index_to_display_index(
 | 
				
			||||||
 | 
					                    self.app.selected_entry_index
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                if table.row_count > 0 and display_index < table.row_count:
 | 
				
			||||||
 | 
					                    table.move_cursor(row=display_index)
 | 
				
			||||||
 | 
					                self.app.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					                self.app.update_status(f"{message} - Changes saved automatically")
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.app.update_status(f"Entry moved but save failed: {save_message}")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.app.update_status(f"Error moving entry: {message}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def move_entry_down(self) -> None:
 | 
				
			||||||
 | 
					        """Move the selected entry down in the list."""
 | 
				
			||||||
 | 
					        if not self.app.edit_mode:
 | 
				
			||||||
 | 
					            self.app.update_status(
 | 
				
			||||||
 | 
					                "❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.app.hosts_file.entries:
 | 
				
			||||||
 | 
					            self.app.update_status("No entries to move")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        success, message = self.app.manager.move_entry_down(
 | 
				
			||||||
 | 
					            self.app.hosts_file, self.app.selected_entry_index
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if success:
 | 
				
			||||||
 | 
					            # Auto-save the changes immediately
 | 
				
			||||||
 | 
					            save_success, save_message = self.app.manager.save_hosts_file(self.app.hosts_file)
 | 
				
			||||||
 | 
					            if save_success:
 | 
				
			||||||
 | 
					                # Update the selection index to follow the moved entry
 | 
				
			||||||
 | 
					                if self.app.selected_entry_index < len(self.app.hosts_file.entries) - 1:
 | 
				
			||||||
 | 
					                    self.app.selected_entry_index += 1
 | 
				
			||||||
 | 
					                self.app.table_handler.populate_entries_table()
 | 
				
			||||||
 | 
					                # Update the DataTable cursor position to follow the moved entry
 | 
				
			||||||
 | 
					                table = self.app.query_one("#entries-table", DataTable)
 | 
				
			||||||
 | 
					                display_index = self.app.table_handler.actual_index_to_display_index(
 | 
				
			||||||
 | 
					                    self.app.selected_entry_index
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                if table.row_count > 0 and display_index < table.row_count:
 | 
				
			||||||
 | 
					                    table.move_cursor(row=display_index)
 | 
				
			||||||
 | 
					                self.app.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					                self.app.update_status(f"{message} - Changes saved automatically")
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.app.update_status(f"Entry moved but save failed: {save_message}")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.app.update_status(f"Error moving entry: {message}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save_hosts_file(self) -> None:
 | 
				
			||||||
 | 
					        """Save the hosts file to disk."""
 | 
				
			||||||
 | 
					        if not self.app.edit_mode:
 | 
				
			||||||
 | 
					            self.app.update_status(
 | 
				
			||||||
 | 
					                "❌ Cannot save: Application is in read-only mode. No changes to save."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        success, message = self.app.manager.save_hosts_file(self.app.hosts_file)
 | 
				
			||||||
 | 
					        if success:
 | 
				
			||||||
 | 
					            self.app.update_status(message)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.app.update_status(f"Error saving file: {message}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def quit_application(self) -> None:
 | 
				
			||||||
 | 
					        """Quit the application with proper cleanup."""
 | 
				
			||||||
 | 
					        # If in entry edit mode, exit it first
 | 
				
			||||||
 | 
					        if self.app.entry_edit_mode:
 | 
				
			||||||
 | 
					            self.app.edit_handler.exit_edit_entry_with_confirmation()
 | 
				
			||||||
 | 
					            return  # Let the confirmation handle the exit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # If in edit mode, exit it first
 | 
				
			||||||
 | 
					        if self.app.edit_mode:
 | 
				
			||||||
 | 
					            self.app.manager.exit_edit_mode()
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        self.app.exit()
 | 
				
			||||||
							
								
								
									
										95
									
								
								src/hosts/tui/styles.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/hosts/tui/styles.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,95 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					CSS styles and theming for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module contains all CSS definitions for consistent styling
 | 
				
			||||||
 | 
					across the application.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# CSS styles for the hosts manager application
 | 
				
			||||||
 | 
					HOSTS_MANAGER_CSS = """
 | 
				
			||||||
 | 
					.hosts-container {
 | 
				
			||||||
 | 
					    height: 1fr;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.left-pane {
 | 
				
			||||||
 | 
					    width: 60%;
 | 
				
			||||||
 | 
					    border: round $primary;
 | 
				
			||||||
 | 
					    margin: 1;
 | 
				
			||||||
 | 
					    padding: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.right-pane {
 | 
				
			||||||
 | 
					    width: 40%;
 | 
				
			||||||
 | 
					    border: round $primary;
 | 
				
			||||||
 | 
					    margin: 1;
 | 
				
			||||||
 | 
					    padding: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.entry-active {
 | 
				
			||||||
 | 
					    color: $success;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.entry-inactive {
 | 
				
			||||||
 | 
					    color: $warning;
 | 
				
			||||||
 | 
					    text-style: italic;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.status-bar {
 | 
				
			||||||
 | 
					    background: $surface;
 | 
				
			||||||
 | 
					    color: $text;
 | 
				
			||||||
 | 
					    height: 1;
 | 
				
			||||||
 | 
					    padding: 0 1;
 | 
				
			||||||
 | 
					    dock: bottom;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.status-error {
 | 
				
			||||||
 | 
					    background: $error;
 | 
				
			||||||
 | 
					    color: $text;
 | 
				
			||||||
 | 
					    height: 1;
 | 
				
			||||||
 | 
					    padding: 0 1;
 | 
				
			||||||
 | 
					    text-style: bold;
 | 
				
			||||||
 | 
					    dock: bottom;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* DataTable styling to match background */
 | 
				
			||||||
 | 
					#entries-table {
 | 
				
			||||||
 | 
					    background: $background;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#entries-table .datatable--header {
 | 
				
			||||||
 | 
					    background: $surface;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#entries-table .datatable--even-row {
 | 
				
			||||||
 | 
					    background: $background;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#entries-table .datatable--odd-row {
 | 
				
			||||||
 | 
					    background: $surface;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* DataTable row styling - colors are now handled via Rich Text objects */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.hidden {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#entry-edit-form {
 | 
				
			||||||
 | 
					    height: auto;
 | 
				
			||||||
 | 
					    padding: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#entry-edit-form Label {
 | 
				
			||||||
 | 
					    margin-bottom: 1;
 | 
				
			||||||
 | 
					    color: $accent;
 | 
				
			||||||
 | 
					    text-style: bold;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#entry-edit-form Input {
 | 
				
			||||||
 | 
					    margin-bottom: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#entry-edit-form Checkbox {
 | 
				
			||||||
 | 
					    margin-bottom: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
							
								
								
									
										219
									
								
								src/hosts/tui/table_handler.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								src/hosts/tui/table_handler.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,219 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Data table management for the hosts TUI application.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This module handles table population, sorting, filtering, and
 | 
				
			||||||
 | 
					row selection functionality.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from rich.text import Text
 | 
				
			||||||
 | 
					from textual.widgets import DataTable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TableHandler:
 | 
				
			||||||
 | 
					    """Handles all data table operations for the hosts manager."""
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def __init__(self, app):
 | 
				
			||||||
 | 
					        """Initialize the table handler with reference to the main app."""
 | 
				
			||||||
 | 
					        self.app = app
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    def get_visible_entries(self) -> list:
 | 
				
			||||||
 | 
					        """Get the list of entries that are visible in the table (after filtering)."""
 | 
				
			||||||
 | 
					        show_defaults = self.app.config.should_show_default_entries()
 | 
				
			||||||
 | 
					        visible_entries = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for entry in self.app.hosts_file.entries:
 | 
				
			||||||
 | 
					            canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
 | 
				
			||||||
 | 
					            # Skip default entries if configured to hide them
 | 
				
			||||||
 | 
					            if not show_defaults and self.app.config.is_default_entry(
 | 
				
			||||||
 | 
					                entry.ip_address, canonical_hostname
 | 
				
			||||||
 | 
					            ):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            visible_entries.append(entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return visible_entries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_first_visible_entry_index(self) -> int:
 | 
				
			||||||
 | 
					        """Get the index of the first visible entry in the hosts file."""
 | 
				
			||||||
 | 
					        show_defaults = self.app.config.should_show_default_entries()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for i, entry in enumerate(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					            canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
 | 
				
			||||||
 | 
					            # Skip default entries if configured to hide them
 | 
				
			||||||
 | 
					            if not show_defaults and self.app.config.is_default_entry(
 | 
				
			||||||
 | 
					                entry.ip_address, canonical_hostname
 | 
				
			||||||
 | 
					            ):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            return i
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # If no visible entries found, return 0
 | 
				
			||||||
 | 
					        return 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def display_index_to_actual_index(self, display_index: int) -> int:
 | 
				
			||||||
 | 
					        """Convert a display table index to the actual hosts file entry index."""
 | 
				
			||||||
 | 
					        visible_entries = self.get_visible_entries()
 | 
				
			||||||
 | 
					        if display_index >= len(visible_entries):
 | 
				
			||||||
 | 
					            return 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        target_entry = visible_entries[display_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Find this entry in the full hosts file
 | 
				
			||||||
 | 
					        for i, entry in enumerate(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					            if entry is target_entry:
 | 
				
			||||||
 | 
					                return i
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def actual_index_to_display_index(self, actual_index: int) -> int:
 | 
				
			||||||
 | 
					        """Convert an actual hosts file entry index to a display table index."""
 | 
				
			||||||
 | 
					        if actual_index >= len(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					            return 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        target_entry = self.app.hosts_file.entries[actual_index]
 | 
				
			||||||
 | 
					        visible_entries = self.get_visible_entries()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Find this entry in the visible entries
 | 
				
			||||||
 | 
					        for i, entry in enumerate(visible_entries):
 | 
				
			||||||
 | 
					            if entry is target_entry:
 | 
				
			||||||
 | 
					                return i
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_entries_table(self) -> None:
 | 
				
			||||||
 | 
					        """Populate the left pane with hosts entries using DataTable."""
 | 
				
			||||||
 | 
					        table = self.app.query_one("#entries-table", DataTable)
 | 
				
			||||||
 | 
					        table.clear(columns=True)  # Clear both rows and columns
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Configure DataTable properties
 | 
				
			||||||
 | 
					        table.zebra_stripes = True
 | 
				
			||||||
 | 
					        table.cursor_type = "row"
 | 
				
			||||||
 | 
					        table.show_header = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create column labels with sort indicators
 | 
				
			||||||
 | 
					        active_label = "Active"
 | 
				
			||||||
 | 
					        ip_label = "IP Address"
 | 
				
			||||||
 | 
					        hostname_label = "Canonical Hostname"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add sort indicators
 | 
				
			||||||
 | 
					        if self.app.sort_column == "ip":
 | 
				
			||||||
 | 
					            arrow = "↑" if self.app.sort_ascending else "↓"
 | 
				
			||||||
 | 
					            ip_label = f"{arrow} IP Address"
 | 
				
			||||||
 | 
					        elif self.app.sort_column == "hostname":
 | 
				
			||||||
 | 
					            arrow = "↑" if self.app.sort_ascending else "↓"
 | 
				
			||||||
 | 
					            hostname_label = f"{arrow} Canonical Hostname"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add columns with proper labels (Active column first)
 | 
				
			||||||
 | 
					        table.add_columns(active_label, ip_label, hostname_label)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Get visible entries (after filtering)
 | 
				
			||||||
 | 
					        visible_entries = self.get_visible_entries()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add rows
 | 
				
			||||||
 | 
					        for entry in visible_entries:
 | 
				
			||||||
 | 
					            # Get the canonical hostname (first hostname)
 | 
				
			||||||
 | 
					            canonical_hostname = entry.hostnames[0] if entry.hostnames else ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Check if this is a default system entry
 | 
				
			||||||
 | 
					            is_default = entry.is_default_entry()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Add row with styling based on active status and default entry status
 | 
				
			||||||
 | 
					            if is_default:
 | 
				
			||||||
 | 
					                # Default entries are always shown in dim grey regardless of active status
 | 
				
			||||||
 | 
					                active_text = Text("✓" if entry.is_active else "", style="dim white")
 | 
				
			||||||
 | 
					                ip_text = Text(entry.ip_address, style="dim white")
 | 
				
			||||||
 | 
					                hostname_text = Text(canonical_hostname, style="dim white")
 | 
				
			||||||
 | 
					                table.add_row(active_text, ip_text, hostname_text)
 | 
				
			||||||
 | 
					            elif entry.is_active:
 | 
				
			||||||
 | 
					                # Active entries in green with checkmark
 | 
				
			||||||
 | 
					                active_text = Text("✓", style="bold green")
 | 
				
			||||||
 | 
					                ip_text = Text(entry.ip_address, style="bold green")
 | 
				
			||||||
 | 
					                hostname_text = Text(canonical_hostname, style="bold green")
 | 
				
			||||||
 | 
					                table.add_row(active_text, ip_text, hostname_text)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                # Inactive entries in dim yellow with italic (no checkmark)
 | 
				
			||||||
 | 
					                active_text = Text("", style="dim yellow italic")
 | 
				
			||||||
 | 
					                ip_text = Text(entry.ip_address, style="dim yellow italic")
 | 
				
			||||||
 | 
					                hostname_text = Text(canonical_hostname, style="dim yellow italic")
 | 
				
			||||||
 | 
					                table.add_row(active_text, ip_text, hostname_text)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def restore_cursor_position(self, previous_entry) -> None:
 | 
				
			||||||
 | 
					        """Restore cursor position after reload, maintaining selection if possible."""
 | 
				
			||||||
 | 
					        if not self.app.hosts_file.entries:
 | 
				
			||||||
 | 
					            self.app.selected_entry_index = 0
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if previous_entry is None:
 | 
				
			||||||
 | 
					            # No previous selection, start at first visible entry
 | 
				
			||||||
 | 
					            self.app.selected_entry_index = self.get_first_visible_entry_index()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Try to find the same entry in the reloaded file
 | 
				
			||||||
 | 
					            for i, entry in enumerate(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					                if (
 | 
				
			||||||
 | 
					                    entry.ip_address == previous_entry.ip_address
 | 
				
			||||||
 | 
					                    and entry.hostnames == previous_entry.hostnames
 | 
				
			||||||
 | 
					                    and entry.comment == previous_entry.comment
 | 
				
			||||||
 | 
					                ):
 | 
				
			||||||
 | 
					                    self.app.selected_entry_index = i
 | 
				
			||||||
 | 
					                    break
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                # Entry not found, default to first visible entry
 | 
				
			||||||
 | 
					                self.app.selected_entry_index = self.get_first_visible_entry_index()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Update the DataTable cursor position using display index
 | 
				
			||||||
 | 
					        table = self.app.query_one("#entries-table", DataTable)
 | 
				
			||||||
 | 
					        display_index = self.actual_index_to_display_index(self.app.selected_entry_index)
 | 
				
			||||||
 | 
					        if table.row_count > 0 and display_index < table.row_count:
 | 
				
			||||||
 | 
					            # Move cursor to the selected row
 | 
				
			||||||
 | 
					            table.move_cursor(row=display_index)
 | 
				
			||||||
 | 
					            table.focus()
 | 
				
			||||||
 | 
					            # Update the details pane to match the selection
 | 
				
			||||||
 | 
					            self.app.details_handler.update_entry_details()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sort_entries_by_ip(self) -> None:
 | 
				
			||||||
 | 
					        """Sort entries by IP address."""
 | 
				
			||||||
 | 
					        if self.app.sort_column == "ip":
 | 
				
			||||||
 | 
					            # Toggle sort direction if already sorting by IP
 | 
				
			||||||
 | 
					            self.app.sort_ascending = not self.app.sort_ascending
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Set new sort column and default to ascending
 | 
				
			||||||
 | 
					            self.app.sort_column = "ip"
 | 
				
			||||||
 | 
					            self.app.sort_ascending = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Remember the currently selected entry
 | 
				
			||||||
 | 
					        current_entry = None
 | 
				
			||||||
 | 
					        if self.app.hosts_file.entries and self.app.selected_entry_index < len(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					            current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Sort the entries
 | 
				
			||||||
 | 
					        self.app.hosts_file.entries.sort(
 | 
				
			||||||
 | 
					            key=lambda entry: entry.ip_address,
 | 
				
			||||||
 | 
					            reverse=not self.app.sort_ascending
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Refresh the table and restore cursor position
 | 
				
			||||||
 | 
					        self.populate_entries_table()
 | 
				
			||||||
 | 
					        self.restore_cursor_position(current_entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def sort_entries_by_hostname(self) -> None:
 | 
				
			||||||
 | 
					        """Sort entries by canonical hostname."""
 | 
				
			||||||
 | 
					        if self.app.sort_column == "hostname":
 | 
				
			||||||
 | 
					            # Toggle sort direction if already sorting by hostname
 | 
				
			||||||
 | 
					            self.app.sort_ascending = not self.app.sort_ascending
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Set new sort column and default to ascending
 | 
				
			||||||
 | 
					            self.app.sort_column = "hostname"
 | 
				
			||||||
 | 
					            self.app.sort_ascending = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Remember the currently selected entry
 | 
				
			||||||
 | 
					        current_entry = None
 | 
				
			||||||
 | 
					        if self.app.hosts_file.entries and self.app.selected_entry_index < len(self.app.hosts_file.entries):
 | 
				
			||||||
 | 
					            current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Sort the entries
 | 
				
			||||||
 | 
					        self.app.hosts_file.entries.sort(
 | 
				
			||||||
 | 
					            key=lambda entry: entry.hostnames[0].lower() if entry.hostnames else "",
 | 
				
			||||||
 | 
					            reverse=not self.app.sort_ascending
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Refresh the table and restore cursor position
 | 
				
			||||||
 | 
					        self.populate_entries_table()
 | 
				
			||||||
 | 
					        self.restore_cursor_position(current_entry)
 | 
				
			||||||
| 
						 | 
					@ -8,7 +8,7 @@ validating application behavior, navigation, and user interactions.
 | 
				
			||||||
from unittest.mock import Mock, patch
 | 
					from unittest.mock import Mock, patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from hosts.main import HostsManagerApp
 | 
					from hosts.tui.app import HostsManagerApp
 | 
				
			||||||
from hosts.core.models import HostEntry, HostsFile
 | 
					from hosts.core.models import HostEntry, HostsFile
 | 
				
			||||||
from hosts.core.parser import HostsParser
 | 
					from hosts.core.parser import HostsParser
 | 
				
			||||||
from hosts.core.config import Config
 | 
					from hosts.core.config import Config
 | 
				
			||||||
| 
						 | 
					@ -19,7 +19,7 @@ class TestHostsManagerApp:
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    def test_app_initialization(self):
 | 
					    def test_app_initialization(self):
 | 
				
			||||||
        """Test application initialization."""
 | 
					        """Test application initialization."""
 | 
				
			||||||
        with patch('hosts.main.HostsParser'), patch('hosts.main.Config'):
 | 
					        with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            assert app.title == "Hosts Manager"
 | 
					            assert app.title == "Hosts Manager"
 | 
				
			||||||
| 
						 | 
					@ -31,7 +31,7 @@ class TestHostsManagerApp:
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    def test_app_compose_method_exists(self):
 | 
					    def test_app_compose_method_exists(self):
 | 
				
			||||||
        """Test that app has compose method."""
 | 
					        """Test that app has compose method."""
 | 
				
			||||||
        with patch('hosts.main.HostsParser'), patch('hosts.main.Config'):
 | 
					        with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            # Test that compose method exists and is callable
 | 
					            # Test that compose method exists and is callable
 | 
				
			||||||
| 
						 | 
					@ -55,8 +55,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
            'size': 100
 | 
					            'size': 100
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.populate_entries_table = Mock()
 | 
					            app.populate_entries_table = Mock()
 | 
				
			||||||
| 
						 | 
					@ -76,8 +76,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        mock_parser.parse.side_effect = FileNotFoundError("File not found")
 | 
					        mock_parser.parse.side_effect = FileNotFoundError("File not found")
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.update_status = Mock()
 | 
					            app.update_status = Mock()
 | 
				
			||||||
| 
						 | 
					@ -93,8 +93,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        mock_parser.parse.side_effect = PermissionError("Permission denied")
 | 
					        mock_parser.parse.side_effect = PermissionError("Permission denied")
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.update_status = Mock()
 | 
					            app.update_status = Mock()
 | 
				
			||||||
| 
						 | 
					@ -111,8 +111,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_config.should_show_default_entries.return_value = True
 | 
					        mock_config.should_show_default_entries.return_value = True
 | 
				
			||||||
        mock_config.is_default_entry.return_value = False
 | 
					        mock_config.is_default_entry.return_value = False
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -143,8 +143,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -176,8 +176,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -202,8 +202,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
            'size': 100
 | 
					            'size': 100
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -234,8 +234,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -256,8 +256,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.load_hosts_file = Mock()
 | 
					            app.load_hosts_file = Mock()
 | 
				
			||||||
| 
						 | 
					@ -273,8 +273,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.update_status = Mock()
 | 
					            app.update_status = Mock()
 | 
				
			||||||
| 
						 | 
					@ -291,8 +291,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.push_screen = Mock()
 | 
					            app.push_screen = Mock()
 | 
				
			||||||
| 
						 | 
					@ -309,8 +309,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -339,8 +339,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -369,8 +369,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.update_entry_details = Mock()
 | 
					            app.update_entry_details = Mock()
 | 
				
			||||||
| 
						 | 
					@ -397,8 +397,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            app.action_sort_by_ip = Mock()
 | 
					            app.action_sort_by_ip = Mock()
 | 
				
			||||||
| 
						 | 
					@ -419,8 +419,8 @@ class TestHostsManagerApp:
 | 
				
			||||||
        mock_parser = Mock(spec=HostsParser)
 | 
					        mock_parser = Mock(spec=HostsParser)
 | 
				
			||||||
        mock_config = Mock(spec=Config)
 | 
					        mock_config = Mock(spec=Config)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        with patch('hosts.main.HostsParser', return_value=mock_parser), \
 | 
					        with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
 | 
				
			||||||
             patch('hosts.main.Config', return_value=mock_config):
 | 
					             patch('hosts.tui.app.Config', return_value=mock_config):
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
| 
						 | 
					@ -449,7 +449,7 @@ class TestHostsManagerApp:
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    def test_app_bindings_defined(self):
 | 
					    def test_app_bindings_defined(self):
 | 
				
			||||||
        """Test that application has expected key bindings."""
 | 
					        """Test that application has expected key bindings."""
 | 
				
			||||||
        with patch('hosts.main.HostsParser'), patch('hosts.main.Config'):
 | 
					        with patch('hosts.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
 | 
				
			||||||
            app = HostsManagerApp()
 | 
					            app = HostsManagerApp()
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            # Check that bindings are defined
 | 
					            # Check that bindings are defined
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,7 +8,7 @@ import pytest
 | 
				
			||||||
from unittest.mock import Mock, patch
 | 
					from unittest.mock import Mock, patch
 | 
				
			||||||
from textual.widgets import Button
 | 
					from textual.widgets import Button
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from hosts.main import HostsManagerApp
 | 
					from hosts.tui.app import HostsManagerApp
 | 
				
			||||||
from hosts.tui.save_confirmation_modal import SaveConfirmationModal
 | 
					from hosts.tui.save_confirmation_modal import SaveConfirmationModal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue