Add entry editing functionality with auto-save; enhance input validation and navigation

This commit is contained in:
Philip Henning 2025-07-30 10:05:05 +02:00
parent d477328bea
commit 5a117fb624

View file

@ -6,10 +6,12 @@ This module contains the main application class and entry point function.
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical 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.binding import Binding
from textual.reactive import reactive from textual.reactive import reactive
from rich.text import Text from rich.text import Text
import ipaddress
import re
from .core.parser import HostsParser from .core.parser import HostsParser
from .core.models import HostsFile from .core.models import HostsFile
@ -89,6 +91,29 @@ class HostsManagerApp(App):
} }
/* DataTable row styling - colors are now handled via Rich Text objects */ /* 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 = [ BINDINGS = [
@ -99,10 +124,14 @@ class HostsManagerApp(App):
Binding("n", "sort_by_hostname", "Sort by Hostname"), Binding("n", "sort_by_hostname", "Sort by Hostname"),
Binding("c", "config", "Config"), Binding("c", "config", "Config"),
Binding("ctrl+e", "toggle_edit_mode", "Edit Mode"), 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("space", "toggle_entry", "Toggle Entry", show=False),
Binding("ctrl+s", "save_file", "Save", show=False), Binding("ctrl+s", "save_file", "Save", show=False),
Binding("shift+up", "move_entry_up", "Move Up", show=False), Binding("shift+up", "move_entry_up", "Move Up", show=False),
Binding("shift+down", "move_entry_down", "Move Down", 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"), ("ctrl+c", "quit", "Quit"),
] ]
@ -110,6 +139,7 @@ class HostsManagerApp(App):
hosts_file: reactive[HostsFile] = reactive(HostsFile()) hosts_file: reactive[HostsFile] = reactive(HostsFile())
selected_entry_index: reactive[int] = reactive(0) selected_entry_index: reactive[int] = reactive(0)
edit_mode: reactive[bool] = reactive(False) edit_mode: reactive[bool] = reactive(False)
entry_edit_mode: reactive[bool] = reactive(False)
sort_column: reactive[str] = reactive("") # "ip" or "hostname" sort_column: reactive[str] = reactive("") # "ip" or "hostname"
sort_ascending: reactive[bool] = reactive(True) sort_ascending: reactive[bool] = reactive(True)
@ -137,6 +167,15 @@ class HostsManagerApp(App):
right_pane.border_title = "Entry Details" right_pane.border_title = "Entry Details"
with right_pane: with right_pane:
yield Static("", id="entry-details") 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 right_pane
yield Static("", classes="status-bar", id="status") yield Static("", classes="status-bar", id="status")
@ -322,7 +361,19 @@ class HostsManagerApp(App):
def update_entry_details(self) -> None: def update_entry_details(self) -> None:
"""Update the right pane with selected entry details.""" """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) 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: if not self.hosts_file.entries:
details_widget.update("No entries loaded") details_widget.update("No entries loaded")
@ -374,6 +425,31 @@ class HostsManagerApp(App):
details_widget.update("\n".join(details_lines)) 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: def update_status(self, message: str = "") -> None:
"""Update the status bar.""" """Update the status bar."""
status_widget = self.query_one("#status", Static) status_widget = self.query_one("#status", Static)
@ -437,7 +513,7 @@ class HostsManagerApp(App):
def action_help(self) -> None: def action_help(self) -> None:
"""Show help information.""" """Show help information."""
# For now, just update the status with help info # 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: def action_config(self) -> None:
"""Show configuration modal.""" """Show configuration modal."""
@ -449,7 +525,6 @@ class HostsManagerApp(App):
self.push_screen(ConfigModal(self.config), handle_config_result) self.push_screen(ConfigModal(self.config), handle_config_result)
def action_sort_by_ip(self) -> None: def action_sort_by_ip(self) -> None:
"""Sort entries by IP address, toggle ascending/descending.""" """Sort entries by IP address, toggle ascending/descending."""
# Toggle sort direction if already sorting by IP # Toggle sort direction if already sorting by IP
@ -512,6 +587,168 @@ class HostsManagerApp(App):
else: else:
self.update_status(f"Error entering edit mode: {message}") 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: def action_toggle_entry(self) -> None:
"""Toggle the active state of the selected entry.""" """Toggle the active state of the selected entry."""
if not self.edit_mode: if not self.edit_mode:
@ -616,6 +853,10 @@ class HostsManagerApp(App):
def action_quit(self) -> None: def action_quit(self) -> None:
"""Quit the application.""" """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 in edit mode, exit it first
if self.edit_mode: if self.edit_mode:
self.manager.exit_edit_mode() self.manager.exit_edit_mode()