Compare commits

..

No commits in common. "116752135566e7732db1907ed7fdff159b5208db" and "8b1c01c89494bce68ecbe82bdcc751c864e46454" have entirely different histories.

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

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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"),
]

View file

@ -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()

View file

@ -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;
}
"""

View file

@ -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)

View file

@ -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

View file

@ -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