Compare commits

..

No commits in common. "77d4a2e95530a14774ba03d5b5fb7b00c3096ec5" and "adc40fc16aa97557de7cb65b04be682f74e744cd" have entirely different histories.

8 changed files with 110 additions and 242 deletions

View file

@ -296,18 +296,7 @@ class HostsManager:
try: try:
# Add the new entry at the end # Add the new entry at the end
hosts_file.entries.append(entry) hosts_file.entries.append(entry)
return True, "Entry added successfully"
# Save the file immediately
save_success, save_message = self.save_hosts_file(hosts_file)
if not save_success:
# If save fails, remove the entry that was just added
hosts_file.entries.pop()
return False, f"Failed to save after adding entry: {save_message}"
canonical_hostname = (
entry.hostnames[0] if entry.hostnames else entry.ip_address
)
return True, f"Entry added: {canonical_hostname}"
except Exception as e: except Exception as e:
return False, f"Error adding entry: {e}" return False, f"Error adding entry: {e}"
@ -342,11 +331,7 @@ class HostsManager:
# Remove the entry # Remove the entry
deleted_entry = hosts_file.entries.pop(index) deleted_entry = hosts_file.entries.pop(index)
canonical_hostname = ( canonical_hostname = deleted_entry.hostnames[0] if deleted_entry.hostnames else deleted_entry.ip_address
deleted_entry.hostnames[0]
if deleted_entry.hostnames
else deleted_entry.ip_address
)
# Save the file immediately # Save the file immediately
save_success, save_message = self.save_hosts_file(hosts_file) save_success, save_message = self.save_hosts_file(hosts_file)

View file

@ -7,7 +7,7 @@ all the handlers and provides the primary user interface.
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Static, DataTable, Input, Checkbox from textual.widgets import Header, Static, DataTable, Input, Checkbox, Label
from textual.reactive import reactive from textual.reactive import reactive
from ..core.parser import HostsParser from ..core.parser import HostsParser
@ -91,80 +91,24 @@ class HostsManagerApp(App):
# Right pane - entry details or edit form # Right pane - entry details or edit form
with Vertical(classes="common-pane right-pane") as right_pane: with Vertical(classes="common-pane right-pane") as right_pane:
right_pane.border_title = "Entry Details" right_pane.border_title = "Entry Details"
yield DataTable(
# Details display form (disabled inputs) id="entry-details-table",
with Vertical(id="entry-details-display", classes="entry-form"): show_header=False,
with Vertical( show_cursor=False,
classes="default-section section-no-top-margin" disabled=True,
) as ip_address: )
ip_address.border_title = "IP Address"
yield Input(
placeholder="No entry selected",
id="details-ip-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as hostnames:
hostnames.border_title = "Hostnames (comma-separated)"
yield Input(
placeholder="No entry selected",
id="details-hostname-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as comment:
comment.border_title = "Comment:"
yield Input(
placeholder="No entry selected",
id="details-comment-input",
disabled=True,
classes="default-input",
)
with Vertical(classes="default-section") as active:
active.border_title = "Active"
yield Checkbox(
"Active",
id="details-active-checkbox",
disabled=True,
classes="default-checkbox",
)
# Edit form (initially hidden) # Edit form (initially hidden)
with Vertical(id="entry-edit-form", classes="entry-form hidden"): with Vertical(id="entry-edit-form", classes="hidden"):
with Vertical( yield Label("IP Address:")
classes="default-section section-no-top-margin" yield Input(placeholder="Enter IP address", id="ip-input")
) as ip_address: yield Label("Hostnames (comma-separated):")
ip_address.border_title = "IP Address" yield Input(placeholder="Enter hostnames", id="hostname-input")
yield Input( yield Label("Comment:")
placeholder="Enter IP address", yield Input(
id="ip-input", placeholder="Enter comment (optional)", id="comment-input"
classes="default-input", )
) yield Checkbox("Active", id="active-checkbox")
with Vertical(classes="default-section") as hostnames:
hostnames.border_title = "Hostnames (comma-separated)"
yield Input(
placeholder="Enter hostnames",
id="hostname-input",
classes="default-input",
)
with Vertical(classes="default-section") as comment:
comment.border_title = "Comment:"
yield Input(
placeholder="Enter comment (optional)",
id="comment-input",
classes="default-input",
)
with Vertical(classes="default-section") as active:
active.border_title = "Active"
yield Checkbox(
"Active", id="active-checkbox", classes="default-checkbox"
)
# Status bar for error/temporary messages (overlay, doesn't affect layout) # Status bar for error/temporary messages (overlay, doesn't affect layout)
yield Static("", id="status-bar", classes="status-bar hidden") yield Static("", id="status-bar", classes="status-bar hidden")

View file

@ -5,7 +5,7 @@ This module handles the display and updating of entry details
and edit forms in the right pane. and edit forms in the right pane.
""" """
from textual.widgets import Input, Checkbox from textual.widgets import Input, Checkbox, DataTable
class DetailsHandler: class DetailsHandler:
@ -23,41 +23,30 @@ class DetailsHandler:
self.update_details_display() self.update_details_display()
def update_details_display(self) -> None: def update_details_display(self) -> None:
"""Update the details display using disabled Input widgets.""" """Update the details display using a DataTable with labeled rows."""
details_display = self.app.query_one("#entry-details-display") details_table = self.app.query_one("#entry-details-table", DataTable)
edit_form = self.app.query_one("#entry-edit-form") edit_form = self.app.query_one("#entry-edit-form")
# Show details display, hide edit form # Show details table, hide edit form
details_display.remove_class("hidden") details_table.remove_class("hidden")
edit_form.add_class("hidden") edit_form.add_class("hidden")
# Get the input widgets # Clear existing data
ip_input = self.app.query_one("#details-ip-input", Input) details_table.clear()
hostname_input = self.app.query_one("#details-hostname-input", Input)
comment_input = self.app.query_one("#details-comment-input", Input)
active_checkbox = self.app.query_one("#details-active-checkbox", Checkbox)
if not self.app.hosts_file.entries: if not self.app.hosts_file.entries:
# Show empty message # Show empty message in a single row
ip_input.value = "" if not details_table.columns:
ip_input.placeholder = "No entries loaded" details_table.add_column("Field", key="field")
hostname_input.value = "" details_table.add_row("No entries loaded")
hostname_input.placeholder = "No entries loaded"
comment_input.value = ""
comment_input.placeholder = "No entries loaded"
active_checkbox.value = False
return return
# Get visible entries to check if we need to adjust selection # Get visible entries to check if we need to adjust selection
visible_entries = self.app.table_handler.get_visible_entries() visible_entries = self.app.table_handler.get_visible_entries()
if not visible_entries: if not visible_entries:
ip_input.value = "" if not details_table.columns:
ip_input.placeholder = "No visible entries" details_table.add_column("Field", key="field")
hostname_input.value = "" details_table.add_row("No visible entries")
hostname_input.placeholder = "No visible entries"
comment_input.value = ""
comment_input.placeholder = "No visible entries"
active_checkbox.value = False
return return
# If default entries are hidden and selected_entry_index points to a hidden entry, # If default entries are hidden and selected_entry_index points to a hidden entry,
@ -84,28 +73,36 @@ class DetailsHandler:
entry = self.app.hosts_file.entries[self.app.selected_entry_index] entry = self.app.hosts_file.entries[self.app.selected_entry_index]
# Update the input widgets with entry data # Add columns for labeled rows (Field, Value) - only if not already present
ip_input.value = entry.ip_address if not details_table.columns:
ip_input.placeholder = "" details_table.add_column("Field", key="field")
hostname_input.value = ", ".join(entry.hostnames) details_table.add_column("Value", key="value")
hostname_input.placeholder = ""
comment_input.value = entry.comment or ""
comment_input.placeholder = "No comment"
active_checkbox.value = entry.is_active
# For default entries, show warning in placeholder text # Add rows in the same order as edit form
details_table.add_row("IP Address", entry.ip_address, key="ip")
details_table.add_row("Hostnames", ", ".join(entry.hostnames), key="hostnames")
details_table.add_row("Comment", entry.comment or "", key="comment")
details_table.add_row(
"Active", "Yes" if entry.is_active else "No", key="active"
)
# Add DNS name if present (not in edit form but good to show)
if entry.dns_name:
details_table.add_row("DNS Name", entry.dns_name, key="dns")
# Add notice for default system entries
if entry.is_default_entry(): if entry.is_default_entry():
ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" details_table.add_row("", "", key="spacer")
hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" details_table.add_row("⚠️ WARNING", "SYSTEM DEFAULT ENTRY", key="warning")
comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" details_table.add_row("Note", "This entry cannot be modified", key="note")
def update_edit_form(self) -> None: def update_edit_form(self) -> None:
"""Update the edit form with current entry values.""" """Update the edit form with current entry values."""
details_display = self.app.query_one("#entry-details-display") details_table = self.app.query_one("#entry-details-table", DataTable)
edit_form = self.app.query_one("#entry-edit-form") edit_form = self.app.query_one("#entry-edit-form")
# Hide details display, show edit form # Hide details table, show edit form
details_display.add_class("hidden") details_table.add_class("hidden")
edit_form.remove_class("hidden") edit_form.remove_class("hidden")
if not self.app.hosts_file.entries or self.app.selected_entry_index >= len( if not self.app.hosts_file.entries or self.app.selected_entry_index >= len(

View file

@ -9,7 +9,7 @@ from textual.binding import Binding
# Key bindings for the hosts manager application # Key bindings for the hosts manager application
HOSTS_MANAGER_BINDINGS = [ HOSTS_MANAGER_BINDINGS = [
Binding("n", "add_entry", "New entry", show=True, id="left:new_entry"), Binding("a", "add_entry", "Add new entry", show=True, id="left:add_entry"),
Binding("d", "delete_entry", "Delete entry", show=True, id="left:delete_entry"), Binding("d", "delete_entry", "Delete entry", show=True, id="left:delete_entry"),
Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"), Binding("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"),
Binding( Binding(

View file

@ -41,10 +41,6 @@ COMMON_CSS = """
.hidden { .hidden {
display: none; display: none;
} }
.section-no-top-margin {
margin-top: 0 !important;
}
""" """
# CSS styles for the hosts manager application # CSS styles for the hosts manager application
@ -130,11 +126,39 @@ HOSTS_MANAGER_CSS = (
display: none; display: none;
} }
.entry-form { #entry-edit-form {
height: auto; height: auto;
padding: 1; 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;
}
/* Entry details table styling */
#entry-details-table {
background: $background;
height: auto;
}
#entry-details-table .datatable--even-row {
background: $background;
}
#entry-details-table .datatable--odd-row {
background: $surface;
}
Header { Header {
height: 1; height: 1;
} }

View file

@ -156,27 +156,16 @@ class TestHostsManagerApp:
): ):
app = HostsManagerApp() app = HostsManagerApp()
# Mock the query_one method to return disabled input widgets # Mock the query_one method to return DataTable mock
mock_details_display = Mock() mock_details_table = Mock()
mock_details_table.columns = [] # Mock empty columns list
mock_edit_form = Mock() mock_edit_form = Mock()
mock_ip_input = Mock()
mock_hostname_input = Mock()
mock_comment_input = Mock()
mock_active_checkbox = Mock()
def mock_query_one(selector, widget_type=None): def mock_query_one(selector, widget_type=None):
if selector == "#entry-details-display": if selector == "#entry-details-table":
return mock_details_display return mock_details_table
elif selector == "#entry-edit-form": elif selector == "#entry-edit-form":
return mock_edit_form return mock_edit_form
elif selector == "#details-ip-input":
return mock_ip_input
elif selector == "#details-hostname-input":
return mock_hostname_input
elif selector == "#details-comment-input":
return mock_comment_input
elif selector == "#details-active-checkbox":
return mock_active_checkbox
return Mock() return Mock()
app.query_one = mock_query_one app.query_one = mock_query_one
@ -193,13 +182,12 @@ class TestHostsManagerApp:
app.update_entry_details() app.update_entry_details()
# Verify input widgets were updated with entry data # Verify DataTable operations were called
mock_details_display.remove_class.assert_called_with("hidden") mock_details_table.remove_class.assert_called_with("hidden")
mock_edit_form.add_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden")
assert mock_ip_input.value == "127.0.0.1" mock_details_table.clear.assert_called_once()
assert mock_hostname_input.value == "localhost, local" mock_details_table.add_column.assert_called()
assert mock_comment_input.value == "Test comment" mock_details_table.add_row.assert_called()
assert mock_active_checkbox.value
def test_update_entry_details_no_entries(self): def test_update_entry_details_no_entries(self):
"""Test updating entry details with no entries.""" """Test updating entry details with no entries."""
@ -212,27 +200,16 @@ class TestHostsManagerApp:
): ):
app = HostsManagerApp() app = HostsManagerApp()
# Mock the query_one method to return disabled input widgets # Mock the query_one method to return DataTable mock
mock_details_display = Mock() mock_details_table = Mock()
mock_details_table.columns = [] # Mock empty columns list
mock_edit_form = Mock() mock_edit_form = Mock()
mock_ip_input = Mock()
mock_hostname_input = Mock()
mock_comment_input = Mock()
mock_active_checkbox = Mock()
def mock_query_one(selector, widget_type=None): def mock_query_one(selector, widget_type=None):
if selector == "#entry-details-display": if selector == "#entry-details-table":
return mock_details_display return mock_details_table
elif selector == "#entry-edit-form": elif selector == "#entry-edit-form":
return mock_edit_form return mock_edit_form
elif selector == "#details-ip-input":
return mock_ip_input
elif selector == "#details-hostname-input":
return mock_hostname_input
elif selector == "#details-comment-input":
return mock_comment_input
elif selector == "#details-active-checkbox":
return mock_active_checkbox
return Mock() return Mock()
app.query_one = mock_query_one app.query_one = mock_query_one
@ -240,16 +217,12 @@ class TestHostsManagerApp:
app.update_entry_details() app.update_entry_details()
# Verify widgets show empty state placeholders # Verify DataTable operations were called for empty state
mock_details_display.remove_class.assert_called_with("hidden") mock_details_table.remove_class.assert_called_with("hidden")
mock_edit_form.add_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden")
assert mock_ip_input.value == "" mock_details_table.clear.assert_called_once()
assert mock_ip_input.placeholder == "No entries loaded" mock_details_table.add_column.assert_called_with("Field", key="field")
assert mock_hostname_input.value == "" mock_details_table.add_row.assert_called_with("No entries loaded")
assert mock_hostname_input.placeholder == "No entries loaded"
assert mock_comment_input.value == ""
assert mock_comment_input.placeholder == "No entries loaded"
assert not mock_active_checkbox.value
def test_update_status_default(self): def test_update_status_default(self):
"""Test status bar update with default information.""" """Test status bar update with default information."""

View file

@ -634,58 +634,3 @@ class TestHostsManager:
finally: finally:
# Clean up # Clean up
Path(temp_path).unlink() Path(temp_path).unlink()
def test_add_entry_success(self):
"""Test successfully adding an entry with immediate save."""
manager = HostsManager()
manager.edit_mode = True
# Mock the save operation to succeed
with patch.object(manager, "save_hosts_file") as mock_save:
mock_save.return_value = (True, "File saved successfully")
hosts_file = HostsFile()
test_entry = HostEntry(ip_address="192.168.1.100", hostnames=["testhost"])
success, message = manager.add_entry(hosts_file, test_entry)
assert success
assert "Entry added: testhost" in message
assert len(hosts_file.entries) == 1
assert hosts_file.entries[0] == test_entry
mock_save.assert_called_once_with(hosts_file)
def test_add_entry_save_failure(self):
"""Test adding an entry when save fails."""
manager = HostsManager()
manager.edit_mode = True
# Mock the save operation to fail
with patch.object(manager, "save_hosts_file") as mock_save:
mock_save.return_value = (False, "Permission denied")
hosts_file = HostsFile()
test_entry = HostEntry(ip_address="192.168.1.100", hostnames=["testhost"])
success, message = manager.add_entry(hosts_file, test_entry)
assert not success
assert "Failed to save after adding entry" in message
assert (
len(hosts_file.entries) == 0
) # Entry should be removed on save failure
mock_save.assert_called_once_with(hosts_file)
def test_add_entry_not_in_edit_mode(self):
"""Test adding an entry when not in edit mode."""
manager = HostsManager()
# edit_mode defaults to False
hosts_file = HostsFile()
test_entry = HostEntry(ip_address="192.168.1.100", hostnames=["testhost"])
success, message = manager.add_entry(hosts_file, test_entry)
assert not success
assert "Not in edit mode" in message
assert len(hosts_file.entries) == 0