diff --git a/src/hosts/main.py b/src/hosts/main.py index 4df0d95..d2f3856 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -6,10 +6,12 @@ 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 +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 @@ -89,6 +91,29 @@ class HostsManagerApp(App): } /* 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 = [ @@ -99,10 +124,14 @@ class HostsManagerApp(App): 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"), ] @@ -110,6 +139,7 @@ class HostsManagerApp(App): 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) @@ -137,6 +167,15 @@ class HostsManagerApp(App): 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") @@ -322,7 +361,19 @@ class HostsManagerApp(App): 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") @@ -374,6 +425,31 @@ class HostsManagerApp(App): 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) @@ -437,7 +513,7 @@ class HostsManagerApp(App): 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") + self.update_status("Help: ↑/↓ Navigate, r Reload, q Quit, h Help, c Config, e Edit") def action_config(self) -> None: """Show configuration modal.""" @@ -449,7 +525,6 @@ class HostsManagerApp(App): 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 @@ -512,6 +587,168 @@ class HostsManagerApp(App): 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 + + 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 action_exit_edit_entry(self) -> None: + """Exit entry edit mode and return focus to the entries table.""" + if self.entry_edit_mode: + self.entry_edit_mode = False + 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 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.""" + if self.entry_edit_mode 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 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 and auto-save.""" + if not self.entry_edit_mode or not self.edit_mode: + return + + if event.input.id in ["ip-input", "hostname-input", "comment-input"]: + self.save_entry_changes() + + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + """Handle checkbox changes and auto-save.""" + if not self.entry_edit_mode or not self.edit_mode: + return + + if event.checkbox.id == "active-checkbox": + self.save_entry_changes() + + def save_entry_changes(self) -> None: + """Save the current entry changes.""" + 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] + + # 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") + return + + # 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") + return + + 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}") + return + + # 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") + else: + self.update_status(f"❌ Error saving entry: {message}") + def action_toggle_entry(self) -> None: """Toggle the active state of the selected entry.""" if not self.edit_mode: @@ -616,6 +853,10 @@ class HostsManagerApp(App): 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()