diff --git a/main_old.py b/main_old.py new file mode 100644 index 0000000..12a0b01 --- /dev/null +++ b/main_old.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from hosts!") + + +if __name__ == "__main__": + main() diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index a1d4e77..35b0636 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,59 +2,10 @@ ## Current Work Focus -**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. +**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. ## 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**: 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 - ✅ **Advanced configuration system**: Complete Config class with JSON persistence to ~/.config/hosts-manager/ - ✅ **Professional configuration modal**: Modal dialog with keyboard bindings for settings management @@ -63,7 +14,7 @@ The memory bank now accurately reflects the current state of the project, ready - ✅ **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**: 149 tests covering all functionality including new features +- ✅ **Comprehensive testing**: 97 tests covering all functionality including new features ### Current Project State - **Production-ready application**: `uv run hosts` launches polished TUI with advanced features @@ -71,24 +22,38 @@ The memory bank now accurately reflects the current state of the project, ready - **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**: 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 +- **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 ## Next Steps -### 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 +### 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 -2. **Code quality validation**: - - Ensure all ruff checks pass with zero issues - - Maintain perfect test coverage (149 tests passing) - - Verify application functionality after cleanup +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 -### Phase 4: Advanced Edit Features (Next Phase) +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) 1. **Advanced editing operations**: - Add new entries with validation - Delete entries with confirmation @@ -122,37 +87,8 @@ The memory bank now accurately reflects the current state of the project, ready - ✅ **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**: 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 +- 🔄 **Command pattern**: Planned for Phase 3 edit operations with undo/redo +- 🔄 **Observer pattern**: Will implement for state change notifications in edit mode ### 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 9ffb0c7..a5b38cd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -39,6 +39,16 @@ - ✅ **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 @@ -54,23 +64,7 @@ - ✅ **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 -- ✅ **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 - -### 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 +- ✅ **Auto-save functionality**: Immediate saving of changes when entries are edited ### Phase 4: Advanced Edit Features - ❌ **Add new entries**: Create new host entries @@ -95,9 +89,19 @@ ## Current Status ### Development Stage -**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 +**Stage**: Phase 3 Complete - Moving to Phase 4 +**Progress**: 75% (Complete edit mode foundation with permission management) +**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 ### Phase 3 Final Achievements ✅ COMPLETE 1. ✅ **Permission management**: Complete PermissionManager class with sudo request and validation @@ -108,17 +112,18 @@ 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**: 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 +9. ✅ **Comprehensive testing**: 38 new tests for manager module (135 total tests) -### 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 +### 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 +- ✅ **Comprehensive testing**: 135 total tests with 100% pass rate including edit operations +- ✅ **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) ## Technical Implementation Details @@ -131,13 +136,11 @@ ### Test Coverage Excellence - **Models**: 27 comprehensive tests covering all data model edge cases - **Parser**: 15 tests covering file operations, permissions, and error conditions -- **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 +- **Coverage**: 100% of core functionality with edge case validation - **Quality**: All tests passing consistently with fast execution ### Code Quality Standards -- **Linting**: 20 minor issues (unused imports/variables) requiring cleanup +- **Linting**: Perfect ruff compliance with zero issues - **Type hints**: Complete type coverage throughout entire codebase - **Documentation**: Comprehensive docstrings and inline comments - **Error handling**: Graceful exception handling with user feedback @@ -145,14 +148,7 @@ ## Known Issues -### 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) +### Phase 3 Enhancement Opportunities - **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 047e999..f4de32c 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 (149 tests total) +### Implemented Tests (97 tests total) 1. **Parsing Tests** (15 tests): - Parse simple `/etc/hosts` with comments and disabled entries @@ -101,39 +101,20 @@ hosts/ - Configuration loading and saving - Default entry detection and filtering -4. **TUI Application Tests** (28 tests): - - Main application initialization and startup +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 - 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) -- **Advanced Edit Tests**: Add/delete entries, bulk operations +- **Edit Mode Tests**: Permission management and file modification - **DNS Resolution Tests**: Hostname resolution and IP comparison - **Performance Tests**: Large file handling and optimization -- **Search Functionality Tests**: Entry searching and filtering +- **Integration Tests**: End-to-end workflow testing diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index a29bf79..45499d8 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -34,9 +34,8 @@ #### 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 @@ -94,13 +93,10 @@ 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 e4fa75f..1260810 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 -- ✅ **Code quality maintenance required**: 20 linting issues (unused imports/variables) need cleanup +- ✅ **Perfect code quality**: All ruff checks passing with zero issues - ✅ **Proper project structure**: Well-organized src/hosts/ package with core and tui modules -- ✅ **Comprehensive testing**: 149 tests covering all functionality including new features +- ✅ **Comprehensive testing**: 97 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, edit mode, and save confirmation +- ✅ **Advanced features**: Sorting, filtering, and rich visual interface ### Runtime Management - ✅ **uv run hosts**: Command executes application instantly @@ -94,17 +94,11 @@ hosts = "hosts.main:main" ### Development Workflow 1. ✅ **uv run hosts**: Execute the application - launches instantly -2. 🔧 **uv run ruff check**: Lint code - 20 issues need fixing +2. ✅ **uv run ruff check**: Lint code - all checks passing perfectly 3. ✅ **uv run ruff format**: Auto-format code - consistent style maintained -4. ✅ **uv run pytest**: Run test suite - 149 tests passing with 100% success rate +4. ✅ **uv run pytest**: Run test suite - 42 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 diff --git a/src/hosts/main.py b/src/hosts/main.py index dac9024..d2f3856 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -18,17 +18,16 @@ 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; @@ -116,7 +115,7 @@ class HostsManagerApp(App): margin-bottom: 1; } """ - + BINDINGS = [ Binding("q", "quit", "Quit"), Binding("r", "reload", "Reload"), @@ -135,7 +134,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) @@ -143,7 +142,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() @@ -151,14 +150,11 @@ 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") @@ -166,7 +162,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: @@ -177,38 +173,34 @@ 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: @@ -220,84 +212,80 @@ 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 "↓" @@ -305,21 +293,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 @@ -339,30 +327,28 @@ 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) @@ -372,44 +358,40 @@ 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 @@ -417,65 +399,61 @@ 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("❌"): @@ -496,38 +474,34 @@ 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 @@ -535,25 +509,22 @@ 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 @@ -562,14 +533,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 @@ -578,14 +549,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": @@ -594,7 +565,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: @@ -615,170 +586,155 @@ 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 + table = self.query_one("#entries-table", DataTable) + table.focus() + + self.update_status("Exited entry edit mode") + + def action_next_field(self) -> None: + """Move to the next field in edit mode.""" 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: + + # 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) + ] + + # 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 - - # 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 - + + # 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) + ] + + # 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": + # 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": + # Prevent default shift+tab behavior and use our custom navigation + 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() + + 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 - changes not saved") - return False - + self.update_status("❌ Invalid IP address") + return + # Validate hostname(s) - hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()] + 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 - + 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])?)*$" + 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 - + 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: @@ -786,103 +742,27 @@ class HostsManagerApp(App): 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 - ) + 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), - ] - - # 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), - ] - - # 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.""" - # 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 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() - - def on_input_changed(self, event: Input.Changed) -> None: - """Handle input field changes (no auto-save - changes saved on exit).""" - # Input changes are tracked but not automatically saved - # Changes will be validated and saved when exiting edit mode - pass - - def on_checkbox_changed(self, event: Checkbox.Changed) -> None: - """Handle checkbox changes (no auto-save - changes saved on exit).""" - # Checkbox changes are tracked but not automatically saved - # Changes will be validated and saved when exiting edit mode - pass - + 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) @@ -896,22 +776,18 @@ 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) @@ -922,9 +798,7 @@ 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() @@ -933,22 +807,18 @@ 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) @@ -959,9 +829,7 @@ 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() @@ -970,27 +838,25 @@ 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 deleted file mode 100644 index a49f8f1..0000000 --- a/src/hosts/tui/save_confirmation_modal.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -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: 15; - 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; - } - - .save-confirmation-button:focus { - border: thick $accent; - } - """ - - 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_mount(self) -> None: - """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_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 deleted file mode 100644 index c6c268a..0000000 --- a/tests/test_save_confirmation_modal.py +++ /dev/null @@ -1,296 +0,0 @@ -""" -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, Button - -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") - - @patch.object(SaveConfirmationModal, "query_one") - 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.on_mount() - - 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.""" - - @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")