diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py index a1230fe..9e49fc5 100644 --- a/src/hosts/core/manager.py +++ b/src/hosts/core/manager.py @@ -296,18 +296,7 @@ class HostsManager: try: # Add the new entry at the end hosts_file.entries.append(entry) - - # 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}" + return True, "Entry added successfully" except Exception as e: return False, f"Error adding entry: {e}" @@ -342,19 +331,15 @@ class HostsManager: # Remove the entry deleted_entry = hosts_file.entries.pop(index) - canonical_hostname = ( - deleted_entry.hostnames[0] - if deleted_entry.hostnames - else deleted_entry.ip_address - ) - + canonical_hostname = deleted_entry.hostnames[0] if deleted_entry.hostnames else deleted_entry.ip_address + # Save the file immediately save_success, save_message = self.save_hosts_file(hosts_file) if not save_success: # If save fails, restore the entry hosts_file.entries.insert(index, deleted_entry) return False, f"Failed to save after deletion: {save_message}" - + return True, f"Entry deleted: {canonical_hostname}" except Exception as e: diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 520dfdb..f2de666 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -7,7 +7,7 @@ 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, Static, DataTable, Input, Checkbox +from textual.widgets import Header, Static, DataTable, Input, Checkbox, Label from textual.reactive import reactive from ..core.parser import HostsParser @@ -91,80 +91,24 @@ class HostsManagerApp(App): # Right pane - entry details or edit form with Vertical(classes="common-pane right-pane") as right_pane: right_pane.border_title = "Entry Details" - - # Details display form (disabled inputs) - with Vertical(id="entry-details-display", classes="entry-form"): - with Vertical( - classes="default-section section-no-top-margin" - ) 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", - ) + yield DataTable( + id="entry-details-table", + show_header=False, + show_cursor=False, + disabled=True, + ) # Edit form (initially hidden) - with Vertical(id="entry-edit-form", classes="entry-form hidden"): - with Vertical( - classes="default-section section-no-top-margin" - ) as ip_address: - ip_address.border_title = "IP Address" - yield Input( - placeholder="Enter IP address", - id="ip-input", - classes="default-input", - ) - - 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" - ) + with Vertical(id="entry-edit-form", classes="hidden"): + yield Label("IP Address:") + yield Input(placeholder="Enter IP address", id="ip-input") + yield Label("Hostnames (comma-separated):") + yield Input(placeholder="Enter hostnames", id="hostname-input") + yield Label("Comment:") + yield Input( + placeholder="Enter comment (optional)", id="comment-input" + ) + yield Checkbox("Active", id="active-checkbox") # Status bar for error/temporary messages (overlay, doesn't affect layout) yield Static("", id="status-bar", classes="status-bar hidden") @@ -206,7 +150,7 @@ class HostsManagerApp(App): # Skip tuple-style bindings and only process Binding objects if not hasattr(binding, "show"): continue - + # Only show bindings marked with show=True if binding.show: # Get the display key diff --git a/src/hosts/tui/custom_footer.py b/src/hosts/tui/custom_footer.py index bbc2d41..a2f46ad 100644 --- a/src/hosts/tui/custom_footer.py +++ b/src/hosts/tui/custom_footer.py @@ -169,7 +169,7 @@ class CustomFooter(Widget): def add_left_item_old(self, item: str) -> None: """Backward compatibility method.""" self.add_left_item_legacy(item) - + def add_right_item_old(self, item: str) -> None: """Backward compatibility method.""" self.add_right_item_legacy(item) diff --git a/src/hosts/tui/details_handler.py b/src/hosts/tui/details_handler.py index b91c37b..c3c4102 100644 --- a/src/hosts/tui/details_handler.py +++ b/src/hosts/tui/details_handler.py @@ -5,7 +5,7 @@ This module handles the display and updating of entry details and edit forms in the right pane. """ -from textual.widgets import Input, Checkbox +from textual.widgets import Input, Checkbox, DataTable class DetailsHandler: @@ -23,41 +23,30 @@ class DetailsHandler: self.update_details_display() def update_details_display(self) -> None: - """Update the details display using disabled Input widgets.""" - details_display = self.app.query_one("#entry-details-display") + """Update the details display using a DataTable with labeled rows.""" + details_table = self.app.query_one("#entry-details-table", DataTable) edit_form = self.app.query_one("#entry-edit-form") - # Show details display, hide edit form - details_display.remove_class("hidden") + # Show details table, hide edit form + details_table.remove_class("hidden") edit_form.add_class("hidden") - # Get the input widgets - ip_input = self.app.query_one("#details-ip-input", Input) - 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) + # Clear existing data + details_table.clear() if not self.app.hosts_file.entries: - # Show empty message - ip_input.value = "" - ip_input.placeholder = "No entries loaded" - hostname_input.value = "" - hostname_input.placeholder = "No entries loaded" - comment_input.value = "" - comment_input.placeholder = "No entries loaded" - active_checkbox.value = False + # Show empty message in a single row + if not details_table.columns: + details_table.add_column("Field", key="field") + details_table.add_row("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: - ip_input.value = "" - ip_input.placeholder = "No visible entries" - hostname_input.value = "" - hostname_input.placeholder = "No visible entries" - comment_input.value = "" - comment_input.placeholder = "No visible entries" - active_checkbox.value = False + if not details_table.columns: + details_table.add_column("Field", key="field") + details_table.add_row("No visible entries") return # 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] - # Update the input widgets with entry data - ip_input.value = entry.ip_address - ip_input.placeholder = "" - hostname_input.value = ", ".join(entry.hostnames) - hostname_input.placeholder = "" - comment_input.value = entry.comment or "" - comment_input.placeholder = "No comment" - active_checkbox.value = entry.is_active + # Add columns for labeled rows (Field, Value) - only if not already present + if not details_table.columns: + details_table.add_column("Field", key="field") + details_table.add_column("Value", key="value") - # 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(): - ip_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" - hostname_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" - comment_input.placeholder = "⚠️ SYSTEM DEFAULT ENTRY - Cannot be modified" + details_table.add_row("", "", key="spacer") + details_table.add_row("⚠️ WARNING", "SYSTEM DEFAULT ENTRY", key="warning") + details_table.add_row("Note", "This entry cannot be modified", key="note") def update_edit_form(self) -> None: """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") - # Hide details display, show edit form - details_display.add_class("hidden") + # Hide details table, show edit form + details_table.add_class("hidden") edit_form.remove_class("hidden") if not self.app.hosts_file.entries or self.app.selected_entry_index >= len( diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 3d37cd9..d455379 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -9,7 +9,7 @@ from textual.binding import Binding # Key bindings for the hosts manager application 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("e", "edit_entry", "Edit entry", show=True, id="left:edit_entry"), Binding( diff --git a/src/hosts/tui/styles.py b/src/hosts/tui/styles.py index c090993..495e8e1 100644 --- a/src/hosts/tui/styles.py +++ b/src/hosts/tui/styles.py @@ -41,10 +41,6 @@ COMMON_CSS = """ .hidden { display: none; } - -.section-no-top-margin { - margin-top: 0 !important; -} """ # CSS styles for the hosts manager application @@ -130,11 +126,39 @@ HOSTS_MANAGER_CSS = ( display: none; } -.entry-form { +#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; +} + +/* 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 { height: 1; } diff --git a/tests/test_main.py b/tests/test_main.py index f3389ed..6dcfc68 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -156,27 +156,16 @@ class TestHostsManagerApp: ): app = HostsManagerApp() - # Mock the query_one method to return disabled input widgets - mock_details_display = Mock() + # Mock the query_one method to return DataTable mock + mock_details_table = Mock() + mock_details_table.columns = [] # Mock empty columns list 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): - if selector == "#entry-details-display": - return mock_details_display + if selector == "#entry-details-table": + return mock_details_table elif selector == "#entry-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() app.query_one = mock_query_one @@ -193,13 +182,12 @@ class TestHostsManagerApp: app.update_entry_details() - # Verify input widgets were updated with entry data - mock_details_display.remove_class.assert_called_with("hidden") + # Verify DataTable operations were called + mock_details_table.remove_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden") - assert mock_ip_input.value == "127.0.0.1" - assert mock_hostname_input.value == "localhost, local" - assert mock_comment_input.value == "Test comment" - assert mock_active_checkbox.value + mock_details_table.clear.assert_called_once() + mock_details_table.add_column.assert_called() + mock_details_table.add_row.assert_called() def test_update_entry_details_no_entries(self): """Test updating entry details with no entries.""" @@ -212,27 +200,16 @@ class TestHostsManagerApp: ): app = HostsManagerApp() - # Mock the query_one method to return disabled input widgets - mock_details_display = Mock() + # Mock the query_one method to return DataTable mock + mock_details_table = Mock() + mock_details_table.columns = [] # Mock empty columns list 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): - if selector == "#entry-details-display": - return mock_details_display + if selector == "#entry-details-table": + return mock_details_table elif selector == "#entry-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() app.query_one = mock_query_one @@ -240,16 +217,12 @@ class TestHostsManagerApp: app.update_entry_details() - # Verify widgets show empty state placeholders - mock_details_display.remove_class.assert_called_with("hidden") + # Verify DataTable operations were called for empty state + mock_details_table.remove_class.assert_called_with("hidden") mock_edit_form.add_class.assert_called_with("hidden") - assert mock_ip_input.value == "" - assert mock_ip_input.placeholder == "No entries loaded" - assert mock_hostname_input.value == "" - 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 + mock_details_table.clear.assert_called_once() + mock_details_table.add_column.assert_called_with("Field", key="field") + mock_details_table.add_row.assert_called_with("No entries loaded") def test_update_status_default(self): """Test status bar update with default information.""" diff --git a/tests/test_manager.py b/tests/test_manager.py index ae5f6c0..c1d0657 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -634,58 +634,3 @@ class TestHostsManager: finally: # Clean up 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