Add entry editing functionality with auto-save; enhance input validation and navigation
This commit is contained in:
		
							parent
							
								
									d477328bea
								
							
						
					
					
						commit
						5a117fb624
					
				
					 1 changed files with 244 additions and 3 deletions
				
			
		| 
						 | 
				
			
			@ -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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue