Add TUI components for hosts management
- Implement DetailsHandler for managing entry details display and edit forms. - Create EditHandler to handle edit mode operations, including validation and saving of entry changes. - Introduce NavigationHandler for entry movement and action operations. - Define key bindings for various application actions in keybindings.py. - Add TableHandler for managing the data table, including sorting and filtering of entries. - Establish CSS styles for consistent theming across the application. - Update tests to reflect changes in module structure and ensure proper functionality.
This commit is contained in:
parent
8b1c01c894
commit
4dbf200c5f
12 changed files with 2259 additions and 1029 deletions
File diff suppressed because it is too large
Load diff
1007
src/hosts/main_backup.py
Normal file
1007
src/hosts/main_backup.py
Normal file
File diff suppressed because it is too large
Load diff
17
src/hosts/main_new.py
Normal file
17
src/hosts/main_new.py
Normal file
|
@ -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()
|
368
src/hosts/tui/app.py
Normal file
368
src/hosts/tui/app.py
Normal file
|
@ -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()
|
115
src/hosts/tui/details_handler.py
Normal file
115
src/hosts/tui/details_handler.py
Normal file
|
@ -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
|
222
src/hosts/tui/edit_handler.py
Normal file
222
src/hosts/tui/edit_handler.py
Normal file
|
@ -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
|
28
src/hosts/tui/keybindings.py
Normal file
28
src/hosts/tui/keybindings.py
Normal file
|
@ -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"),
|
||||
]
|
149
src/hosts/tui/navigation_handler.py
Normal file
149
src/hosts/tui/navigation_handler.py
Normal file
|
@ -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()
|
95
src/hosts/tui/styles.py
Normal file
95
src/hosts/tui/styles.py
Normal file
|
@ -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;
|
||||
}
|
||||
"""
|
219
src/hosts/tui/table_handler.py
Normal file
219
src/hosts/tui/table_handler.py
Normal file
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue