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.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()
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue