From f7671db43efd19c903ddca1d7e779ae745852116 Mon Sep 17 00:00:00 2001 From: phg Date: Wed, 30 Jul 2025 13:36:25 +0200 Subject: [PATCH 1/5] Add save confirmation modal and integrate with entry editing - Implemented SaveConfirmationModal to prompt users for saving changes when exiting edit mode. - Integrated modal into HostsManagerApp to handle unsaved changes. - Added methods to validate and save entry changes, restoring original values if discarded. - Created unit tests for SaveConfirmationModal and its integration with the main application. - Refactored entry editing logic to track changes and confirm before exiting edit mode. --- memory-bank/activeContext.md | 11 +- memory-bank/progress.md | 16 +- src/hosts/main.py | 559 ++++++++++++++--------- src/hosts/tui/save_confirmation_modal.py | 112 +++++ tests/test_save_confirmation_modal.py | 284 ++++++++++++ 5 files changed, 761 insertions(+), 221 deletions(-) create mode 100644 src/hosts/tui/save_confirmation_modal.py create mode 100644 tests/test_save_confirmation_modal.py diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 35b0636..e85656d 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,10 +2,19 @@ ## Current Work Focus -**Phase 3 Complete - Edit Mode Foundation**: The hosts TUI application now has a complete edit mode foundation with permission management, entry manipulation, and safe file operations. All keyboard shortcuts are implemented and tested. The application is ready for Phase 4 advanced edit features. +**Phase 3 Complete with Save Confirmation Enhancement**: The hosts TUI application now has a complete edit mode foundation with permission management, entry manipulation, safe file operations, and professional save confirmation functionality. All keyboard shortcuts are implemented and tested. The application is ready for Phase 4 advanced edit features. ## Recent Changes +### Phase 3 Save Confirmation Enhancement ✅ COMPLETE +- ✅ **Save confirmation modal**: Professional modal dialog asking to save, discard, or cancel when exiting edit entry mode +- ✅ **Change detection system**: Intelligent tracking of original entry values vs. current form values +- ✅ **No auto-save behavior**: Changes are only saved when explicitly confirmed by the user +- ✅ **Graceful exit handling**: ESC key in edit entry mode now triggers save confirmation instead of auto-exiting +- ✅ **Validation integration**: Full validation before saving with clear error messages for invalid data +- ✅ **Comprehensive testing**: 13 new tests for save confirmation functionality (161 total tests) +- ✅ **Modal keyboard shortcuts**: Save (S), Discard (D), Cancel (ESC) with intuitive button labels + ### Phase 2 Implementation Complete - ✅ **Advanced configuration system**: Complete Config class with JSON persistence to ~/.config/hosts-manager/ - ✅ **Professional configuration modal**: Modal dialog with keyboard bindings for settings management diff --git a/memory-bank/progress.md b/memory-bank/progress.md index a5b38cd..455215d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -64,7 +64,9 @@ - ✅ **Live testing**: Manual testing confirms all functionality works correctly - ✅ **Human-readable formatting**: Tab-based column alignment with proper spacing - ✅ **Management header**: Automatic addition of management header to hosts files -- ✅ **Auto-save functionality**: Immediate saving of changes when entries are edited +- ✅ **Save confirmation modal**: Professional modal dialog for save/discard/cancel when exiting edit entry mode +- ✅ **Change detection**: Intelligent tracking of original vs. current entry values +- ✅ **No auto-save**: Changes saved only when explicitly confirmed by user ### Phase 4: Advanced Edit Features - ❌ **Add new entries**: Create new host entries @@ -89,8 +91,8 @@ ## Current Status ### Development Stage -**Stage**: Phase 3 Complete - Moving to Phase 4 -**Progress**: 75% (Complete edit mode foundation with permission management) +**Stage**: Phase 3 Complete with Save Confirmation Enhancement - Ready for Phase 4 +**Progress**: 78% (Complete edit mode foundation with professional save confirmation) **Next Milestone**: Advanced edit features (add/delete entries, bulk operations) ### Phase 2 Final Achievements @@ -112,7 +114,10 @@ 6. ✅ **Manager module**: Complete HostsManager class for all edit operations 7. ✅ **Safe file operations**: Atomic file writing with rollback capability 8. ✅ **Enhanced error messages**: Professional read-only mode error messages with clear instructions -9. ✅ **Comprehensive testing**: 38 new tests for manager module (135 total tests) +9. ✅ **Comprehensive testing**: 38 new tests for manager module (148 total tests) +10. ✅ **Save confirmation modal**: Professional save/discard/cancel dialog when exiting edit entry mode +11. ✅ **Change detection system**: Intelligent tracking of original vs. current entry values +12. ✅ **No auto-save behavior**: User-controlled saving with explicit confirmation ### Recent Major Accomplishments - ✅ **Complete Phase 3 implementation**: Full edit mode foundation with permission management @@ -121,7 +126,8 @@ - ✅ **Permission system**: Robust sudo request, validation, and release functionality - ✅ **File backup system**: Automatic backup creation with timestamp naming - ✅ **Entry manipulation**: Toggle active/inactive and reorder entries safely -- ✅ **Comprehensive testing**: 135 total tests with 100% pass rate including edit operations +- ✅ **Save confirmation enhancement**: Professional modal dialog system for editing workflow +- ✅ **Comprehensive testing**: 161 total tests with 100% pass rate including save confirmation - ✅ **Error handling**: Graceful handling of permission errors and file operations - ✅ **Keyboard shortcuts**: Complete set of edit mode bindings (e, space, Ctrl+Up/Down, Ctrl+S) diff --git a/src/hosts/main.py b/src/hosts/main.py index d2f3856..8754a72 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -18,16 +18,17 @@ from .core.models import HostsFile from .core.config import Config from .core.manager import HostsManager from .tui.config_modal import ConfigModal +from .tui.save_confirmation_modal import SaveConfirmationModal 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-container { height: 1fr; @@ -115,7 +116,7 @@ class HostsManagerApp(App): margin-bottom: 1; } """ - + BINDINGS = [ Binding("q", "quit", "Quit"), Binding("r", "reload", "Reload"), @@ -134,7 +135,7 @@ class HostsManagerApp(App): Binding("shift+tab", "prev_field", "Prev Field", show=False), ("ctrl+c", "quit", "Quit"), ] - + # Reactive attributes hosts_file: reactive[HostsFile] = reactive(HostsFile()) selected_entry_index: reactive[int] = reactive(0) @@ -142,7 +143,7 @@ class HostsManagerApp(App): 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.parser = HostsParser() @@ -150,11 +151,14 @@ class HostsManagerApp(App): self.manager = HostsManager() self.title = "Hosts Manager" self.sub_title = "Read-only mode" - + + # Track original entry values for change detection + self.original_entry_values = None + def compose(self) -> ComposeResult: """Create the application layout.""" yield Header() - + with Vertical(): with Horizontal(classes="hosts-container"): left_pane = Vertical(classes="left-pane") @@ -162,7 +166,7 @@ class HostsManagerApp(App): with left_pane: yield DataTable(id="entries-table") yield left_pane - + right_pane = Vertical(classes="right-pane") right_pane.border_title = "Entry Details" with right_pane: @@ -173,34 +177,38 @@ class HostsManagerApp(App): yield Label("Hostname:") yield Input(id="hostname-input", placeholder="Enter hostname") yield Label("Comment:") - yield Input(id="comment-input", placeholder="Enter comment (optional)") + yield Input( + id="comment-input", placeholder="Enter comment (optional)" + ) yield Label("Active:") yield Checkbox(id="active-checkbox", value=True) yield right_pane - + yield Static("", classes="status-bar", id="status") - + yield Footer() - + def on_ready(self) -> None: """Initialize the application when ready.""" self.load_hosts_file() self.update_status() - + def load_hosts_file(self) -> None: """Load the hosts file and populate the interface.""" # Remember current selection for restoration current_entry = None - if self.hosts_file.entries and self.selected_entry_index < len(self.hosts_file.entries): + if self.hosts_file.entries and self.selected_entry_index < len( + self.hosts_file.entries + ): current_entry = self.hosts_file.entries[self.selected_entry_index] - + try: self.hosts_file = self.parser.parse() self.populate_entries_table() - + # Restore cursor position with a timer to ensure ListView is fully rendered self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry)) - + self.update_entry_details() self.log(f"Loaded {len(self.hosts_file.entries)} entries from hosts file") except FileNotFoundError: @@ -212,80 +220,84 @@ class HostsManagerApp(App): except Exception as e: self.log(f"Error loading hosts file: {e}") self.update_status(f"Error: {e}") - + def get_visible_entries(self) -> list: """Get the list of entries that are visible in the table (after filtering).""" show_defaults = self.config.should_show_default_entries() visible_entries = [] - + for entry in self.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.config.is_default_entry(entry.ip_address, canonical_hostname): + if not show_defaults and self.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.config.should_show_default_entries() - + for i, entry in enumerate(self.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.config.is_default_entry(entry.ip_address, canonical_hostname): + if not show_defaults and self.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.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.hosts_file.entries): return 0 - + target_entry = self.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.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.sort_column == "ip": arrow = "↑" if self.sort_ascending else "↓" @@ -293,21 +305,21 @@ class HostsManagerApp(App): elif self.sort_column == "hostname": arrow = "↑" if self.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 @@ -327,28 +339,30 @@ class HostsManagerApp(App): 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.hosts_file.entries: self.selected_entry_index = 0 return - + if previous_entry is None: # No previous selection, start at first visible entry self.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.hosts_file.entries): - if (entry.ip_address == previous_entry.ip_address and - entry.hostnames == previous_entry.hostnames and - entry.comment == previous_entry.comment): + if ( + entry.ip_address == previous_entry.ip_address + and entry.hostnames == previous_entry.hostnames + and entry.comment == previous_entry.comment + ): self.selected_entry_index = i break else: # Entry not found, default to first visible entry self.selected_entry_index = self.get_first_visible_entry_index() - + # Update the DataTable cursor position using display index table = self.query_one("#entries-table", DataTable) display_index = self.actual_index_to_display_index(self.selected_entry_index) @@ -358,40 +372,44 @@ class HostsManagerApp(App): table.focus() # Update the details pane to match the selection self.update_entry_details() - + def update_entry_details(self) -> None: """Update the right pane with selected entry details.""" if self.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.query_one("#entry-details", Static) edit_form = self.query_one("#entry-edit-form") - + # Show details, hide edit form details_widget.remove_class("hidden") edit_form.add_class("hidden") - + if not self.hosts_file.entries: details_widget.update("No entries loaded") return - + # Get visible entries to check if we need to adjust selection visible_entries = self.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.config.should_show_default_entries() if not show_defaults: # Check if the currently selected entry is a default entry (hidden) - if (self.selected_entry_index < len(self.hosts_file.entries) and - self.hosts_file.entries[self.selected_entry_index].is_default_entry()): + if ( + self.selected_entry_index < len(self.hosts_file.entries) + and self.hosts_file.entries[ + self.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 @@ -399,61 +417,65 @@ class HostsManagerApp(App): if not entry.is_default_entry(): self.selected_entry_index = i break - + if self.selected_entry_index >= len(self.hosts_file.entries): self.selected_entry_index = 0 - + entry = self.hosts_file.entries[self.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.") - + 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.query_one("#entry-details", Static) edit_form = self.query_one("#entry-edit-form") - + # Hide details, show edit form details_widget.add_class("hidden") edit_form.remove_class("hidden") - - if not self.hosts_file.entries or self.selected_entry_index >= len(self.hosts_file.entries): + + if not self.hosts_file.entries or self.selected_entry_index >= len( + self.hosts_file.entries + ): return - + entry = self.hosts_file.entries[self.selected_entry_index] - + # Update form fields with current entry values ip_input = self.query_one("#ip-input", Input) hostname_input = self.query_one("#hostname-input", Input) comment_input = self.query_one("#comment-input", Input) active_checkbox = self.query_one("#active-checkbox", Checkbox) - + ip_input.value = entry.ip_address - hostname_input.value = ', '.join(entry.hostnames) + hostname_input.value = ", ".join(entry.hostnames) comment_input.value = entry.comment or "" active_checkbox.value = entry.is_active - + def update_status(self, message: str = "") -> None: """Update the status bar.""" status_widget = self.query_one("#status", Static) - + if message: # Check if this is an error message (starts with ❌) if message.startswith("❌"): @@ -474,34 +496,38 @@ class HostsManagerApp(App): # Reset to normal status display status_widget.remove_class("status-error") status_widget.add_class("status-bar") - + mode = "Edit mode" if self.edit_mode else "Read-only mode" entry_count = len(self.hosts_file.entries) active_count = len(self.hosts_file.get_active_entries()) - + status_text = f"{mode} | {entry_count} entries ({active_count} active)" - + # Add file info file_info = self.parser.get_file_info() - if file_info['exists']: + if file_info["exists"]: status_text += f" | {file_info['path']}" - + status_widget.update(status_text) - + 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.display_index_to_actual_index(event.cursor_row) + self.selected_entry_index = self.display_index_to_actual_index( + event.cursor_row + ) self.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.display_index_to_actual_index(event.cursor_row) + self.selected_entry_index = self.display_index_to_actual_index( + event.cursor_row + ) self.update_entry_details() - + def action_reload(self) -> None: """Reload the hosts file.""" # Reset sort state on reload @@ -509,22 +535,25 @@ class HostsManagerApp(App): 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") - + 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.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.""" # Toggle sort direction if already sorting by IP @@ -533,14 +562,14 @@ class HostsManagerApp(App): else: self.sort_column = "ip" self.sort_ascending = True - + # Sort the entries using the new method that keeps defaults on top self.hosts_file.sort_by_ip(self.sort_ascending) self.populate_entries_table() - + 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.""" # Toggle sort direction if already sorting by hostname @@ -549,14 +578,14 @@ class HostsManagerApp(App): else: self.sort_column = "hostname" self.sort_ascending = True - + # Sort the entries using the new method that keeps defaults on top self.hosts_file.sort_by_hostname(self.sort_ascending) self.populate_entries_table() - + direction = "ascending" if self.sort_ascending else "descending" self.update_status(f"Sorted by hostname ({direction})") - + def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None: """Handle column header clicks for sorting.""" if event.data_table.id == "entries-table": @@ -565,7 +594,7 @@ class HostsManagerApp(App): self.action_sort_by_ip() elif "Canonical Hostname" in str(event.column_key): self.action_sort_by_hostname() - + def action_toggle_edit_mode(self) -> None: """Toggle between read-only and edit mode.""" if self.edit_mode: @@ -586,87 +615,228 @@ class HostsManagerApp(App): 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.") + 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.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 has_entry_changes(self) -> bool: + """Check if the current entry has been modified from its original values.""" + if not self.original_entry_values or not self.entry_edit_mode: + return False + + # Get current values from form fields + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.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.original_entry_values["ip_address"] + or current_hostnames != self.original_entry_values["hostnames"] + or current_comment != self.original_entry_values["comment"] + or active_checkbox.value != self.original_entry_values["is_active"] + ) + def action_exit_edit_entry(self) -> None: """Exit entry edit mode and return focus to the entries table.""" - if self.entry_edit_mode: - self.entry_edit_mode = False - self.update_entry_details() - - # Return focus to the entries table + if not self.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.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.entry_edit_mode = False + self.original_entry_values = None + self.update_entry_details() + + # Return focus to the entries table + table = self.query_one("#entries-table", DataTable) + table.focus() + + self.update_status("Exited entry edit mode") + + def restore_original_entry_values(self) -> None: + """Restore the original values to the form fields.""" + if not self.original_entry_values: + return + + # Update form fields with original values + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + ip_input.value = self.original_entry_values["ip_address"] + hostname_input.value = ", ".join(self.original_entry_values["hostnames"]) + comment_input.value = self.original_entry_values["comment"] or "" + active_checkbox.value = self.original_entry_values["is_active"] + + def validate_and_save_entry_changes(self) -> bool: + """Validate current entry values and save if valid.""" + if not self.hosts_file.entries or self.selected_entry_index >= len( + self.hosts_file.entries + ): + return False + + entry = self.hosts_file.entries[self.selected_entry_index] + + # Get values from form fields + ip_input = self.query_one("#ip-input", Input) + hostname_input = self.query_one("#hostname-input", Input) + comment_input = self.query_one("#comment-input", Input) + active_checkbox = self.query_one("#active-checkbox", Checkbox) + + # Validate IP address + try: + ipaddress.ip_address(ip_input.value.strip()) + except ValueError: + self.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.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.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.manager.save_hosts_file(self.hosts_file) + if success: + # Update the table display + self.populate_entries_table() + # Restore cursor position table = self.query_one("#entries-table", DataTable) - table.focus() - - self.update_status("Exited entry edit mode") - + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) + if table.row_count > 0 and display_index < table.row_count: + table.move_cursor(row=display_index) + self.update_status("Entry saved successfully") + return True + else: + self.update_status(f"❌ Error saving entry: {message}") + return False + def action_next_field(self) -> None: """Move to the next field in edit mode.""" if not self.entry_edit_mode: return - + # Get all input fields in order fields = [ self.query_one("#ip-input", Input), self.query_one("#hostname-input", Input), self.query_one("#comment-input", Input), - self.query_one("#active-checkbox", Checkbox) + self.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 action_prev_field(self) -> None: """Move to the previous field in edit mode.""" if not self.entry_edit_mode: return - + # Get all input fields in order fields = [ self.query_one("#ip-input", Input), self.query_one("#hostname-input", Input), self.query_one("#comment-input", Input), - self.query_one("#active-checkbox", Checkbox) + self.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 on_key(self, event) -> None: """Handle key events to override default tab behavior in edit mode.""" if self.entry_edit_mode and event.key == "tab": @@ -675,94 +845,39 @@ class HostsManagerApp(App): self.action_next_field() elif self.entry_edit_mode and event.key == "shift+tab": # Prevent default shift+tab behavior and use our custom navigation - event.prevent_default() + event.prevent_default() self.action_prev_field() - + def on_input_changed(self, event: Input.Changed) -> None: - """Handle input field changes and auto-save.""" - if not self.entry_edit_mode or not self.edit_mode: - return - - if event.input.id in ["ip-input", "hostname-input", "comment-input"]: - self.save_entry_changes() - + """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 and auto-save.""" - if not self.entry_edit_mode or not self.edit_mode: - return - - if event.checkbox.id == "active-checkbox": - self.save_entry_changes() - - def save_entry_changes(self) -> None: - """Save the current entry changes.""" - if not self.hosts_file.entries or self.selected_entry_index >= len(self.hosts_file.entries): - return - - entry = self.hosts_file.entries[self.selected_entry_index] - - # Get values from form fields - ip_input = self.query_one("#ip-input", Input) - hostname_input = self.query_one("#hostname-input", Input) - comment_input = self.query_one("#comment-input", Input) - active_checkbox = self.query_one("#active-checkbox", Checkbox) - - # Validate IP address - try: - ipaddress.ip_address(ip_input.value.strip()) - except ValueError: - self.update_status("❌ Invalid IP address") - return - - # Validate hostname(s) - hostnames = [h.strip() for h in hostname_input.value.split(',') if h.strip()] - if not hostnames: - self.update_status("❌ At least one hostname is required") - return - - 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.update_status(f"❌ Invalid hostname: {hostname}") - return - - # 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.manager.save_hosts_file(self.hosts_file) - if success: - # Update the table display - self.populate_entries_table() - # Restore cursor position - table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index(self.selected_entry_index) - if table.row_count > 0 and display_index < table.row_count: - table.move_cursor(row=display_index) - self.update_status("Entry saved successfully") - else: - self.update_status(f"❌ Error saving entry: {message}") - + """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 + def action_toggle_entry(self) -> None: """Toggle the active state of the selected entry.""" if not self.edit_mode: - self.update_status("❌ Cannot toggle entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.") + self.update_status( + "❌ Cannot toggle 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 toggle") return - + # Remember current entry for cursor position restoration current_entry = self.hosts_file.entries[self.selected_entry_index] - - success, message = self.manager.toggle_entry(self.hosts_file, self.selected_entry_index) + + success, message = self.manager.toggle_entry( + self.hosts_file, self.selected_entry_index + ) if success: # Auto-save the changes immediately save_success, save_message = self.manager.save_hosts_file(self.hosts_file) @@ -776,18 +891,22 @@ class HostsManagerApp(App): self.update_status(f"Entry toggled but save failed: {save_message}") else: self.update_status(f"Error toggling entry: {message}") - + def action_move_entry_up(self) -> None: """Move the selected entry up in the list.""" if not self.edit_mode: - self.update_status("❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.") + self.update_status( + "❌ Cannot move 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 move") return - - success, message = self.manager.move_entry_up(self.hosts_file, self.selected_entry_index) + + success, message = self.manager.move_entry_up( + self.hosts_file, self.selected_entry_index + ) if success: # Auto-save the changes immediately save_success, save_message = self.manager.save_hosts_file(self.hosts_file) @@ -798,7 +917,9 @@ class HostsManagerApp(App): self.populate_entries_table() # Update the DataTable cursor position to follow the moved entry table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index(self.selected_entry_index) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) if table.row_count > 0 and display_index < table.row_count: table.move_cursor(row=display_index) self.update_entry_details() @@ -807,18 +928,22 @@ class HostsManagerApp(App): self.update_status(f"Entry moved but save failed: {save_message}") else: self.update_status(f"Error moving entry: {message}") - + def action_move_entry_down(self) -> None: """Move the selected entry down in the list.""" if not self.edit_mode: - self.update_status("❌ Cannot move entry: Application is in read-only mode. Press 'Ctrl+E' to enable edit mode.") + self.update_status( + "❌ Cannot move 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 move") return - - success, message = self.manager.move_entry_down(self.hosts_file, self.selected_entry_index) + + success, message = self.manager.move_entry_down( + self.hosts_file, self.selected_entry_index + ) if success: # Auto-save the changes immediately save_success, save_message = self.manager.save_hosts_file(self.hosts_file) @@ -829,7 +954,9 @@ class HostsManagerApp(App): self.populate_entries_table() # Update the DataTable cursor position to follow the moved entry table = self.query_one("#entries-table", DataTable) - display_index = self.actual_index_to_display_index(self.selected_entry_index) + display_index = self.actual_index_to_display_index( + self.selected_entry_index + ) if table.row_count > 0 and display_index < table.row_count: table.move_cursor(row=display_index) self.update_entry_details() @@ -838,25 +965,27 @@ class HostsManagerApp(App): self.update_status(f"Entry moved but save failed: {save_message}") else: self.update_status(f"Error moving entry: {message}") - + def action_save_file(self) -> None: """Save the hosts file to disk.""" if not self.edit_mode: - self.update_status("❌ Cannot save: Application is in read-only mode. No changes to save.") + self.update_status( + "❌ Cannot save: Application is in read-only mode. No changes to save." + ) return - + success, message = self.manager.save_hosts_file(self.hosts_file) if success: self.update_status(message) else: self.update_status(f"Error saving file: {message}") - + def action_quit(self) -> None: """Quit the application.""" # If in entry edit mode, exit it first if self.entry_edit_mode: self.action_exit_edit_entry() - + # If in edit mode, exit it first if self.edit_mode: self.manager.exit_edit_mode() diff --git a/src/hosts/tui/save_confirmation_modal.py b/src/hosts/tui/save_confirmation_modal.py new file mode 100644 index 0000000..8de6e48 --- /dev/null +++ b/src/hosts/tui/save_confirmation_modal.py @@ -0,0 +1,112 @@ +""" +Save confirmation modal for the hosts TUI application. + +This module provides a modal dialog to confirm saving changes when exiting edit entry mode. +""" + +from textual.app import ComposeResult +from textual.containers import Vertical, Horizontal +from textual.widgets import Static, Button, Label +from textual.screen import ModalScreen +from textual.binding import Binding + + +class SaveConfirmationModal(ModalScreen): + """ + Modal screen for save confirmation when exiting edit entry mode. + + Provides a confirmation dialog asking whether to save or discard changes. + """ + + CSS = """ + SaveConfirmationModal { + align: center middle; + } + + .save-confirmation-container { + width: 60; + height: 12; + background: $surface; + border: thick $primary; + padding: 1; + } + + .save-confirmation-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; + } + + .save-confirmation-message { + text-align: center; + margin-bottom: 2; + color: $text; + } + + .button-row { + align: center middle; + } + + .save-confirmation-button { + margin: 0 1; + min-width: 12; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("enter", "save", "Save"), + Binding("s", "save", "Save"), + Binding("d", "discard", "Discard"), + ] + + def compose(self) -> ComposeResult: + """Create the save confirmation modal layout.""" + with Vertical(classes="save-confirmation-container"): + yield Static("Save Changes?", classes="save-confirmation-title") + yield Label( + "You have made changes to this entry.\nDo you want to save or discard them?", + classes="save-confirmation-message", + ) + + with Horizontal(classes="button-row"): + yield Button( + "Save (S)", + variant="primary", + id="save-button", + classes="save-confirmation-button", + ) + yield Button( + "Discard (D)", + variant="default", + id="discard-button", + classes="save-confirmation-button", + ) + yield Button( + "Cancel (ESC)", + variant="default", + id="cancel-button", + classes="save-confirmation-button", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "save-button": + self.action_save() + elif event.button.id == "discard-button": + self.action_discard() + elif event.button.id == "cancel-button": + self.action_cancel() + + def action_save(self) -> None: + """Save changes and close modal.""" + self.dismiss("save") + + def action_discard(self) -> None: + """Discard changes and close modal.""" + self.dismiss("discard") + + def action_cancel(self) -> None: + """Cancel operation and close modal.""" + self.dismiss("cancel") diff --git a/tests/test_save_confirmation_modal.py b/tests/test_save_confirmation_modal.py new file mode 100644 index 0000000..0cff6a5 --- /dev/null +++ b/tests/test_save_confirmation_modal.py @@ -0,0 +1,284 @@ +""" +Tests for the save confirmation modal. + +This module tests the save confirmation functionality when exiting edit entry mode. +""" + +import pytest +from unittest.mock import Mock, patch +from textual.widgets import Input, Checkbox + +from hosts.main import HostsManagerApp +from hosts.core.models import HostsFile, HostEntry +from hosts.tui.save_confirmation_modal import SaveConfirmationModal + + +class TestSaveConfirmationModal: + """Test cases for the SaveConfirmationModal class.""" + + def test_modal_creation(self): + """Test that the modal can be created.""" + modal = SaveConfirmationModal() + assert modal is not None + + def test_modal_compose(self): + """Test that the modal composes correctly.""" + # Note: Cannot test compose() directly without app context + # This is a basic existence check for the modal + modal = SaveConfirmationModal() + assert hasattr(modal, "compose") + assert callable(modal.compose) + + def test_action_save(self): + """Test save action dismisses with 'save'.""" + modal = SaveConfirmationModal() + modal.dismiss = Mock() + + modal.action_save() + + modal.dismiss.assert_called_once_with("save") + + def test_action_discard(self): + """Test discard action dismisses with 'discard'.""" + modal = SaveConfirmationModal() + modal.dismiss = Mock() + + modal.action_discard() + + modal.dismiss.assert_called_once_with("discard") + + def test_action_cancel(self): + """Test cancel action dismisses with 'cancel'.""" + modal = SaveConfirmationModal() + modal.dismiss = Mock() + + modal.action_cancel() + + modal.dismiss.assert_called_once_with("cancel") + + +class TestSaveConfirmationIntegration: + """Test cases for save confirmation integration with the main app.""" + + @pytest.fixture + def app(self): + """Create a test app instance.""" + return HostsManagerApp() + + def test_has_entry_changes_no_original_values(self, app): + """Test has_entry_changes returns False when no original values stored.""" + app.original_entry_values = None + app.entry_edit_mode = True + + assert not app.has_entry_changes() + + def test_has_entry_changes_not_in_edit_mode(self, app): + """Test has_entry_changes returns False when not in edit mode.""" + app.original_entry_values = { + "ip_address": "127.0.0.1", + "hostnames": ["localhost"], + "comment": None, + "is_active": True, + } + app.entry_edit_mode = False + + assert not app.has_entry_changes() + + @patch.object(HostsManagerApp, "query_one") + def test_has_entry_changes_no_changes(self, mock_query_one, app): + """Test has_entry_changes returns False when no changes made.""" + # Setup original values + app.original_entry_values = { + "ip_address": "127.0.0.1", + "hostnames": ["localhost"], + "comment": None, + "is_active": True, + } + app.entry_edit_mode = True + + # Mock form fields with original values + mock_ip_input = Mock() + mock_ip_input.value = "127.0.0.1" + mock_hostname_input = Mock() + mock_hostname_input.value = "localhost" + mock_comment_input = Mock() + mock_comment_input.value = "" + mock_checkbox = Mock() + mock_checkbox.value = True + + def mock_query_side_effect(selector, widget_type=None): + if selector == "#ip-input": + return mock_ip_input + elif selector == "#hostname-input": + return mock_hostname_input + elif selector == "#comment-input": + return mock_comment_input + elif selector == "#active-checkbox": + return mock_checkbox + + mock_query_one.side_effect = mock_query_side_effect + + assert not app.has_entry_changes() + + @patch.object(HostsManagerApp, "query_one") + def test_has_entry_changes_ip_changed(self, mock_query_one, app): + """Test has_entry_changes returns True when IP address changed.""" + # Setup original values + app.original_entry_values = { + "ip_address": "127.0.0.1", + "hostnames": ["localhost"], + "comment": None, + "is_active": True, + } + app.entry_edit_mode = True + + # Mock form fields with changed IP + mock_ip_input = Mock() + mock_ip_input.value = "192.168.1.1" # Changed IP + mock_hostname_input = Mock() + mock_hostname_input.value = "localhost" + mock_comment_input = Mock() + mock_comment_input.value = "" + mock_checkbox = Mock() + mock_checkbox.value = True + + def mock_query_side_effect(selector, widget_type=None): + if selector == "#ip-input": + return mock_ip_input + elif selector == "#hostname-input": + return mock_hostname_input + elif selector == "#comment-input": + return mock_comment_input + elif selector == "#active-checkbox": + return mock_checkbox + + mock_query_one.side_effect = mock_query_side_effect + + assert app.has_entry_changes() + + @patch.object(HostsManagerApp, "query_one") + def test_has_entry_changes_hostname_changed(self, mock_query_one, app): + """Test has_entry_changes returns True when hostname changed.""" + # Setup original values + app.original_entry_values = { + "ip_address": "127.0.0.1", + "hostnames": ["localhost"], + "comment": None, + "is_active": True, + } + app.entry_edit_mode = True + + # Mock form fields with changed hostname + mock_ip_input = Mock() + mock_ip_input.value = "127.0.0.1" + mock_hostname_input = Mock() + mock_hostname_input.value = "localhost, test.local" # Added hostname + mock_comment_input = Mock() + mock_comment_input.value = "" + mock_checkbox = Mock() + mock_checkbox.value = True + + def mock_query_side_effect(selector, widget_type=None): + if selector == "#ip-input": + return mock_ip_input + elif selector == "#hostname-input": + return mock_hostname_input + elif selector == "#comment-input": + return mock_comment_input + elif selector == "#active-checkbox": + return mock_checkbox + + mock_query_one.side_effect = mock_query_side_effect + + assert app.has_entry_changes() + + @patch.object(HostsManagerApp, "query_one") + def test_has_entry_changes_comment_added(self, mock_query_one, app): + """Test has_entry_changes returns True when comment added.""" + # Setup original values + app.original_entry_values = { + "ip_address": "127.0.0.1", + "hostnames": ["localhost"], + "comment": None, + "is_active": True, + } + app.entry_edit_mode = True + + # Mock form fields with added comment + mock_ip_input = Mock() + mock_ip_input.value = "127.0.0.1" + mock_hostname_input = Mock() + mock_hostname_input.value = "localhost" + mock_comment_input = Mock() + mock_comment_input.value = "Test comment" # Added comment + mock_checkbox = Mock() + mock_checkbox.value = True + + def mock_query_side_effect(selector, widget_type=None): + if selector == "#ip-input": + return mock_ip_input + elif selector == "#hostname-input": + return mock_hostname_input + elif selector == "#comment-input": + return mock_comment_input + elif selector == "#active-checkbox": + return mock_checkbox + + mock_query_one.side_effect = mock_query_side_effect + + assert app.has_entry_changes() + + @patch.object(HostsManagerApp, "query_one") + def test_has_entry_changes_active_state_changed(self, mock_query_one, app): + """Test has_entry_changes returns True when active state changed.""" + # Setup original values + app.original_entry_values = { + "ip_address": "127.0.0.1", + "hostnames": ["localhost"], + "comment": None, + "is_active": True, + } + app.entry_edit_mode = True + + # Mock form fields with changed active state + mock_ip_input = Mock() + mock_ip_input.value = "127.0.0.1" + mock_hostname_input = Mock() + mock_hostname_input.value = "localhost" + mock_comment_input = Mock() + mock_comment_input.value = "" + mock_checkbox = Mock() + mock_checkbox.value = False # Changed active state + + def mock_query_side_effect(selector, widget_type=None): + if selector == "#ip-input": + return mock_ip_input + elif selector == "#hostname-input": + return mock_hostname_input + elif selector == "#comment-input": + return mock_comment_input + elif selector == "#active-checkbox": + return mock_checkbox + + mock_query_one.side_effect = mock_query_side_effect + + assert app.has_entry_changes() + + def test_exit_edit_entry_mode(self, app): + """Test exit_edit_entry_mode cleans up properly.""" + app.entry_edit_mode = True + app.original_entry_values = {"test": "data"} + app.update_entry_details = Mock() + app.query_one = Mock() + app.update_status = Mock() + + mock_table = Mock() + app.query_one.return_value = mock_table + + app.exit_edit_entry_mode() + + assert not app.entry_edit_mode + assert app.original_entry_values is None + app.update_entry_details.assert_called_once() + mock_table.focus.assert_called_once() + app.update_status.assert_called_once_with("Exited entry edit mode") From 77d8e647f2e3261b4782892e9868b6f1753b193e Mon Sep 17 00:00:00 2001 From: phg Date: Wed, 30 Jul 2025 14:38:39 +0200 Subject: [PATCH 2/5] Enhance save confirmation modal: adjust button focus behavior and update tests for new functionality --- src/hosts/tui/save_confirmation_modal.py | 60 +++++++++++++++++++++++- tests/test_save_confirmation_modal.py | 25 +++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/hosts/tui/save_confirmation_modal.py b/src/hosts/tui/save_confirmation_modal.py index 8de6e48..1aa38fd 100644 --- a/src/hosts/tui/save_confirmation_modal.py +++ b/src/hosts/tui/save_confirmation_modal.py @@ -25,7 +25,7 @@ class SaveConfirmationModal(ModalScreen): .save-confirmation-container { width: 60; - height: 12; + height: 15; background: $surface; border: thick $primary; padding: 1; @@ -52,6 +52,10 @@ class SaveConfirmationModal(ModalScreen): margin: 0 1; min-width: 12; } + + .save-confirmation-button:focus { + border: thick $accent; + } """ BINDINGS = [ @@ -90,6 +94,60 @@ class SaveConfirmationModal(ModalScreen): classes="save-confirmation-button", ) + def on_mount(self) -> None: + """Called when the modal is mounted. Set focus and ensure modal captures input.""" + # Set focus to the modal screen itself first + self.focus() + # Then focus on the Save button + self.call_after_refresh(self._focus_save_button) + + def _focus_save_button(self) -> None: + """Focus the save button after refresh.""" + save_button = self.query_one("#save-button", Button) + save_button.focus() + + def on_key(self, event) -> None: + """Handle key events, ensuring tab navigation works within the modal.""" + if event.key == "tab": + # Get all buttons in order + buttons = [ + self.query_one("#save-button", Button), + self.query_one("#discard-button", Button), + self.query_one("#cancel-button", Button), + ] + + # Find currently focused button and move to next + for i, button in enumerate(buttons): + if button.has_focus: + next_button = buttons[(i + 1) % len(buttons)] + next_button.focus() + event.prevent_default() + return + + # If no button has focus, focus the first one + buttons[0].focus() + event.prevent_default() + + elif event.key == "shift+tab": + # Get all buttons in order + buttons = [ + self.query_one("#save-button", Button), + self.query_one("#discard-button", Button), + self.query_one("#cancel-button", Button), + ] + + # Find currently focused button and move to previous + for i, button in enumerate(buttons): + if button.has_focus: + prev_button = buttons[(i - 1) % len(buttons)] + prev_button.focus() + event.prevent_default() + return + + # If no button has focus, focus the last one + buttons[-1].focus() + event.prevent_default() + def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "save-button": diff --git a/tests/test_save_confirmation_modal.py b/tests/test_save_confirmation_modal.py index 0cff6a5..d13ed41 100644 --- a/tests/test_save_confirmation_modal.py +++ b/tests/test_save_confirmation_modal.py @@ -6,7 +6,7 @@ This module tests the save confirmation functionality when exiting edit entry mo import pytest from unittest.mock import Mock, patch -from textual.widgets import Input, Checkbox +from textual.widgets import Input, Checkbox, Button from hosts.main import HostsManagerApp from hosts.core.models import HostsFile, HostEntry @@ -56,6 +56,29 @@ class TestSaveConfirmationModal: modal.dismiss.assert_called_once_with("cancel") + @patch.object(SaveConfirmationModal, "call_after_refresh") + @patch.object(SaveConfirmationModal, "focus") + def test_on_mount_sets_focus(self, mock_focus, mock_call_after_refresh): + """Test that on_mount sets focus to the modal and schedules button focus.""" + modal = SaveConfirmationModal() + + modal.on_mount() + + mock_focus.assert_called_once() + mock_call_after_refresh.assert_called_once() + + @patch.object(SaveConfirmationModal, "query_one") + def test_focus_save_button(self, mock_query_one): + """Test that _focus_save_button focuses the save button.""" + modal = SaveConfirmationModal() + mock_save_button = Mock() + mock_query_one.return_value = mock_save_button + + modal._focus_save_button() + + mock_query_one.assert_called_once_with("#save-button", Button) + mock_save_button.focus.assert_called_once() + class TestSaveConfirmationIntegration: """Test cases for save confirmation integration with the main app.""" From 1dd54b0cb5326c977a6974be4f8b84ce1afeb970 Mon Sep 17 00:00:00 2001 From: phg Date: Wed, 30 Jul 2025 14:58:43 +0200 Subject: [PATCH 3/5] Refine modal focus handling: update tab navigation logic and simplify focus setting in SaveConfirmationModal tests --- src/hosts/main.py | 9 +++- src/hosts/tui/save_confirmation_modal.py | 52 +----------------------- tests/test_save_confirmation_modal.py | 17 ++------ 3 files changed, 12 insertions(+), 66 deletions(-) diff --git a/src/hosts/main.py b/src/hosts/main.py index 8754a72..dac9024 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -839,11 +839,16 @@ class HostsManagerApp(App): def on_key(self, event) -> None: """Handle key events to override default tab behavior in edit mode.""" - if self.entry_edit_mode and event.key == "tab": + # Only handle custom tab navigation if in entry edit mode AND no modal is open + if self.entry_edit_mode and len(self.screen_stack) == 1 and event.key == "tab": # Prevent default tab behavior and use our custom navigation event.prevent_default() self.action_next_field() - elif self.entry_edit_mode and event.key == "shift+tab": + elif ( + self.entry_edit_mode + and len(self.screen_stack) == 1 + and event.key == "shift+tab" + ): # Prevent default shift+tab behavior and use our custom navigation event.prevent_default() self.action_prev_field() diff --git a/src/hosts/tui/save_confirmation_modal.py b/src/hosts/tui/save_confirmation_modal.py index 1aa38fd..a49f8f1 100644 --- a/src/hosts/tui/save_confirmation_modal.py +++ b/src/hosts/tui/save_confirmation_modal.py @@ -95,59 +95,11 @@ class SaveConfirmationModal(ModalScreen): ) def on_mount(self) -> None: - """Called when the modal is mounted. Set focus and ensure modal captures input.""" - # Set focus to the modal screen itself first - self.focus() - # Then focus on the Save button - self.call_after_refresh(self._focus_save_button) - - def _focus_save_button(self) -> None: - """Focus the save button after refresh.""" + """Called when the modal is mounted. Set focus to the first button.""" + # Focus on the Save button by default save_button = self.query_one("#save-button", Button) save_button.focus() - def on_key(self, event) -> None: - """Handle key events, ensuring tab navigation works within the modal.""" - if event.key == "tab": - # Get all buttons in order - buttons = [ - self.query_one("#save-button", Button), - self.query_one("#discard-button", Button), - self.query_one("#cancel-button", Button), - ] - - # Find currently focused button and move to next - for i, button in enumerate(buttons): - if button.has_focus: - next_button = buttons[(i + 1) % len(buttons)] - next_button.focus() - event.prevent_default() - return - - # If no button has focus, focus the first one - buttons[0].focus() - event.prevent_default() - - elif event.key == "shift+tab": - # Get all buttons in order - buttons = [ - self.query_one("#save-button", Button), - self.query_one("#discard-button", Button), - self.query_one("#cancel-button", Button), - ] - - # Find currently focused button and move to previous - for i, button in enumerate(buttons): - if button.has_focus: - prev_button = buttons[(i - 1) % len(buttons)] - prev_button.focus() - event.prevent_default() - return - - # If no button has focus, focus the last one - buttons[-1].focus() - event.prevent_default() - def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "save-button": diff --git a/tests/test_save_confirmation_modal.py b/tests/test_save_confirmation_modal.py index d13ed41..c6c268a 100644 --- a/tests/test_save_confirmation_modal.py +++ b/tests/test_save_confirmation_modal.py @@ -56,25 +56,14 @@ class TestSaveConfirmationModal: modal.dismiss.assert_called_once_with("cancel") - @patch.object(SaveConfirmationModal, "call_after_refresh") - @patch.object(SaveConfirmationModal, "focus") - def test_on_mount_sets_focus(self, mock_focus, mock_call_after_refresh): - """Test that on_mount sets focus to the modal and schedules button focus.""" - modal = SaveConfirmationModal() - - modal.on_mount() - - mock_focus.assert_called_once() - mock_call_after_refresh.assert_called_once() - @patch.object(SaveConfirmationModal, "query_one") - def test_focus_save_button(self, mock_query_one): - """Test that _focus_save_button focuses the save button.""" + def test_on_mount_sets_focus(self, mock_query_one): + """Test that on_mount sets focus to the save button.""" modal = SaveConfirmationModal() mock_save_button = Mock() mock_query_one.return_value = mock_save_button - modal._focus_save_button() + modal.on_mount() mock_query_one.assert_called_once_with("#save-button", Button) mock_save_button.focus.assert_called_once() From 0ce58cbe686ff014fc0da3d96e788c0c7b265e04 Mon Sep 17 00:00:00 2001 From: phg Date: Wed, 30 Jul 2025 15:01:54 +0200 Subject: [PATCH 4/5] Remove obsolete main function and associated script --- main_old.py | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 main_old.py diff --git a/main_old.py b/main_old.py deleted file mode 100644 index 12a0b01..0000000 --- a/main_old.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from hosts!") - - -if __name__ == "__main__": - main() From b0abec730ccaaae517a3791032a9110c97b4256c Mon Sep 17 00:00:00 2001 From: phg Date: Wed, 30 Jul 2025 15:23:31 +0200 Subject: [PATCH 5/5] Refactor documentation: update active context, progress, project brief, system patterns, tech context to reflect code quality maintenance and testing improvements --- memory-bank/activeContext.md | 119 +++++++++++++++++++++++++--------- memory-bank/progress.md | 74 ++++++++++----------- memory-bank/projectbrief.md | 41 ++++++++---- memory-bank/systemPatterns.md | 6 +- memory-bank/techContext.md | 16 +++-- 5 files changed, 169 insertions(+), 87 deletions(-) diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index e85656d..a1d4e77 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,7 +2,47 @@ ## Current Work Focus -**Phase 3 Complete with Save Confirmation Enhancement**: The hosts TUI application now has a complete edit mode foundation with permission management, entry manipulation, safe file operations, and professional save confirmation functionality. All keyboard shortcuts are implemented and tested. The application is ready for Phase 4 advanced edit features. +**Post-Phase 3 Code Quality Maintenance**: The hosts TUI application has successfully completed Phase 3 with full edit mode foundation, save confirmation functionality, and comprehensive testing (149 tests). However, 20 minor linting issues (unused imports and variables) require cleanup before proceeding to Phase 4 advanced features. + +## Immediate Next Steps + +### Priority 1: Code Quality Cleanup +1. **Fix linting issues**: Run `uv run ruff check --fix` to address 20 unused import and variable warnings +2. **Validate fixes**: Ensure all tests still pass (149 tests) after cleanup +3. **Confirm application functionality**: Test that `uv run hosts` still works perfectly +4. **Commit clean state**: Create commit with "Fix linting issues" once cleanup is complete + +### Priority 2: Phase 4 Planning +Once code quality is restored: +1. **Advanced entry operations**: Add/delete entries with validation +2. **Search functionality**: Find entries by hostname or IP address +3. **Bulk operations**: Select and modify multiple entries +4. **Help modal**: Proper modal dialog with keyboard shortcuts + +## Memory Bank Update Summary + +### Files Updated +- ✅ **activeContext.md**: Updated current focus and next steps +- ✅ **progress.md**: Corrected test count (149 vs 97), added code quality status +- ✅ **techContext.md**: Updated development workflow and code quality status +- ✅ **systemPatterns.md**: Added edit mode and permission management patterns +- ✅ **projectbrief.md**: Updated test coverage details and current status + +### Key Corrections Made +- **Test count**: Updated from 97 to 149 tests across all files +- **Code quality**: Noted 20 linting issues requiring cleanup +- **Project stage**: Clarified completion of Phase 3 with save confirmation +- **Current status**: Maintenance phase before Phase 4 development +- **Recent commits**: Reflected completion of save confirmation modal + +### Architecture Insights Confirmed +- **Textual framework**: Excellent for complex TUI applications with modal dialogs +- **Layered architecture**: Proven effective for maintainable, testable code +- **Test-driven development**: 149 comprehensive tests enable confident refactoring +- **Configuration system**: JSON-based persistence working reliably +- **Permission management**: Sudo handling implemented safely and securely + +The memory bank now accurately reflects the current state of the project, ready for the next phase of development after code quality maintenance. ## Recent Changes @@ -12,7 +52,7 @@ - ✅ **No auto-save behavior**: Changes are only saved when explicitly confirmed by the user - ✅ **Graceful exit handling**: ESC key in edit entry mode now triggers save confirmation instead of auto-exiting - ✅ **Validation integration**: Full validation before saving with clear error messages for invalid data -- ✅ **Comprehensive testing**: 13 new tests for save confirmation functionality (161 total tests) +- ✅ **Comprehensive testing**: Save confirmation functionality fully tested (149 total tests) - ✅ **Modal keyboard shortcuts**: Save (S), Discard (D), Cancel (ESC) with intuitive button labels ### Phase 2 Implementation Complete @@ -23,7 +63,7 @@ - ✅ **Rich visual interface**: Color-coded entries with professional DataTable styling - ✅ **Interactive column headers**: Click headers to sort data with visual feedback - ✅ **Enhanced status bar**: Detailed information including entry counts and file path -- ✅ **Comprehensive testing**: 97 tests covering all functionality including new features +- ✅ **Comprehensive testing**: 149 tests covering all functionality including new features ### Current Project State - **Production-ready application**: `uv run hosts` launches polished TUI with advanced features @@ -31,38 +71,24 @@ - **Professional visual design**: Color-coded entries, zebra striping, and rich text styling - **Interactive sorting**: Click column headers or use keyboard shortcuts to sort data - **Intelligent filtering**: Hide default system entries based on user preference -- **Comprehensive test coverage**: 97 tests with 100% pass rate covering all components -- **Perfect code quality**: All linting and formatting standards maintained -- **Robust architecture**: Clean layered design ready for edit mode extension +- **Comprehensive test coverage**: 149 tests with 100% pass rate covering all components +- **Code quality maintenance needed**: 20 linting issues (unused imports/variables) require cleanup +- **Robust architecture**: Clean layered design ready for Phase 4 advanced features ## Next Steps -### Phase 3: Edit Mode Foundation (Current Priority) -1. **Permission management system**: - - Implement sudo request and validation - - Edit mode toggle with proper security handling - - Permission validation and error handling - - Graceful fallback for permission denied scenarios +### Immediate Priority: Code Quality Cleanup +1. **Fix linting issues**: Address 20 unused import and variable warnings + - Remove unused imports in core/config.py, test files + - Clean up unused variables in exception handling + - Run `uv run ruff check --fix` to auto-fix issues -2. **Basic editing operations**: - - Toggle entries active/inactive with visual feedback - - Entry editing interface for IP addresses, hostnames, and comments - - Real-time validation of IP addresses and hostnames - - Safe state management during editing +2. **Code quality validation**: + - Ensure all ruff checks pass with zero issues + - Maintain perfect test coverage (149 tests passing) + - Verify application functionality after cleanup -3. **File safety and backup**: - - Automatic backup before any modifications - - Atomic file operations with rollback capability - - Validation before writing changes to disk - - Error recovery and restoration mechanisms - -4. **Edit mode user interface**: - - Clear visual indicators for edit mode vs read-only mode - - Edit forms and dialogs for entry modification - - Confirmation dialogs for destructive operations - - Enhanced status feedback during edit operations - -### Phase 4: Advanced Edit Features (Future) +### Phase 4: Advanced Edit Features (Next Phase) 1. **Advanced editing operations**: - Add new entries with validation - Delete entries with confirmation @@ -96,8 +122,37 @@ - ✅ **Error handling**: Graceful degradation and user feedback throughout - ✅ **Modal pattern**: Professional modal dialogs with proper lifecycle management - ✅ **Configuration pattern**: Centralized settings with persistence and defaults -- 🔄 **Command pattern**: Planned for Phase 3 edit operations with undo/redo -- 🔄 **Observer pattern**: Will implement for state change notifications in edit mode +- ✅ **Command pattern**: Implemented for edit operations with save confirmation +- 🔄 **Observer pattern**: Will implement for state change notifications in advanced features + +## Important Patterns and Preferences + +### Code Quality Standards +- **Zero tolerance for linting issues**: All ruff checks must pass before commits +- **Comprehensive testing**: Maintain 100% test pass rate with meaningful coverage +- **Type safety**: Full type hints throughout codebase +- **Documentation**: Clear docstrings and inline comments for complex logic +- **Error handling**: Graceful degradation with informative user feedback + +### Development Workflow +- **Test-driven development**: Write tests before implementing features +- **Incremental implementation**: Small, focused changes with immediate testing +- **Clean commits**: Each commit should represent a complete, working feature +- **Memory bank maintenance**: Update documentation after significant changes + +## Learnings and Project Insights + +### Technical Insights +- **Textual framework**: Excellent for rich TUI applications with reactive state management +- **Modal system**: Professional dialog implementation requires careful focus and lifecycle management +- **File operations**: Atomic operations and backup systems essential for system file modification +- **Permission management**: Sudo handling requires careful security consideration and user experience design + +### Process Insights +- **Memory bank value**: Documentation consistency crucial for maintaining project context +- **Testing strategy**: Comprehensive test coverage enables confident refactoring and feature addition +- **Code quality**: Automated linting and formatting tools essential for maintaining standards +- **Incremental development**: Small, focused phases enable better quality and easier debugging ### Technical Constraints Confirmed - ✅ **Python 3.13+**: Excellent choice with modern features working perfectly diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 455215d..9ffb0c7 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -39,16 +39,6 @@ - ✅ **Modal system**: Proper modal dialogs with keyboard bindings - ✅ **Configuration persistence**: Settings saved to ~/.config/hosts-manager/ -### Documentation -- ✅ **Project brief**: Comprehensive project definition and requirements -- ✅ **Product context**: User experience goals and problem definition -- ✅ **Technical context**: Technology stack and development setup -- ✅ **System patterns**: Architecture, design patterns, and implementation paths -- ✅ **Active context**: Current work focus and next steps - -## What's Left to Build - - ### Phase 3: Edit Mode Foundation ✅ COMPLETE - ✅ **Permission management**: Sudo request and management with PermissionManager class - ✅ **Edit mode toggle**: Switch between read-only and edit modes with 'e' key @@ -68,6 +58,20 @@ - ✅ **Change detection**: Intelligent tracking of original vs. current entry values - ✅ **No auto-save**: Changes saved only when explicitly confirmed by user +### Documentation +- ✅ **Project brief**: Comprehensive project definition and requirements +- ✅ **Product context**: User experience goals and problem definition +- ✅ **Technical context**: Technology stack and development setup +- ✅ **System patterns**: Architecture, design patterns, and implementation paths +- ✅ **Active context**: Current work focus and next steps + +## What's Left to Build + +### Immediate Priority: Code Quality Cleanup +- ❌ **Fix linting issues**: Address 20 unused import and variable warnings +- ❌ **Code quality validation**: Ensure all ruff checks pass with zero issues +- ❌ **Maintain test coverage**: Keep 149 tests passing during cleanup + ### Phase 4: Advanced Edit Features - ❌ **Add new entries**: Create new host entries - ❌ **Delete entries**: Remove host entries @@ -91,19 +95,9 @@ ## Current Status ### Development Stage -**Stage**: Phase 3 Complete with Save Confirmation Enhancement - Ready for Phase 4 -**Progress**: 78% (Complete edit mode foundation with professional save confirmation) -**Next Milestone**: Advanced edit features (add/delete entries, bulk operations) - -### Phase 2 Final Achievements -1. ✅ **Advanced configuration system**: Complete settings management with persistence -2. ✅ **Professional modal dialogs**: Configuration modal with proper keyboard bindings -3. ✅ **Intelligent filtering**: Hide/show default system entries based on user preference -4. ✅ **Complete sorting system**: Sort by IP address and hostname with direction toggle -5. ✅ **Rich visual interface**: Color-coded entries with professional DataTable styling -6. ✅ **Interactive headers**: Click column headers to sort data -7. ✅ **Enhanced user experience**: Visual sort indicators and comprehensive status information -8. ✅ **Robust configuration**: JSON-based settings with graceful error handling +**Stage**: Phase 3 Complete with Code Quality Maintenance Required +**Progress**: 82% (Complete edit mode foundation with save confirmation, code cleanup needed) +**Next Milestone**: Code quality cleanup, then Phase 4 advanced edit features ### Phase 3 Final Achievements ✅ COMPLETE 1. ✅ **Permission management**: Complete PermissionManager class with sudo request and validation @@ -114,22 +108,17 @@ 6. ✅ **Manager module**: Complete HostsManager class for all edit operations 7. ✅ **Safe file operations**: Atomic file writing with rollback capability 8. ✅ **Enhanced error messages**: Professional read-only mode error messages with clear instructions -9. ✅ **Comprehensive testing**: 38 new tests for manager module (148 total tests) +9. ✅ **Comprehensive testing**: Full test coverage for manager module (149 total tests) 10. ✅ **Save confirmation modal**: Professional save/discard/cancel dialog when exiting edit entry mode 11. ✅ **Change detection system**: Intelligent tracking of original vs. current entry values 12. ✅ **No auto-save behavior**: User-controlled saving with explicit confirmation -### Recent Major Accomplishments -- ✅ **Complete Phase 3 implementation**: Full edit mode foundation with permission management -- ✅ **Manager module**: PermissionManager and HostsManager classes with comprehensive functionality -- ✅ **Edit mode integration**: Seamless integration with main TUI application -- ✅ **Permission system**: Robust sudo request, validation, and release functionality -- ✅ **File backup system**: Automatic backup creation with timestamp naming -- ✅ **Entry manipulation**: Toggle active/inactive and reorder entries safely -- ✅ **Save confirmation enhancement**: Professional modal dialog system for editing workflow -- ✅ **Comprehensive testing**: 161 total tests with 100% pass rate including save confirmation -- ✅ **Error handling**: Graceful handling of permission errors and file operations -- ✅ **Keyboard shortcuts**: Complete set of edit mode bindings (e, space, Ctrl+Up/Down, Ctrl+S) +### Current Project State +- **Application functionality**: Fully functional with advanced edit capabilities +- **Test coverage**: 149 tests with 100% pass rate covering all functionality +- **Code quality issue**: 20 linting issues (unused imports/variables) need cleanup +- **Architecture**: Robust layered design ready for Phase 4 advanced features +- **User experience**: Professional TUI with modal dialogs and comprehensive keyboard shortcuts ## Technical Implementation Details @@ -142,11 +131,13 @@ ### Test Coverage Excellence - **Models**: 27 comprehensive tests covering all data model edge cases - **Parser**: 15 tests covering file operations, permissions, and error conditions -- **Coverage**: 100% of core functionality with edge case validation +- **Manager**: Comprehensive tests for permission management and edit operations +- **UI Components**: Full coverage of modal dialogs and TUI interactions +- **Total**: 149 tests with 100% pass rate and comprehensive edge case coverage - **Quality**: All tests passing consistently with fast execution ### Code Quality Standards -- **Linting**: Perfect ruff compliance with zero issues +- **Linting**: 20 minor issues (unused imports/variables) requiring cleanup - **Type hints**: Complete type coverage throughout entire codebase - **Documentation**: Comprehensive docstrings and inline comments - **Error handling**: Graceful exception handling with user feedback @@ -154,7 +145,14 @@ ## Known Issues -### Phase 3 Enhancement Opportunities +### Immediate Code Quality Issues +- **Linting warnings**: 20 unused import and variable warnings in core and test files +- **Code standard compliance**: Must address before proceeding to Phase 4 +- **Auto-fixable**: All issues can be resolved with `uv run ruff check --fix` + +### Phase 4 Enhancement Opportunities +- **Add new entries**: Create new host entries with validation (planned for Phase 4) +- **Delete entries**: Remove host entries with confirmation (planned for Phase 4) - **Search functionality**: Find entries by hostname or IP address (planned for Phase 4) - **Help modal**: Proper help dialog instead of status message (planned for Phase 4) - **Large file performance**: Not yet tested with very large hosts files diff --git a/memory-bank/projectbrief.md b/memory-bank/projectbrief.md index f4de32c..047e999 100644 --- a/memory-bank/projectbrief.md +++ b/memory-bank/projectbrief.md @@ -81,7 +81,7 @@ hosts/ - Mock `/etc/hosts` file I/O and DNS lookups to avoid system dependencies. - Include integration tests for the Textual TUI (using `textual.testing` or snapshot testing). -### Implemented Tests (97 tests total) +### Implemented Tests (149 tests total) 1. **Parsing Tests** (15 tests): - Parse simple `/etc/hosts` with comments and disabled entries @@ -101,20 +101,39 @@ hosts/ - Configuration loading and saving - Default entry detection and filtering -4. **Modal Dialog Tests** (15 tests): - - Configuration modal lifecycle - - User interaction handling - - Keyboard binding validation - - State management during configuration changes - -5. **Main Application Tests** (18 tests): - - Application initialization and startup +4. **TUI Application Tests** (28 tests): + - Main application initialization and startup - File loading and error handling - User interface state management - Sorting and navigation functionality + - Modal dialog lifecycle and interactions + - Keyboard binding validation + +5. **Manager Module Tests** (38 tests): + - Permission management and sudo handling + - Edit mode operations and state transitions + - File backup and atomic operations + - Entry manipulation and validation + +6. **Save Confirmation Tests** (13 tests): + - Modal dialog lifecycle and user interactions + - Change detection and validation + - Save/discard/cancel functionality + - Integration with edit workflow + +7. **Configuration Modal Tests** (6 tests): + - Modal configuration interface + - Settings persistence and validation + - User interaction handling + +### Current Test Coverage Status +- **Total Tests**: 149 comprehensive tests +- **Pass Rate**: 100% (all tests passing) +- **Coverage Areas**: Core models, file parsing, configuration, TUI components, edit operations, modal dialogs +- **Code Quality**: 20 minor linting issues (unused imports/variables) requiring cleanup ### Future Test Areas (Planned) -- **Edit Mode Tests**: Permission management and file modification +- **Advanced Edit Tests**: Add/delete entries, bulk operations - **DNS Resolution Tests**: Hostname resolution and IP comparison - **Performance Tests**: Large file handling and optimization -- **Integration Tests**: End-to-end workflow testing +- **Search Functionality Tests**: Entry searching and filtering diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index 45499d8..a29bf79 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -34,8 +34,9 @@ #### System Layer (Implemented) - ✅ **File I/O**: Atomic file operations with backup support - ✅ **Permission checking**: Validation of file access permissions +- ✅ **Permission management**: Sudo request and handling for edit mode +- ✅ **Backup system**: Automatic backup creation before modifications - 🔄 **DNS Resolution**: Planned for Phase 5 advanced features -- 🔄 **Permission Management**: Sudo handling planned for Phase 3 edit mode ## Key Technical Decisions @@ -93,10 +94,13 @@ class Config: - ✅ **Reactive state**: Using Textual's reactive attributes for complex UI updates - ✅ **Configuration state**: Persistent settings with JSON storage and graceful error handling - ✅ **Sorting state**: Reactive sort column and direction with visual indicators +- ✅ **Edit mode state**: Safe transitions between read-only and edit modes +- ✅ **Permission state**: Sudo request, validation, and release management - ✅ **Validation pipeline**: All data validated in models, parser, and configuration - ✅ **File integrity**: Atomic operations preserve file structure - ✅ **Error handling**: Graceful degradation for all error conditions - ✅ **Modal state**: Professional modal dialog lifecycle management +- ✅ **Change detection**: Intelligent tracking for save confirmation - 🔄 **Undo/Redo capability**: Planned for Phase 4 with command pattern - 🔄 **Dirty state tracking**: Will be implemented in Phase 3 edit mode diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md index 1260810..e4fa75f 100644 --- a/memory-bank/techContext.md +++ b/memory-bank/techContext.md @@ -32,13 +32,13 @@ hosts/ ### Current State - ✅ **Complete uv project**: Python 3.13 with full dependency management - ✅ **Production application**: Fully functional TUI with advanced features and professional interface -- ✅ **Perfect code quality**: All ruff checks passing with zero issues +- ✅ **Code quality maintenance required**: 20 linting issues (unused imports/variables) need cleanup - ✅ **Proper project structure**: Well-organized src/hosts/ package with core and tui modules -- ✅ **Comprehensive testing**: 97 tests covering all functionality including new features +- ✅ **Comprehensive testing**: 149 tests covering all functionality including new features - ✅ **Entry point configured**: `hosts` command launches application perfectly - ✅ **Configuration system**: Complete settings management with JSON persistence - ✅ **Modal interface**: Professional configuration dialogs with keyboard bindings -- ✅ **Advanced features**: Sorting, filtering, and rich visual interface +- ✅ **Advanced features**: Sorting, filtering, edit mode, and save confirmation ### Runtime Management - ✅ **uv run hosts**: Command executes application instantly @@ -94,11 +94,17 @@ hosts = "hosts.main:main" ### Development Workflow 1. ✅ **uv run hosts**: Execute the application - launches instantly -2. ✅ **uv run ruff check**: Lint code - all checks passing perfectly +2. 🔧 **uv run ruff check**: Lint code - 20 issues need fixing 3. ✅ **uv run ruff format**: Auto-format code - consistent style maintained -4. ✅ **uv run pytest**: Run test suite - 42 tests passing with 100% success rate +4. ✅ **uv run pytest**: Run test suite - 149 tests passing with 100% success rate 5. ✅ **uv add**: Add dependencies - seamless dependency management +### Code Quality Status +- **Current issues**: 20 linting warnings (unused imports and variables) +- **Auto-fixable**: All issues can be resolved with `uv run ruff check --fix` +- **Test coverage**: 149 comprehensive tests with 100% pass rate +- **Code formatting**: Perfect formatting compliance maintained + ### Code Quality Achieved - ✅ **ruff configuration**: Perfect compliance with zero issues across all modules - ✅ **Type hints**: Complete type coverage throughout entire codebase including new components