Compare commits
No commits in common. "116752135566e7732db1907ed7fdff159b5208db" and "8b1c01c89494bce68ecbe82bdcc751c864e46454" have entirely different histories.
1167521355
...
8b1c01c894
12 changed files with 1029 additions and 2227 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -1,17 +0,0 @@
|
|||
"""
|
||||
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()
|
|
@ -1,353 +0,0 @@
|
|||
"""
|
||||
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")
|
||||
|
||||
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 footer subtitle with status information."""
|
||||
if message:
|
||||
# Set temporary status message
|
||||
self.sub_title = message
|
||||
if message.startswith("❌"):
|
||||
# Auto-clear error message after 5 seconds
|
||||
self.set_timer(5.0, lambda: self.update_status())
|
||||
else:
|
||||
# Auto-clear regular message after 3 seconds
|
||||
self.set_timer(3.0, lambda: self.update_status())
|
||||
else:
|
||||
# Reset to normal status display
|
||||
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']}"
|
||||
|
||||
self.sub_title = 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()
|
|
@ -1,115 +0,0 @@
|
|||
"""
|
||||
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
|
|
@ -1,222 +0,0 @@
|
|||
"""
|
||||
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
|
|
@ -1,28 +0,0 @@
|
|||
"""
|
||||
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"),
|
||||
]
|
|
@ -1,149 +0,0 @@
|
|||
"""
|
||||
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()
|
|
@ -1,78 +0,0 @@
|
|||
"""
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
"""
|
|
@ -1,219 +0,0 @@
|
|||
"""
|
||||
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.tui.app import HostsManagerApp
|
||||
from hosts.main 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.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
|
||||
with patch('hosts.main.HostsParser'), patch('hosts.main.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.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
|
||||
with patch('hosts.main.HostsParser'), patch('hosts.main.Config'):
|
||||
app = HostsManagerApp()
|
||||
|
||||
# Test that compose method exists and is callable
|
||||
|
@ -55,8 +55,8 @@ class TestHostsManagerApp:
|
|||
'size': 100
|
||||
}
|
||||
|
||||
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.Config', return_value=mock_config):
|
||||
|
||||
app = HostsManagerApp()
|
||||
|
||||
|
@ -202,8 +202,8 @@ class TestHostsManagerApp:
|
|||
'size': 100
|
||||
}
|
||||
|
||||
with patch('hosts.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.tui.app.Config', return_value=mock_config):
|
||||
with patch('hosts.main.HostsParser', return_value=mock_parser), \
|
||||
patch('hosts.main.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.tui.app.HostsParser'), patch('hosts.tui.app.Config'):
|
||||
with patch('hosts.main.HostsParser'), patch('hosts.main.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.tui.app import HostsManagerApp
|
||||
from hosts.main import HostsManagerApp
|
||||
from hosts.tui.save_confirmation_modal import SaveConfirmationModal
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue