diff --git a/src/hosts/main.py b/src/hosts/main.py index dac9024..ffd6850 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -1,1000 +1,10 @@ """ Main entry point for the hosts TUI application. -This module contains the main application class and entry point function. +This module contains the main application entry point function. """ -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.binding import Binding -from textual.reactive import reactive -from rich.text import Text -import ipaddress -import re - -from .core.parser import HostsParser -from .core.models import HostsFile -from .core.config import Config -from .core.manager import HostsManager -from .tui.config_modal import ConfigModal -from .tui.save_confirmation_modal import SaveConfirmationModal - - -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-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; - } - """ - - 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"), - ] - - # 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.parser = HostsParser() - self.config = Config() - self.manager = HostsManager() - self.title = "Hosts Manager" - self.sub_title = "Read-only mode" - - # Track original entry values for change detection - self.original_entry_values = None - - def compose(self) -> ComposeResult: - """Create the application layout.""" - yield Header() - - with Vertical(): - with Horizontal(classes="hosts-container"): - left_pane = Vertical(classes="left-pane") - left_pane.border_title = "Hosts Entries" - with left_pane: - yield DataTable(id="entries-table") - yield left_pane - - right_pane = Vertical(classes="right-pane") - right_pane.border_title = "Entry Details" - with right_pane: - yield Static("", id="entry-details") - with Vertical(id="entry-edit-form", classes="hidden"): - yield Label("IP Address:") - yield Input(id="ip-input", placeholder="Enter IP address") - yield Label("Hostname:") - yield Input(id="hostname-input", placeholder="Enter hostname") - yield Label("Comment:") - yield Input( - id="comment-input", placeholder="Enter comment (optional)" - ) - yield Label("Active:") - yield Checkbox(id="active-checkbox", value=True) - yield right_pane - - yield Static("", classes="status-bar", id="status") - - yield Footer() - - def on_ready(self) -> None: - """Initialize the application when ready.""" - self.load_hosts_file() - self.update_status() - - def load_hosts_file(self) -> None: - """Load the hosts file and populate the interface.""" - # Remember current selection for restoration - current_entry = None - if self.hosts_file.entries and self.selected_entry_index < len( - self.hosts_file.entries - ): - current_entry = self.hosts_file.entries[self.selected_entry_index] - - try: - self.hosts_file = self.parser.parse() - self.populate_entries_table() - - # Restore cursor position with a timer to ensure ListView is fully rendered - self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) - - self.update_entry_details() - self.log(f"Loaded {len(self.hosts_file.entries)} entries from hosts file") - except FileNotFoundError: - self.log("Hosts file not found") - self.update_status("Error: Hosts file not found") - except PermissionError: - self.log("Permission denied reading hosts file") - self.update_status("Error: Permission denied") - except Exception as e: - self.log(f"Error loading hosts file: {e}") - self.update_status(f"Error: {e}") - - def get_visible_entries(self) -> list: - """Get the list of entries that are visible in the table (after filtering).""" - show_defaults = self.config.should_show_default_entries() - visible_entries = [] - - for entry in self.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.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.config.should_show_default_entries() - - for i, entry in enumerate(self.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.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.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.hosts_file.entries): - return 0 - - target_entry = self.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.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.sort_column == "ip": - arrow = "↑" if self.sort_ascending else "↓" - ip_label = f"{arrow} IP Address" - elif self.sort_column == "hostname": - arrow = "↑" if self.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.hosts_file.entries: - self.selected_entry_index = 0 - return - - if previous_entry is None: - # No previous selection, start at first visible entry - self.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.hosts_file.entries): - if ( - entry.ip_address == previous_entry.ip_address - and entry.hostnames == previous_entry.hostnames - and entry.comment == previous_entry.comment - ): - self.selected_entry_index = i - break - else: - # Entry not found, default to first visible entry - self.selected_entry_index = self.get_first_visible_entry_index() - - # Update the DataTable cursor position using display index - table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index(self.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.update_entry_details() - - def update_entry_details(self) -> None: - """Update the right pane with selected entry details.""" - if self.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.query_one("#entry-details", Static) - edit_form = self.query_one("#entry-edit-form") - - # Show details, hide edit form - details_widget.remove_class("hidden") - edit_form.add_class("hidden") - - if not self.hosts_file.entries: - details_widget.update("No entries loaded") - return - - # Get visible entries to check if we need to adjust selection - visible_entries = self.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.config.should_show_default_entries() - if not show_defaults: - # Check if the currently selected entry is a default entry (hidden) - if ( - self.selected_entry_index < len(self.hosts_file.entries) - and self.hosts_file.entries[ - self.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.hosts_file.entries): - if not entry.is_default_entry(): - self.selected_entry_index = i - break - - if self.selected_entry_index >= len(self.hosts_file.entries): - self.selected_entry_index = 0 - - entry = self.hosts_file.entries[self.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.query_one("#entry-details", Static) - edit_form = self.query_one("#entry-edit-form") - - # Hide details, show edit form - details_widget.add_class("hidden") - edit_form.remove_class("hidden") - - if not self.hosts_file.entries or self.selected_entry_index >= len( - self.hosts_file.entries - ): - return - - entry = self.hosts_file.entries[self.selected_entry_index] - - # Update form fields with current entry values - ip_input = self.query_one("#ip-input", Input) - hostname_input = self.query_one("#hostname-input", Input) - comment_input = self.query_one("#comment-input", Input) - active_checkbox = self.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 - - 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) - - 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.display_index_to_actual_index( - event.cursor_row - ) - self.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.display_index_to_actual_index( - event.cursor_row - ) - self.update_entry_details() - - 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.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.""" - # Toggle sort direction if already sorting by IP - if self.sort_column == "ip": - self.sort_ascending = not self.sort_ascending - else: - self.sort_column = "ip" - self.sort_ascending = True - - # Sort the entries using the new method that keeps defaults on top - self.hosts_file.sort_by_ip(self.sort_ascending) - self.populate_entries_table() - - 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.""" - # Toggle sort direction if already sorting by hostname - if self.sort_column == "hostname": - self.sort_ascending = not self.sort_ascending - else: - self.sort_column = "hostname" - self.sort_ascending = True - - # Sort the entries using the new method that keeps defaults on top - self.hosts_file.sort_by_hostname(self.sort_ascending) - self.populate_entries_table() - - direction = "ascending" if self.sort_ascending else "descending" - self.update_status(f"Sorted by hostname ({direction})") - - 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 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.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 has_entry_changes(self) -> bool: - """Check if the current entry has been modified from its original values.""" - if not self.original_entry_values or not self.entry_edit_mode: - return False - - # Get current values from form fields - ip_input = self.query_one("#ip-input", Input) - hostname_input = self.query_one("#hostname-input", Input) - comment_input = self.query_one("#comment-input", Input) - active_checkbox = self.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.original_entry_values["ip_address"] - or current_hostnames != self.original_entry_values["hostnames"] - or current_comment != self.original_entry_values["comment"] - or active_checkbox.value != self.original_entry_values["is_active"] - ) - - def action_exit_edit_entry(self) -> None: - """Exit entry edit mode and return focus to the entries table.""" - if not self.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.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.entry_edit_mode = False - self.original_entry_values = None - self.update_entry_details() - - # Return focus to the entries table - table = self.query_one("#entries-table", DataTable) - table.focus() - - self.update_status("Exited entry edit mode") - - def restore_original_entry_values(self) -> None: - """Restore the original values to the form fields.""" - if not self.original_entry_values: - return - - # Update form fields with original values - ip_input = self.query_one("#ip-input", Input) - hostname_input = self.query_one("#hostname-input", Input) - comment_input = self.query_one("#comment-input", Input) - active_checkbox = self.query_one("#active-checkbox", Checkbox) - - ip_input.value = self.original_entry_values["ip_address"] - hostname_input.value = ", ".join(self.original_entry_values["hostnames"]) - comment_input.value = self.original_entry_values["comment"] or "" - active_checkbox.value = self.original_entry_values["is_active"] - - def validate_and_save_entry_changes(self) -> bool: - """Validate current entry values and save if valid.""" - if not self.hosts_file.entries or self.selected_entry_index >= len( - self.hosts_file.entries - ): - return False - - entry = self.hosts_file.entries[self.selected_entry_index] - - # Get values from form fields - ip_input = self.query_one("#ip-input", Input) - hostname_input = self.query_one("#hostname-input", Input) - comment_input = self.query_one("#comment-input", Input) - active_checkbox = self.query_one("#active-checkbox", Checkbox) - - # Validate IP address - try: - ipaddress.ip_address(ip_input.value.strip()) - except ValueError: - self.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.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.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.manager.save_hosts_file(self.hosts_file) - if success: - # Update the table display - self.populate_entries_table() - # Restore cursor position - table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index( - self.selected_entry_index - ) - if table.row_count > 0 and display_index < table.row_count: - table.move_cursor(row=display_index) - self.update_status("Entry saved successfully") - return True - else: - self.update_status(f"❌ Error saving entry: {message}") - return False - - def action_next_field(self) -> None: - """Move to the next field in edit mode.""" - if not self.entry_edit_mode: - return - - # Get all input fields in order - fields = [ - self.query_one("#ip-input", Input), - self.query_one("#hostname-input", Input), - self.query_one("#comment-input", Input), - self.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 action_prev_field(self) -> None: - """Move to the previous field in edit mode.""" - if not self.entry_edit_mode: - return - - # Get all input fields in order - fields = [ - self.query_one("#ip-input", Input), - self.query_one("#hostname-input", Input), - self.query_one("#comment-input", Input), - self.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 on_key(self, event) -> None: - """Handle key events to override default tab behavior in edit mode.""" - # Only handle custom tab navigation if in entry edit mode AND no modal is open - if self.entry_edit_mode and len(self.screen_stack) == 1 and event.key == "tab": - # Prevent default tab behavior and use our custom navigation - event.prevent_default() - self.action_next_field() - elif ( - self.entry_edit_mode - and len(self.screen_stack) == 1 - and event.key == "shift+tab" - ): - # Prevent default shift+tab behavior and use our custom navigation - event.prevent_default() - self.action_prev_field() - - 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 - - def action_toggle_entry(self) -> None: - """Toggle the active state of the selected entry.""" - if not self.edit_mode: - self.update_status( - "❌ Cannot toggle 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 toggle") - return - - # Remember current entry for cursor position restoration - current_entry = self.hosts_file.entries[self.selected_entry_index] - - success, message = self.manager.toggle_entry( - self.hosts_file, self.selected_entry_index - ) - if success: - # Auto-save the changes immediately - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - self.populate_entries_table() - # Restore cursor position to the same entry - self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) - self.update_entry_details() - self.update_status(f"{message} - Changes saved automatically") - else: - self.update_status(f"Entry toggled but save failed: {save_message}") - else: - self.update_status(f"Error toggling entry: {message}") - - def action_move_entry_up(self) -> None: - """Move the selected entry up in the list.""" - if not self.edit_mode: - self.update_status( - "❌ Cannot move 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 move") - return - - success, message = self.manager.move_entry_up( - self.hosts_file, self.selected_entry_index - ) - if success: - # Auto-save the changes immediately - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - # Update the selection index to follow the moved entry - if self.selected_entry_index > 0: - self.selected_entry_index -= 1 - self.populate_entries_table() - # Update the DataTable cursor position to follow the moved entry - table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index( - self.selected_entry_index - ) - if table.row_count > 0 and display_index < table.row_count: - table.move_cursor(row=display_index) - self.update_entry_details() - self.update_status(f"{message} - Changes saved automatically") - else: - self.update_status(f"Entry moved but save failed: {save_message}") - else: - self.update_status(f"Error moving entry: {message}") - - def action_move_entry_down(self) -> None: - """Move the selected entry down in the list.""" - if not self.edit_mode: - self.update_status( - "❌ Cannot move 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 move") - return - - success, message = self.manager.move_entry_down( - self.hosts_file, self.selected_entry_index - ) - if success: - # Auto-save the changes immediately - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - # Update the selection index to follow the moved entry - if self.selected_entry_index < len(self.hosts_file.entries) - 1: - self.selected_entry_index += 1 - self.populate_entries_table() - # Update the DataTable cursor position to follow the moved entry - table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index( - self.selected_entry_index - ) - if table.row_count > 0 and display_index < table.row_count: - table.move_cursor(row=display_index) - self.update_entry_details() - self.update_status(f"{message} - Changes saved automatically") - else: - self.update_status(f"Entry moved but save failed: {save_message}") - else: - self.update_status(f"Error moving entry: {message}") - - def action_save_file(self) -> None: - """Save the hosts file to disk.""" - if not self.edit_mode: - self.update_status( - "❌ Cannot save: Application is in read-only mode. No changes to save." - ) - return - - success, message = self.manager.save_hosts_file(self.hosts_file) - if success: - self.update_status(message) - else: - self.update_status(f"Error saving file: {message}") - - def action_quit(self) -> None: - """Quit the application.""" - # If in entry edit mode, exit it first - if self.entry_edit_mode: - self.action_exit_edit_entry() - - # If in edit mode, exit it first - if self.edit_mode: - self.manager.exit_edit_mode() - self.exit() +from .tui.app import HostsManagerApp def main(): diff --git a/src/hosts/main_backup.py b/src/hosts/main_backup.py new file mode 100644 index 0000000..dac9024 --- /dev/null +++ b/src/hosts/main_backup.py @@ -0,0 +1,1007 @@ +""" +Main entry point for the hosts TUI application. + +This module contains the main application class and entry point function. +""" + +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.binding import Binding +from textual.reactive import reactive +from rich.text import Text +import ipaddress +import re + +from .core.parser import HostsParser +from .core.models import HostsFile +from .core.config import Config +from .core.manager import HostsManager +from .tui.config_modal import ConfigModal +from .tui.save_confirmation_modal import SaveConfirmationModal + + +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-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; + } + """ + + 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"), + ] + + # 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.parser = HostsParser() + self.config = Config() + self.manager = HostsManager() + self.title = "Hosts Manager" + self.sub_title = "Read-only mode" + + # Track original entry values for change detection + self.original_entry_values = None + + def compose(self) -> ComposeResult: + """Create the application layout.""" + yield Header() + + with Vertical(): + with Horizontal(classes="hosts-container"): + left_pane = Vertical(classes="left-pane") + left_pane.border_title = "Hosts Entries" + with left_pane: + yield DataTable(id="entries-table") + yield left_pane + + right_pane = Vertical(classes="right-pane") + right_pane.border_title = "Entry Details" + with right_pane: + yield Static("", id="entry-details") + with Vertical(id="entry-edit-form", classes="hidden"): + yield Label("IP Address:") + yield Input(id="ip-input", placeholder="Enter IP address") + yield Label("Hostname:") + yield Input(id="hostname-input", placeholder="Enter hostname") + yield Label("Comment:") + yield Input( + id="comment-input", placeholder="Enter comment (optional)" + ) + yield Label("Active:") + yield Checkbox(id="active-checkbox", value=True) + yield right_pane + + yield Static("", classes="status-bar", id="status") + + yield Footer() + + def on_ready(self) -> None: + """Initialize the application when ready.""" + self.load_hosts_file() + self.update_status() + + def load_hosts_file(self) -> None: + """Load the hosts file and populate the interface.""" + # Remember current selection for restoration + current_entry = None + if self.hosts_file.entries and self.selected_entry_index < len( + self.hosts_file.entries + ): + current_entry = self.hosts_file.entries[self.selected_entry_index] + + try: + self.hosts_file = self.parser.parse() + self.populate_entries_table() + + # Restore cursor position with a timer to ensure ListView is fully rendered + self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) + + self.update_entry_details() + self.log(f"Loaded {len(self.hosts_file.entries)} entries from hosts file") + except FileNotFoundError: + self.log("Hosts file not found") + self.update_status("Error: Hosts file not found") + except PermissionError: + self.log("Permission denied reading hosts file") + self.update_status("Error: Permission denied") + except Exception as e: + self.log(f"Error loading hosts file: {e}") + self.update_status(f"Error: {e}") + + def get_visible_entries(self) -> list: + """Get the list of entries that are visible in the table (after filtering).""" + show_defaults = self.config.should_show_default_entries() + visible_entries = [] + + for entry in self.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.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.config.should_show_default_entries() + + for i, entry in enumerate(self.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.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.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.hosts_file.entries): + return 0 + + target_entry = self.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.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.sort_column == "ip": + arrow = "↑" if self.sort_ascending else "↓" + ip_label = f"{arrow} IP Address" + elif self.sort_column == "hostname": + arrow = "↑" if self.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.hosts_file.entries: + self.selected_entry_index = 0 + return + + if previous_entry is None: + # No previous selection, start at first visible entry + self.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.hosts_file.entries): + if ( + entry.ip_address == previous_entry.ip_address + and entry.hostnames == previous_entry.hostnames + and entry.comment == previous_entry.comment + ): + self.selected_entry_index = i + break + else: + # Entry not found, default to first visible entry + self.selected_entry_index = self.get_first_visible_entry_index() + + # Update the DataTable cursor position using display index + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index(self.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.update_entry_details() + + def update_entry_details(self) -> None: + """Update the right pane with selected entry details.""" + if self.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.query_one("#entry-details", Static) + edit_form = self.query_one("#entry-edit-form") + + # Show details, hide edit form + details_widget.remove_class("hidden") + edit_form.add_class("hidden") + + if not self.hosts_file.entries: + details_widget.update("No entries loaded") + return + + # Get visible entries to check if we need to adjust selection + visible_entries = self.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.config.should_show_default_entries() + if not show_defaults: + # Check if the currently selected entry is a default entry (hidden) + if ( + self.selected_entry_index < len(self.hosts_file.entries) + and self.hosts_file.entries[ + self.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.hosts_file.entries): + if not entry.is_default_entry(): + self.selected_entry_index = i + break + + if self.selected_entry_index >= len(self.hosts_file.entries): + self.selected_entry_index = 0 + + entry = self.hosts_file.entries[self.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.query_one("#entry-details", Static) + edit_form = self.query_one("#entry-edit-form") + + # Hide details, show edit form + details_widget.add_class("hidden") + edit_form.remove_class("hidden") + + if not self.hosts_file.entries or self.selected_entry_index >= len( + self.hosts_file.entries + ): + return + + entry = self.hosts_file.entries[self.selected_entry_index] + + # Update form fields with current entry values + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.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 + + 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) + + 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.display_index_to_actual_index( + event.cursor_row + ) + self.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.display_index_to_actual_index( + event.cursor_row + ) + self.update_entry_details() + + 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.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.""" + # Toggle sort direction if already sorting by IP + if self.sort_column == "ip": + self.sort_ascending = not self.sort_ascending + else: + self.sort_column = "ip" + self.sort_ascending = True + + # Sort the entries using the new method that keeps defaults on top + self.hosts_file.sort_by_ip(self.sort_ascending) + self.populate_entries_table() + + 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.""" + # Toggle sort direction if already sorting by hostname + if self.sort_column == "hostname": + self.sort_ascending = not self.sort_ascending + else: + self.sort_column = "hostname" + self.sort_ascending = True + + # Sort the entries using the new method that keeps defaults on top + self.hosts_file.sort_by_hostname(self.sort_ascending) + self.populate_entries_table() + + direction = "ascending" if self.sort_ascending else "descending" + self.update_status(f"Sorted by hostname ({direction})") + + 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 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.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 has_entry_changes(self) -> bool: + """Check if the current entry has been modified from its original values.""" + if not self.original_entry_values or not self.entry_edit_mode: + return False + + # Get current values from form fields + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.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.original_entry_values["ip_address"] + or current_hostnames != self.original_entry_values["hostnames"] + or current_comment != self.original_entry_values["comment"] + or active_checkbox.value != self.original_entry_values["is_active"] + ) + + def action_exit_edit_entry(self) -> None: + """Exit entry edit mode and return focus to the entries table.""" + if not self.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.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.entry_edit_mode = False + self.original_entry_values = None + self.update_entry_details() + + # Return focus to the entries table + table = self.query_one("#entries-table", DataTable) + table.focus() + + self.update_status("Exited entry edit mode") + + def restore_original_entry_values(self) -> None: + """Restore the original values to the form fields.""" + if not self.original_entry_values: + return + + # Update form fields with original values + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + ip_input.value = self.original_entry_values["ip_address"] + hostname_input.value = ", ".join(self.original_entry_values["hostnames"]) + comment_input.value = self.original_entry_values["comment"] or "" + active_checkbox.value = self.original_entry_values["is_active"] + + def validate_and_save_entry_changes(self) -> bool: + """Validate current entry values and save if valid.""" + if not self.hosts_file.entries or self.selected_entry_index >= len( + self.hosts_file.entries + ): + return False + + entry = self.hosts_file.entries[self.selected_entry_index] + + # Get values from form fields + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + # Validate IP address + try: + ipaddress.ip_address(ip_input.value.strip()) + except ValueError: + self.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.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.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.manager.save_hosts_file(self.hosts_file) + if success: + # Update the table display + self.populate_entries_table() + # Restore cursor position + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) + if table.row_count > 0 and display_index < table.row_count: + table.move_cursor(row=display_index) + self.update_status("Entry saved successfully") + return True + else: + self.update_status(f"❌ Error saving entry: {message}") + return False + + def action_next_field(self) -> None: + """Move to the next field in edit mode.""" + if not self.entry_edit_mode: + return + + # Get all input fields in order + fields = [ + self.query_one("#ip-input", Input), + self.query_one("#hostname-input", Input), + self.query_one("#comment-input", Input), + self.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 action_prev_field(self) -> None: + """Move to the previous field in edit mode.""" + if not self.entry_edit_mode: + return + + # Get all input fields in order + fields = [ + self.query_one("#ip-input", Input), + self.query_one("#hostname-input", Input), + self.query_one("#comment-input", Input), + self.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 on_key(self, event) -> None: + """Handle key events to override default tab behavior in edit mode.""" + # Only handle custom tab navigation if in entry edit mode AND no modal is open + if self.entry_edit_mode and len(self.screen_stack) == 1 and event.key == "tab": + # Prevent default tab behavior and use our custom navigation + event.prevent_default() + self.action_next_field() + elif ( + self.entry_edit_mode + and len(self.screen_stack) == 1 + and event.key == "shift+tab" + ): + # Prevent default shift+tab behavior and use our custom navigation + event.prevent_default() + self.action_prev_field() + + 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 + + def action_toggle_entry(self) -> None: + """Toggle the active state of the selected entry.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot toggle 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 toggle") + return + + # Remember current entry for cursor position restoration + current_entry = self.hosts_file.entries[self.selected_entry_index] + + success, message = self.manager.toggle_entry( + self.hosts_file, self.selected_entry_index + ) + if success: + # Auto-save the changes immediately + save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + if save_success: + self.populate_entries_table() + # Restore cursor position to the same entry + self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) + self.update_entry_details() + self.update_status(f"{message} - Changes saved automatically") + else: + self.update_status(f"Entry toggled but save failed: {save_message}") + else: + self.update_status(f"Error toggling entry: {message}") + + def action_move_entry_up(self) -> None: + """Move the selected entry up in the list.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot move 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 move") + return + + success, message = self.manager.move_entry_up( + self.hosts_file, self.selected_entry_index + ) + if success: + # Auto-save the changes immediately + save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + if save_success: + # Update the selection index to follow the moved entry + if self.selected_entry_index > 0: + self.selected_entry_index -= 1 + self.populate_entries_table() + # Update the DataTable cursor position to follow the moved entry + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) + if table.row_count > 0 and display_index < table.row_count: + table.move_cursor(row=display_index) + self.update_entry_details() + self.update_status(f"{message} - Changes saved automatically") + else: + self.update_status(f"Entry moved but save failed: {save_message}") + else: + self.update_status(f"Error moving entry: {message}") + + def action_move_entry_down(self) -> None: + """Move the selected entry down in the list.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot move 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 move") + return + + success, message = self.manager.move_entry_down( + self.hosts_file, self.selected_entry_index + ) + if success: + # Auto-save the changes immediately + save_success, save_message = self.manager.save_hosts_file(self.hosts_file) + if save_success: + # Update the selection index to follow the moved entry + if self.selected_entry_index < len(self.hosts_file.entries) - 1: + self.selected_entry_index += 1 + self.populate_entries_table() + # Update the DataTable cursor position to follow the moved entry + table = self.query_one("#entries-table", DataTable) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) + if table.row_count > 0 and display_index < table.row_count: + table.move_cursor(row=display_index) + self.update_entry_details() + self.update_status(f"{message} - Changes saved automatically") + else: + self.update_status(f"Entry moved but save failed: {save_message}") + else: + self.update_status(f"Error moving entry: {message}") + + def action_save_file(self) -> None: + """Save the hosts file to disk.""" + if not self.edit_mode: + self.update_status( + "❌ Cannot save: Application is in read-only mode. No changes to save." + ) + return + + success, message = self.manager.save_hosts_file(self.hosts_file) + if success: + self.update_status(message) + else: + self.update_status(f"Error saving file: {message}") + + def action_quit(self) -> None: + """Quit the application.""" + # If in entry edit mode, exit it first + if self.entry_edit_mode: + self.action_exit_edit_entry() + + # If in edit mode, exit it first + if self.edit_mode: + self.manager.exit_edit_mode() + self.exit() + + +def main(): + """Main entry point for the hosts application.""" + app = HostsManagerApp() + app.run() + + +if __name__ == "__main__": + main() diff --git a/src/hosts/main_new.py b/src/hosts/main_new.py new file mode 100644 index 0000000..ffd6850 --- /dev/null +++ b/src/hosts/main_new.py @@ -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() diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py new file mode 100644 index 0000000..6ce6bdb --- /dev/null +++ b/src/hosts/tui/app.py @@ -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() diff --git a/src/hosts/tui/details_handler.py b/src/hosts/tui/details_handler.py new file mode 100644 index 0000000..09d5514 --- /dev/null +++ b/src/hosts/tui/details_handler.py @@ -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 diff --git a/src/hosts/tui/edit_handler.py b/src/hosts/tui/edit_handler.py new file mode 100644 index 0000000..4bf0d11 --- /dev/null +++ b/src/hosts/tui/edit_handler.py @@ -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 diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py new file mode 100644 index 0000000..ad4b72b --- /dev/null +++ b/src/hosts/tui/keybindings.py @@ -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"), +] diff --git a/src/hosts/tui/navigation_handler.py b/src/hosts/tui/navigation_handler.py new file mode 100644 index 0000000..0e7c067 --- /dev/null +++ b/src/hosts/tui/navigation_handler.py @@ -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() diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py new file mode 100644 index 0000000..2b39cc1 --- /dev/null +++ b/src/hosts/tui/styles.py @@ -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; +} +""" diff --git a/src/hosts/tui/table_handler.py b/src/hosts/tui/table_handler.py new file mode 100644 index 0000000..260d670 --- /dev/null +++ b/src/hosts/tui/table_handler.py @@ -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) diff --git a/tests/test_main.py b/tests/test_main.py index dd9de27..2851847 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -8,7 +8,7 @@ validating application behavior, navigation, and user interactions. 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.parser import HostsParser from hosts.core.config import Config @@ -19,7 +19,7 @@ class TestHostsManagerApp: def test_app_initialization(self): """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() assert app.title == "Hosts Manager" @@ -31,7 +31,7 @@ class TestHostsManagerApp: def test_app_compose_method_exists(self): """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() # Test that compose method exists and is callable @@ -55,8 +55,8 @@ class TestHostsManagerApp: 'size': 100 } - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.populate_entries_table = Mock() @@ -76,8 +76,8 @@ class TestHostsManagerApp: mock_config = Mock(spec=Config) mock_parser.parse.side_effect = FileNotFoundError("File not found") - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.update_status = Mock() @@ -93,8 +93,8 @@ class TestHostsManagerApp: mock_config = Mock(spec=Config) mock_parser.parse.side_effect = PermissionError("Permission denied") - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.update_status = Mock() @@ -111,8 +111,8 @@ class TestHostsManagerApp: mock_config.should_show_default_entries.return_value = True mock_config.is_default_entry.return_value = False - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -143,8 +143,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -176,8 +176,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -202,8 +202,8 @@ class TestHostsManagerApp: 'size': 100 } - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -234,8 +234,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -256,8 +256,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.load_hosts_file = Mock() @@ -273,8 +273,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.update_status = Mock() @@ -291,8 +291,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.push_screen = Mock() @@ -309,8 +309,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -339,8 +339,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -369,8 +369,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.update_entry_details = Mock() @@ -397,8 +397,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() app.action_sort_by_ip = Mock() @@ -419,8 +419,8 @@ class TestHostsManagerApp: mock_parser = Mock(spec=HostsParser) mock_config = Mock(spec=Config) - with patch('hosts.main.HostsParser', return_value=mock_parser), \ - patch('hosts.main.Config', return_value=mock_config): + with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \ + patch('hosts.tui.app.Config', return_value=mock_config): app = HostsManagerApp() @@ -449,7 +449,7 @@ class TestHostsManagerApp: def test_app_bindings_defined(self): """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() # Check that bindings are defined diff --git a/tests/test_save_confirmation_modal.py b/tests/test_save_confirmation_modal.py index a6a2acd..3f87b0b 100644 --- a/tests/test_save_confirmation_modal.py +++ b/tests/test_save_confirmation_modal.py @@ -8,7 +8,7 @@ import pytest from unittest.mock import Mock, patch from textual.widgets import Button -from hosts.main import HostsManagerApp +from hosts.tui.app import HostsManagerApp from hosts.tui.save_confirmation_modal import SaveConfirmationModal