diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 234842a..c7bb937 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -2,20 +2,25 @@ ## Current Work Focus -**Phase 4 Advanced Edit Features Complete**: Successfully implemented all Phase 4 features including add/delete entries, inline editing, search functionality, and comprehensive undo/redo system. The application now has complete edit capabilities with modular TUI architecture, command pattern implementation, and professional user interface. Ready for Phase 5 advanced features. +**Phase 4 Advanced Edit Features Largely Complete**: Successfully implemented all major Phase 4 features including add/delete entries, inline editing, and search functionality. The application now has comprehensive edit capabilities with modular TUI architecture and advanced user interface. Ready for Phase 5 advanced features and Polish phase. ## Immediate Next Steps -### Priority 1: Phase 5 Advanced Features +### Priority 1: Phase 4 Completion (Minor Features) +1. **Test fixes**: Resolve 3 failing tests (footer/status bar and keybinding updates) +2. **Documentation updates**: Update keybinding documentation to reflect current implementation + +### Priority 2: Phase 5 Advanced Features 1. **DNS resolution**: Resolve hostnames to IP addresses with comparison 2. **CNAME support**: Store DNS names alongside IP addresses 3. **Advanced filtering**: Filter by active/inactive status 4. **Import/Export**: Support for different file formats -### Priority 2: Phase 6 Polish +### Priority 3: Phase 6 Polish 1. **Bulk operations**: Select and modify multiple entries -2. **Performance optimization**: Testing with large hosts files -3. **Accessibility**: Screen reader support and keyboard accessibility +2. **Undo/Redo functionality**: Command pattern for operation history +3. **Performance optimization**: Testing with large hosts files +4. **Accessibility**: Screen reader support and keyboard accessibility ## Recent Changes @@ -46,27 +51,6 @@ Successfully implemented DataTable-based entry details with consistent field ord - **Labeled rows**: Uses DataTable labeled rows feature for clean presentation - **Professional appearance**: Table format matching main entries table -### Phase 4 Undo/Redo System ✅ COMPLETED -Successfully implemented comprehensive undo/redo functionality using the Command pattern: - -**Command Pattern Implementation:** -- **Abstract Command class**: Base interface with execute/undo methods and operation descriptions -- **OperationResult dataclass**: Standardized result handling with success, message, and optional data -- **UndoRedoHistory manager**: Stack-based operation history with configurable limits (default 50 operations) -- **Concrete command classes**: Complete implementations for all edit operations: - - ToggleEntryCommand: Toggle active/inactive status with reversible operations - - MoveEntryCommand: Move entries up/down with position restoration - - AddEntryCommand: Add entries with removal capability for undo - - DeleteEntryCommand: Remove entries with restoration capability - - UpdateEntryCommand: Modify entry fields with original value restoration - -**Integration and User Interface:** -- **HostsManager integration**: All edit operations now use command pattern with execute/undo methods -- **Keyboard shortcuts**: Ctrl+Z for undo, Ctrl+Y for redo operations -- **UI feedback**: Status bar shows undo/redo availability and operation descriptions -- **History management**: Operations cleared on edit mode exit, failed operations not stored -- **Comprehensive testing**: 43 test cases covering all command operations and edge cases - ### Phase 3 Edit Mode Complete ✅ COMPLETE - ✅ **Permission management**: Complete PermissionManager class with sudo request and validation - ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 11b70a2..5379953 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -72,13 +72,13 @@ ## What's Left to Build -### Phase 4: Advanced Edit Features ✅ COMPLETE +### Phase 4: Advanced Edit Features ✅ LARGELY COMPLETE - ✅ **Add new entries**: Complete AddEntryModal with validation - ✅ **Delete entries**: Complete DeleteConfirmationModal with safety checks - ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status - ✅ **Search functionality**: Complete search/filter by hostname, IP address, or comment -- ✅ **Undo/Redo**: Complete command pattern implementation with 43 comprehensive tests -- ❌ ~~**Bulk operations**: Select and modify multiple entries (won't be implemented)~~ +- ❌ **Bulk operations**: Select and modify multiple entries (planned) +- ❌ **Undo/Redo**: Command pattern implementation for operation history (planned) ### Phase 5: Advanced Features - ❌ **DNS resolution**: Resolve hostnames to IP addresses @@ -127,8 +127,7 @@ - **UI Components**: 28 tests for TUI application and modal dialogs - **Save Confirmation**: 13 tests for save confirmation modal functionality - **Config Modal**: 6 tests for configuration modal interface -- **Commands**: 43 tests for command pattern and undo/redo functionality -- **Total**: 192 tests with 100% pass rate and comprehensive edge case coverage +- **Total**: 149 tests with 100% pass rate and comprehensive edge case coverage ### Code Quality Standards - **Linting**: All ruff checks passing with clean code @@ -139,25 +138,6 @@ ## Phase Completion Summaries -### Phase 4: Undo/Redo System ✅ EXCEPTIONAL SUCCESS -Phase 4 undo/redo implementation exceeded all objectives with comprehensive command pattern: - -1. ✅ **Command Pattern Foundation**: Abstract Command class with execute/undo methods and operation descriptions -2. ✅ **OperationResult System**: Standardized result handling with success, message, and optional data fields -3. ✅ **UndoRedoHistory Manager**: Stack-based operation history with configurable limits (default 50 operations) -4. ✅ **Complete Command Set**: All edit operations implemented as reversible commands: - - ToggleEntryCommand: Toggle active/inactive status with state restoration - - MoveEntryCommand: Move entries up/down with position restoration - - AddEntryCommand: Add entries with removal capability for undo - - DeleteEntryCommand: Remove entries with full restoration capability - - UpdateEntryCommand: Modify entry fields with original value restoration -5. ✅ **HostsManager Integration**: All edit operations now use command pattern with execute/undo methods -6. ✅ **User Interface**: Ctrl+Z/Ctrl+Y keyboard shortcuts with status bar feedback -7. ✅ **History Management**: Operations cleared on edit mode exit, failed operations not stored -8. ✅ **Comprehensive Testing**: 43 test cases covering all command operations, edge cases, and integration -9. ✅ **API Consistency**: Systematic resolution of all integration API mismatches -10. ✅ **Production Ready**: Complete undo/redo functionality integrated into existing workflow - ### Phase 3: Edit Mode Foundation ✅ EXCEPTIONAL SUCCESS Phase 3 exceeded all objectives with comprehensive edit mode implementation: diff --git a/src/hosts/core/commands.py b/src/hosts/core/commands.py deleted file mode 100644 index baa7888..0000000 --- a/src/hosts/core/commands.py +++ /dev/null @@ -1,550 +0,0 @@ -"""Command pattern implementation for undo/redo functionality in hosts management. - -This module provides the command pattern infrastructure for tracking and reversing -edit operations on hosts files, enabling comprehensive undo/redo functionality. -""" - -from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, TYPE_CHECKING -from dataclasses import dataclass - -if TYPE_CHECKING: - from .models import HostsFile, HostEntry - from .manager import HostsManager - - -@dataclass -class OperationResult: - """Result of executing or undoing a command. - - Attributes: - success: Whether the operation succeeded - message: Human-readable description of the result - data: Optional additional data about the operation - """ - success: bool - message: str - data: Optional[Dict[str, Any]] = None - - -class Command(ABC): - """Abstract base class for all edit commands. - - All edit operations (toggle, move, add, delete, update) implement this interface - to provide consistent execute/undo capabilities for the undo/redo system. - """ - - @abstractmethod - def execute(self, hosts_file: "HostsFile") -> OperationResult: - """Execute the command and return the result. - - Args: - hosts_file: The hosts file to operate on - - Returns: - OperationResult indicating success/failure and details - """ - pass - - @abstractmethod - def undo(self, hosts_file: "HostsFile") -> OperationResult: - """Undo the command and return the result. - - Args: - hosts_file: The hosts file to operate on - - Returns: - OperationResult indicating success/failure and details - """ - pass - - @abstractmethod - def get_description(self) -> str: - """Get a human-readable description of the command. - - Returns: - String description of what this command does - """ - pass - - -class UndoRedoHistory: - """Manages undo/redo history with configurable limits. - - This class maintains separate stacks for undo and redo operations, - executes commands while managing history, and provides methods to - check availability of undo/redo operations. - """ - - def __init__(self, max_history: int = 50): - """Initialize the history manager. - - Args: - max_history: Maximum number of commands to keep in history - """ - self.max_history = max_history - self.undo_stack: list[Command] = [] - self.redo_stack: list[Command] = [] - - def execute_command(self, command: Command, hosts_file: "HostsFile") -> OperationResult: - """Execute a command and add it to the undo stack. - - Args: - command: Command to execute - hosts_file: The hosts file to operate on - - Returns: - OperationResult from command execution - """ - result = command.execute(hosts_file) - - if result.success: - # Add to undo stack and clear redo stack - self.undo_stack.append(command) - self.redo_stack.clear() - - # Enforce history limit - if len(self.undo_stack) > self.max_history: - self.undo_stack.pop(0) - - return result - - def undo(self, hosts_file: "HostsFile") -> OperationResult: - """Undo the last command. - - Args: - hosts_file: The hosts file to operate on - - Returns: - OperationResult from undo operation - """ - if not self.can_undo(): - return OperationResult( - success=False, - message="No operations to undo" - ) - - command = self.undo_stack.pop() - result = command.undo(hosts_file) - - if result.success: - # Move command to redo stack - self.redo_stack.append(command) - - # Enforce history limit on redo stack too - if len(self.redo_stack) > self.max_history: - self.redo_stack.pop(0) - else: - # If undo failed, put command back on undo stack - self.undo_stack.append(command) - - return result - - def redo(self, hosts_file: "HostsFile") -> OperationResult: - """Redo the last undone command. - - Args: - hosts_file: The hosts file to operate on - - Returns: - OperationResult from redo operation - """ - if not self.can_redo(): - return OperationResult( - success=False, - message="No operations to redo" - ) - - command = self.redo_stack.pop() - result = command.execute(hosts_file) - - if result.success: - # Move command back to undo stack - self.undo_stack.append(command) - else: - # If redo failed, put command back on redo stack - self.redo_stack.append(command) - - return result - - def can_undo(self) -> bool: - """Check if undo is possible. - - Returns: - True if there are commands that can be undone - """ - return len(self.undo_stack) > 0 - - def can_redo(self) -> bool: - """Check if redo is possible. - - Returns: - True if there are commands that can be redone - """ - return len(self.redo_stack) > 0 - - def clear_history(self) -> None: - """Clear both undo and redo stacks.""" - self.undo_stack.clear() - self.redo_stack.clear() - - def get_undo_description(self) -> Optional[str]: - """Get description of the next command that would be undone. - - Returns: - Description string or None if no undo available - """ - if self.can_undo(): - return self.undo_stack[-1].get_description() - return None - - def get_redo_description(self) -> Optional[str]: - """Get description of the next command that would be redone. - - Returns: - Description string or None if no redo available - """ - if self.can_redo(): - return self.redo_stack[-1].get_description() - return None - - -class ToggleEntryCommand(Command): - """Command to toggle an entry's active state.""" - - def __init__(self, index: int): - """Initialize the toggle command. - - Args: - index: Index of the entry to toggle - """ - self.index = index - self.original_state: Optional[bool] = None - - def execute(self, hosts_file: "HostsFile") -> OperationResult: - """Toggle the entry's active state.""" - if self.index < 0 or self.index >= len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Invalid entry index: {self.index}" - ) - - entry = hosts_file.entries[self.index] - self.original_state = entry.is_active - entry.is_active = not entry.is_active - - action = "activated" if entry.is_active else "deactivated" - return OperationResult( - success=True, - message=f"Entry {action}: {entry.ip_address} {' '.join(entry.hostnames)}", - data={"index": self.index, "new_state": entry.is_active} - ) - - def undo(self, hosts_file: "HostsFile") -> OperationResult: - """Restore the entry's original active state.""" - if self.original_state is None: - return OperationResult( - success=False, - message="Cannot undo: original state not saved" - ) - - if self.index < 0 or self.index >= len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Cannot undo: invalid entry index: {self.index}" - ) - - entry = hosts_file.entries[self.index] - entry.is_active = self.original_state - - action = "activated" if entry.is_active else "deactivated" - return OperationResult( - success=True, - message=f"Undid toggle: entry {action}", - data={"index": self.index, "restored_state": entry.is_active} - ) - - def get_description(self) -> str: - """Get description of this command.""" - return f"Toggle entry at index {self.index}" - - -class MoveEntryCommand(Command): - """Command to move an entry up or down.""" - - def __init__(self, from_index: int, to_index: int): - """Initialize the move command. - - Args: - from_index: Original position of the entry - to_index: Target position for the entry - """ - self.from_index = from_index - self.to_index = to_index - - def execute(self, hosts_file: "HostsFile") -> OperationResult: - """Move the entry from one position to another.""" - if (self.from_index < 0 or self.from_index >= len(hosts_file.entries) or - self.to_index < 0 or self.to_index >= len(hosts_file.entries)): - return OperationResult( - success=False, - message=f"Invalid move: from {self.from_index} to {self.to_index}" - ) - - if self.from_index == self.to_index: - return OperationResult( - success=True, - message="No movement needed", - data={"from_index": self.from_index, "to_index": self.to_index} - ) - - # Move the entry - entry = hosts_file.entries.pop(self.from_index) - hosts_file.entries.insert(self.to_index, entry) - - direction = "up" if self.to_index < self.from_index else "down" - return OperationResult( - success=True, - message=f"Moved entry {direction}: {entry.ip_address} {' '.join(entry.hostnames)}", - data={"from_index": self.from_index, "to_index": self.to_index, "direction": direction} - ) - - def undo(self, hosts_file: "HostsFile") -> OperationResult: - """Move the entry back to its original position.""" - if (self.to_index < 0 or self.to_index >= len(hosts_file.entries) or - self.from_index < 0 or self.from_index >= len(hosts_file.entries)): - return OperationResult( - success=False, - message=f"Cannot undo move: invalid indices" - ) - - # Move back: from to_index back to from_index - entry = hosts_file.entries.pop(self.to_index) - hosts_file.entries.insert(self.from_index, entry) - - direction = "down" if self.to_index < self.from_index else "up" - return OperationResult( - success=True, - message=f"Undid move: moved entry {direction}", - data={"restored_index": self.from_index} - ) - - def get_description(self) -> str: - """Get description of this command.""" - direction = "up" if self.to_index < self.from_index else "down" - return f"Move entry {direction} (from {self.from_index} to {self.to_index})" - - -class AddEntryCommand(Command): - """Command to add a new entry.""" - - def __init__(self, entry: "HostEntry", index: Optional[int] = None): - """Initialize the add command. - - Args: - entry: The entry to add - index: Position to insert at (None for end) - """ - self.entry = entry - self.index = index - self.actual_index: Optional[int] = None - - def execute(self, hosts_file: "HostsFile") -> OperationResult: - """Add the entry to the hosts file.""" - if self.index is None: - # Add at the end - hosts_file.entries.append(self.entry) - self.actual_index = len(hosts_file.entries) - 1 - else: - # Insert at specific position - if self.index < 0 or self.index > len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Invalid insertion index: {self.index}" - ) - hosts_file.entries.insert(self.index, self.entry) - self.actual_index = self.index - - return OperationResult( - success=True, - message=f"Added entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}", - data={"index": self.actual_index, "entry": self.entry} - ) - - def undo(self, hosts_file: "HostsFile") -> OperationResult: - """Remove the added entry.""" - if self.actual_index is None: - return OperationResult( - success=False, - message="Cannot undo: entry index not recorded" - ) - - if self.actual_index < 0 or self.actual_index >= len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Cannot undo: invalid entry index: {self.actual_index}" - ) - - # Verify we're removing the right entry - entry_to_remove = hosts_file.entries[self.actual_index] - if (entry_to_remove.ip_address != self.entry.ip_address or - entry_to_remove.hostnames != self.entry.hostnames): - return OperationResult( - success=False, - message="Cannot undo: entry at index doesn't match added entry" - ) - - hosts_file.entries.pop(self.actual_index) - - return OperationResult( - success=True, - message=f"Undid add: removed entry {self.entry.ip_address}", - data={"removed_index": self.actual_index} - ) - - def get_description(self) -> str: - """Get description of this command.""" - return f"Add entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}" - - -class DeleteEntryCommand(Command): - """Command to delete an entry.""" - - def __init__(self, index: int): - """Initialize the delete command. - - Args: - index: Index of the entry to delete - """ - self.index = index - self.deleted_entry: Optional["HostEntry"] = None - - def execute(self, hosts_file: "HostsFile") -> OperationResult: - """Delete the entry from the hosts file.""" - if self.index < 0 or self.index >= len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Invalid entry index: {self.index}" - ) - - self.deleted_entry = hosts_file.entries.pop(self.index) - - return OperationResult( - success=True, - message=f"Deleted entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}", - data={"index": self.index, "deleted_entry": self.deleted_entry} - ) - - def undo(self, hosts_file: "HostsFile") -> OperationResult: - """Restore the deleted entry.""" - if self.deleted_entry is None: - return OperationResult( - success=False, - message="Cannot undo: deleted entry not saved" - ) - - if self.index < 0 or self.index > len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Cannot undo: invalid restoration index: {self.index}" - ) - - hosts_file.entries.insert(self.index, self.deleted_entry) - - return OperationResult( - success=True, - message=f"Undid delete: restored entry {self.deleted_entry.ip_address}", - data={"restored_index": self.index, "restored_entry": self.deleted_entry} - ) - - def get_description(self) -> str: - """Get description of this command.""" - if self.deleted_entry: - return f"Delete entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}" - return f"Delete entry at index {self.index}" - - -class UpdateEntryCommand(Command): - """Command to update an entry.""" - - def __init__(self, index: int, new_ip: str, new_hostnames: list[str], - new_comment: Optional[str], new_active: bool): - """Initialize the update command. - - Args: - index: Index of the entry to update - new_ip: New IP address - new_hostnames: New list of hostnames - new_comment: New comment (optional) - new_active: New active state - """ - self.index = index - self.new_ip = new_ip - self.new_hostnames = new_hostnames.copy() # Make a copy to avoid mutation - self.new_comment = new_comment - self.new_active = new_active - self.original_entry: Optional["HostEntry"] = None - - def execute(self, hosts_file: "HostsFile") -> OperationResult: - """Update the entry with new values.""" - if self.index < 0 or self.index >= len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Invalid entry index: {self.index}" - ) - - # Save original entry for undo - from .models import HostEntry - original = hosts_file.entries[self.index] - self.original_entry = HostEntry( - ip_address=original.ip_address, - hostnames=original.hostnames.copy(), - comment=original.comment, - is_active=original.is_active - ) - - # Update the entry - entry = hosts_file.entries[self.index] - entry.ip_address = self.new_ip - entry.hostnames = self.new_hostnames.copy() - entry.comment = self.new_comment - entry.is_active = self.new_active - - return OperationResult( - success=True, - message=f"Updated entry: {entry.ip_address} {' '.join(entry.hostnames)}", - data={"index": self.index, "updated_entry": entry} - ) - - def undo(self, hosts_file: "HostsFile") -> OperationResult: - """Restore the entry's original values.""" - if self.original_entry is None: - return OperationResult( - success=False, - message="Cannot undo: original entry not saved" - ) - - if self.index < 0 or self.index >= len(hosts_file.entries): - return OperationResult( - success=False, - message=f"Cannot undo: invalid entry index: {self.index}" - ) - - # Restore original values - entry = hosts_file.entries[self.index] - entry.ip_address = self.original_entry.ip_address - entry.hostnames = self.original_entry.hostnames.copy() - entry.comment = self.original_entry.comment - entry.is_active = self.original_entry.is_active - - return OperationResult( - success=True, - message=f"Undid update: restored entry {entry.ip_address}", - data={"index": self.index, "restored_entry": entry} - ) - - def get_description(self) -> str: - """Get description of this command.""" - if self.original_entry: - return f"Update entry: {self.original_entry.ip_address} → {self.new_ip}" - return f"Update entry at index {self.index}" diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py index 7a1142e..a1230fe 100644 --- a/src/hosts/core/manager.py +++ b/src/hosts/core/manager.py @@ -12,15 +12,7 @@ from pathlib import Path from typing import Optional, Tuple from .models import HostEntry, HostsFile from .parser import HostsParser -from .commands import ( - UndoRedoHistory, - ToggleEntryCommand, - MoveEntryCommand, - AddEntryCommand, - DeleteEntryCommand, - UpdateEntryCommand, - OperationResult, -) + class PermissionManager: """ @@ -120,6 +112,7 @@ class PermissionManager: self.has_sudo = False self._sudo_validated = False + class HostsManager: """ Main manager for hosts file edit operations. @@ -133,7 +126,6 @@ class HostsManager: self.permission_manager = PermissionManager() self.edit_mode = False self._backup_path: Optional[Path] = None - self.undo_redo_history = UndoRedoHistory() def enter_edit_mode(self, password: str = None) -> Tuple[bool, str]: """ @@ -179,7 +171,6 @@ class HostsManager: self.permission_manager.release_sudo() self.edit_mode = False self._backup_path = None - self.undo_redo_history.clear_history() # Clear undo/redo history when exiting edit mode return True, "Edit mode disabled" except Exception as e: return False, f"Error exiting edit mode: {e}" @@ -421,162 +412,6 @@ class HostsManager: except Exception as e: return False, f"Error updating entry: {e}" - # Command-based methods for undo/redo functionality - def execute_toggle_command(self, hosts_file: HostsFile, index: int) -> OperationResult: - """ - Execute a toggle command with undo/redo support. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to toggle - - Returns: - OperationResult with success status and message - """ - if not self.edit_mode: - return OperationResult(False, "Not in edit mode") - - command = ToggleEntryCommand(index) - result = self.undo_redo_history.execute_command(command, hosts_file) - return result - - def execute_move_command(self, hosts_file: HostsFile, index: int, direction: str) -> OperationResult: - """ - Execute a move command with undo/redo support. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to move - direction: "up" or "down" - - Returns: - OperationResult with success status and message - """ - if not self.edit_mode: - return OperationResult(False, "Not in edit mode") - - # Convert direction to target index - if direction == "up": - if index <= 0: - return OperationResult(False, "Cannot move first entry up") - to_index = index - 1 - elif direction == "down": - if index >= len(hosts_file.entries) - 1: - return OperationResult(False, "Cannot move last entry down") - to_index = index + 1 - else: - return OperationResult(False, f"Invalid direction: {direction}") - - command = MoveEntryCommand(index, to_index) - result = self.undo_redo_history.execute_command(command, hosts_file) - return result - - def execute_add_command(self, hosts_file: HostsFile, entry: HostEntry, save_callback=None) -> OperationResult: - """ - Execute an add command with undo/redo support. - - Args: - hosts_file: The hosts file to modify - entry: The new entry to add - save_callback: Optional callback to save the file after adding - - Returns: - OperationResult with success status and message - """ - if not self.edit_mode: - return OperationResult(False, "Not in edit mode") - - command = AddEntryCommand(entry) - result = self.undo_redo_history.execute_command(command, hosts_file) - return result - - def execute_delete_command(self, hosts_file: HostsFile, index: int, save_callback=None) -> OperationResult: - """ - Execute a delete command with undo/redo support. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to delete - save_callback: Optional callback to save the file after deletion - - Returns: - OperationResult with success status and message - """ - if not self.edit_mode: - return OperationResult(False, "Not in edit mode") - - command = DeleteEntryCommand(index) - result = self.undo_redo_history.execute_command(command, hosts_file) - return result - - def execute_update_command(self, hosts_file: HostsFile, index: int, ip_address: str, hostnames: list[str], comment: Optional[str], is_active: bool) -> OperationResult: - """ - Execute an update command with undo/redo support. - - Args: - hosts_file: The hosts file to modify - index: Index of the entry to update - ip_address: New IP address - hostnames: New list of hostnames - comment: New comment (optional) - is_active: New active state - - Returns: - OperationResult with success status and message - """ - if not self.edit_mode: - return OperationResult(False, "Not in edit mode") - - command = UpdateEntryCommand(index, ip_address, hostnames, comment, is_active) - result = self.undo_redo_history.execute_command(command, hosts_file) - return result - - def undo_last_operation(self, hosts_file: HostsFile) -> OperationResult: - """ - Undo the last operation. - - Args: - hosts_file: The hosts file to operate on - - Returns: - OperationResult with success status and message - """ - if not self.edit_mode: - return OperationResult(False, "Not in edit mode") - - return self.undo_redo_history.undo(hosts_file) - - def redo_last_operation(self, hosts_file: HostsFile) -> OperationResult: - """ - Redo the last undone operation. - - Args: - hosts_file: The hosts file to operate on - - Returns: - OperationResult with success status and message - """ - if not self.edit_mode: - return OperationResult(False, "Not in edit mode") - - return self.undo_redo_history.redo(hosts_file) - - def can_undo(self) -> bool: - """Check if undo is available.""" - return self.undo_redo_history.can_undo() - - def can_redo(self) -> bool: - """Check if redo is available.""" - return self.undo_redo_history.can_redo() - - def get_undo_description(self) -> Optional[str]: - """Get description of the operation that would be undone.""" - return self.undo_redo_history.get_undo_description() - - def get_redo_description(self) -> Optional[str]: - """Get description of the operation that would be redone.""" - return self.undo_redo_history.get_redo_description() - def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]: """ Save the hosts file to disk with sudo permissions. @@ -686,16 +521,19 @@ class HostsManager: ["sudo", "chmod", "644", str(self._backup_path)], capture_output=True ) + class EditModeError(Exception): """Base exception for edit mode errors.""" pass + class PermissionError(EditModeError): """Raised when there are permission issues.""" pass + class ValidationError(EditModeError): """Raised when validation fails.""" diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index 58b813e..520dfdb 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -237,20 +237,7 @@ class HostsManagerApp(App): mode = "Edit" if self.edit_mode else "Read-only" entry_count = len(self.hosts_file.entries) active_count = len(self.hosts_file.get_active_entries()) - - # Add undo/redo status in edit mode - undo_redo_status = "" - if self.edit_mode: - can_undo = self.manager.can_undo() - can_redo = self.manager.can_redo() - if can_undo or can_redo: - undo_status = "Undo available" if can_undo else "" - redo_status = "Redo available" if can_redo else "" - statuses = [s for s in [undo_status, redo_status] if s] - if statuses: - undo_redo_status = f" | {', '.join(statuses)}" - - status = f"{entry_count} entries ({active_count} active) | {mode}{undo_redo_status}" + status = f"{entry_count} entries ({active_count} active) | {mode}" footer.set_status(status) except Exception: pass # Footer not ready yet @@ -522,22 +509,17 @@ class HostsManagerApp(App): self.update_status("Entry creation cancelled") return - # Add the entry using the command-based manager method - result = self.manager.execute_add_command(self.hosts_file, new_entry) - if result.success: - # Save the changes - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - # Refresh the table - self.table_handler.populate_entries_table() - # Move cursor to the newly added entry (last entry) - self.selected_entry_index = len(self.hosts_file.entries) - 1 - self.table_handler.restore_cursor_position(new_entry) - self.update_status(f"✅ {result.message} - Changes saved automatically") - else: - self.update_status(f"Entry added but save failed: {save_message}") + # Add the entry using the manager + success, message = self.manager.add_entry(self.hosts_file, new_entry) + if success: + # Refresh the table + self.table_handler.populate_entries_table() + # Move cursor to the newly added entry (last entry) + self.selected_entry_index = len(self.hosts_file.entries) - 1 + self.table_handler.restore_cursor_position(new_entry) + self.update_status(f"✅ {message}") else: - self.update_status(f"❌ {result.message}") + self.update_status(f"❌ {message}") self.push_screen(AddEntryModal(), handle_add_entry_result) @@ -567,26 +549,21 @@ class HostsManagerApp(App): self.update_status("Entry deletion cancelled") return - # Delete the entry using the command-based manager method - result = self.manager.execute_delete_command( + # Delete the entry using the manager + success, message = self.manager.delete_entry( self.hosts_file, self.selected_entry_index ) - if result.success: - # Save the changes - save_success, save_message = self.manager.save_hosts_file(self.hosts_file) - if save_success: - # Adjust selected index if needed - if self.selected_entry_index >= len(self.hosts_file.entries): - self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1) + if success: + # Adjust selected index if needed + if self.selected_entry_index >= len(self.hosts_file.entries): + self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1) - # Refresh the table - self.table_handler.populate_entries_table() - self.details_handler.update_entry_details() - self.update_status(f"✅ {result.message} - Changes saved automatically") - else: - self.update_status(f"Entry deleted but save failed: {save_message}") + # Refresh the table + self.table_handler.populate_entries_table() + self.details_handler.update_entry_details() + self.update_status(f"✅ {message}") else: - self.update_status(f"❌ {result.message}") + self.update_status(f"❌ {message}") self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation) @@ -594,52 +571,6 @@ class HostsManagerApp(App): """Quit the application.""" self.navigation_handler.quit_application() - def action_undo(self) -> None: - """Undo the last operation.""" - if not self.edit_mode: - self.update_status("❌ Cannot undo: Application is in read-only mode") - return - - if not self.manager.can_undo(): - self.update_status("Nothing to undo") - return - - # Get description before undoing - description = self.manager.get_undo_description() - - # Perform undo - result = self.manager.undo_last_operation(self.hosts_file) - if result.success: - # Refresh the table and update UI - self.table_handler.populate_entries_table() - self.details_handler.update_entry_details() - self.update_status(f"✅ Undone: {description or 'operation'}") - else: - self.update_status(f"❌ Undo failed: {result.message}") - - def action_redo(self) -> None: - """Redo the last undone operation.""" - if not self.edit_mode: - self.update_status("❌ Cannot redo: Application is in read-only mode") - return - - if not self.manager.can_redo(): - self.update_status("Nothing to redo") - return - - # Get description before redoing - description = self.manager.get_redo_description() - - # Perform redo - result = self.manager.redo_last_operation(self.hosts_file) - if result.success: - # Refresh the table and update UI - self.table_handler.populate_entries_table() - self.details_handler.update_entry_details() - self.update_status(f"✅ Redone: {description or 'operation'}") - else: - self.update_status(f"❌ Redo failed: {result.message}") - # Delegated methods for backward compatibility with tests def has_entry_changes(self) -> bool: """Check if the current entry has been modified from its original values.""" diff --git a/src/hosts/tui/keybindings.py b/src/hosts/tui/keybindings.py index 3109675..3d37cd9 100644 --- a/src/hosts/tui/keybindings.py +++ b/src/hosts/tui/keybindings.py @@ -42,8 +42,6 @@ HOSTS_MANAGER_BINDINGS = [ Binding("ctrl+s", "save_file", "Save hosts file", show=False), Binding("shift+up", "move_entry_up", "Move entry up", show=False), Binding("shift+down", "move_entry_down", "Move entry down", show=False), - Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"), - Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"), Binding("escape", "exit_edit_entry", "Exit edit mode", show=False), Binding("tab", "next_field", "Next field", show=False), Binding("shift+tab", "prev_field", "Previous field", show=False), diff --git a/src/hosts/tui/navigation_handler.py b/src/hosts/tui/navigation_handler.py index 4bee749..733b895 100644 --- a/src/hosts/tui/navigation_handler.py +++ b/src/hosts/tui/navigation_handler.py @@ -30,11 +30,10 @@ class NavigationHandler: # Remember current entry for cursor position restoration current_entry = self.app.hosts_file.entries[self.app.selected_entry_index] - # Use command-based method for undo/redo support - result = self.app.manager.execute_toggle_command( + success, message = self.app.manager.toggle_entry( self.app.hosts_file, self.app.selected_entry_index ) - if result.success: + if success: # Auto-save the changes immediately save_success, save_message = self.app.manager.save_hosts_file( self.app.hosts_file @@ -49,11 +48,11 @@ class NavigationHandler: ), ) self.app.details_handler.update_entry_details() - self.app.update_status(f"{result.message} - Changes saved automatically") + self.app.update_status(f"{message} - Changes saved automatically") else: self.app.update_status(f"Entry toggled but save failed: {save_message}") else: - self.app.update_status(f"Error toggling entry: {result.message}") + self.app.update_status(f"Error toggling entry: {message}") def move_entry_up(self) -> None: """Move the selected entry up in the list.""" @@ -67,11 +66,10 @@ class NavigationHandler: self.app.update_status("No entries to move") return - # Use command-based method for undo/redo support - result = self.app.manager.execute_move_command( - self.app.hosts_file, self.app.selected_entry_index, "up" + success, message = self.app.manager.move_entry_up( + self.app.hosts_file, self.app.selected_entry_index ) - if result.success: + if success: # Auto-save the changes immediately save_success, save_message = self.app.manager.save_hosts_file( self.app.hosts_file @@ -89,11 +87,11 @@ class NavigationHandler: if table.row_count > 0 and display_index < table.row_count: table.move_cursor(row=display_index) self.app.details_handler.update_entry_details() - self.app.update_status(f"{result.message} - Changes saved automatically") + self.app.update_status(f"{message} - Changes saved automatically") else: self.app.update_status(f"Entry moved but save failed: {save_message}") else: - self.app.update_status(f"Error moving entry: {result.message}") + self.app.update_status(f"Error moving entry: {message}") def move_entry_down(self) -> None: """Move the selected entry down in the list.""" @@ -107,11 +105,10 @@ class NavigationHandler: self.app.update_status("No entries to move") return - # Use command-based method for undo/redo support - result = self.app.manager.execute_move_command( - self.app.hosts_file, self.app.selected_entry_index, "down" + success, message = self.app.manager.move_entry_down( + self.app.hosts_file, self.app.selected_entry_index ) - if result.success: + if success: # Auto-save the changes immediately save_success, save_message = self.app.manager.save_hosts_file( self.app.hosts_file @@ -129,11 +126,11 @@ class NavigationHandler: if table.row_count > 0 and display_index < table.row_count: table.move_cursor(row=display_index) self.app.details_handler.update_entry_details() - self.app.update_status(f"{result.message} - Changes saved automatically") + self.app.update_status(f"{message} - Changes saved automatically") else: self.app.update_status(f"Entry moved but save failed: {save_message}") else: - self.app.update_status(f"Error moving entry: {result.message}") + self.app.update_status(f"Error moving entry: {message}") def save_hosts_file(self) -> None: """Save the hosts file to disk.""" diff --git a/tests/test_commands.py b/tests/test_commands.py deleted file mode 100644 index 6202735..0000000 --- a/tests/test_commands.py +++ /dev/null @@ -1,584 +0,0 @@ -""" -Tests for the command pattern implementation in the hosts TUI application. - -This module tests the undo/redo functionality, command pattern, -and integration with the HostsManager. -""" - -import pytest -from unittest.mock import Mock, patch -from src.hosts.core.commands import ( - Command, - OperationResult, - UndoRedoHistory, - ToggleEntryCommand, - MoveEntryCommand, - AddEntryCommand, - DeleteEntryCommand, - UpdateEntryCommand, -) -from src.hosts.core.models import HostEntry, HostsFile -from src.hosts.core.manager import HostsManager - - -class TestOperationResult: - """Test the OperationResult class.""" - - def test_operation_result_success(self): - """Test successful operation result.""" - result = OperationResult(True, "Operation successful") - assert result.success is True - assert result.message == "Operation successful" - - def test_operation_result_failure(self): - """Test failed operation result.""" - result = OperationResult(False, "Operation failed") - assert result.success is False - assert result.message == "Operation failed" - - def test_operation_result_with_data(self): - """Test operation result with additional data.""" - result = OperationResult(True, "Operation successful", {"key": "value"}) - assert result.success is True - assert result.message == "Operation successful" - assert result.data == {"key": "value"} - - -class TestUndoRedoHistory: - """Test the UndoRedoHistory class.""" - - def test_initial_state(self): - """Test initial state of undo/redo history.""" - history = UndoRedoHistory() - assert not history.can_undo() - assert not history.can_redo() - assert history.get_undo_description() is None - assert history.get_redo_description() is None - - def test_execute_command(self): - """Test executing a command adds it to history.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - command = Mock(spec=Command) - command.execute.return_value = OperationResult(True, "Test executed") - command.get_description.return_value = "Test command" - - result = history.execute_command(command, hosts_file) - - assert result.success is True - assert result.message == "Test executed" - assert history.can_undo() - assert not history.can_redo() - assert history.get_undo_description() == "Test command" - command.execute.assert_called_once_with(hosts_file) - - def test_execute_failed_command(self): - """Test executing a failed command does not add it to history.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - command = Mock(spec=Command) - command.execute.return_value = OperationResult(False, "Test failed") - - result = history.execute_command(command, hosts_file) - - assert result.success is False - assert result.message == "Test failed" - assert not history.can_undo() - assert not history.can_redo() - - def test_undo_operation(self): - """Test undoing an operation.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - command = Mock(spec=Command) - command.execute.return_value = OperationResult(True, "Test executed") - command.undo.return_value = OperationResult(True, "Test undone") - command.get_description.return_value = "Test command" - - # Execute then undo - history.execute_command(command, hosts_file) - result = history.undo(hosts_file) - - assert result.success is True - assert result.message == "Test undone" - assert not history.can_undo() - assert history.can_redo() - assert history.get_redo_description() == "Test command" - command.undo.assert_called_once_with(hosts_file) - - def test_redo_operation(self): - """Test redoing an operation.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - command = Mock(spec=Command) - command.execute.return_value = OperationResult(True, "Test executed") - command.undo.return_value = OperationResult(True, "Test undone") - command.get_description.return_value = "Test command" - - # Execute, undo, then redo - history.execute_command(command, hosts_file) - history.undo(hosts_file) - result = history.redo(hosts_file) - - assert result.success is True - assert result.message == "Test executed" - assert history.can_undo() - assert not history.can_redo() - assert history.get_undo_description() == "Test command" - assert command.execute.call_count == 2 - - def test_undo_with_empty_history(self): - """Test undo with empty history.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - result = history.undo(hosts_file) - - assert result.success is False - assert "No operations to undo" in result.message - - def test_redo_with_empty_history(self): - """Test redo with empty history.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - result = history.redo(hosts_file) - - assert result.success is False - assert "No operations to redo" in result.message - - def test_max_history_limit(self): - """Test that history respects the maximum limit.""" - history = UndoRedoHistory(max_history=2) - hosts_file = HostsFile() - - # Add 3 commands - for i in range(3): - command = Mock(spec=Command) - command.execute.return_value = OperationResult(True, f"Command {i}") - command.get_description.return_value = f"Command {i}" - history.execute_command(command, hosts_file) - - # Should only keep the last 2 commands - assert len(history.undo_stack) == 2 - - def test_clear_history(self): - """Test clearing the history.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - command = Mock(spec=Command) - command.execute.return_value = OperationResult(True, "Test executed") - command.get_description.return_value = "Test command" - - history.execute_command(command, hosts_file) - assert history.can_undo() - - history.clear_history() - assert not history.can_undo() - assert not history.can_redo() - - def test_new_command_clears_redo_stack(self): - """Test that executing a new command clears the redo stack.""" - history = UndoRedoHistory() - hosts_file = HostsFile() - - # Execute and undo a command - command1 = Mock(spec=Command) - command1.execute.return_value = OperationResult(True, "Command 1") - command1.undo.return_value = OperationResult(True, "Undo 1") - command1.get_description.return_value = "Command 1" - - history.execute_command(command1, hosts_file) - history.undo(hosts_file) - assert history.can_redo() - - # Execute a new command - command2 = Mock(spec=Command) - command2.execute.return_value = OperationResult(True, "Command 2") - command2.get_description.return_value = "Command 2" - - history.execute_command(command2, hosts_file) - assert not history.can_redo() # Redo stack should be cleared - - -class TestToggleEntryCommand: - """Test the ToggleEntryCommand class.""" - - def test_toggle_active_to_inactive(self): - """Test toggling an active entry to inactive.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True) - hosts_file.entries.append(entry) - - command = ToggleEntryCommand(0) - result = command.execute(hosts_file) - - assert result.success is True - assert not hosts_file.entries[0].is_active - assert "deactivated" in result.message - - def test_toggle_inactive_to_active(self): - """Test toggling an inactive entry to active.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=False) - hosts_file.entries.append(entry) - - command = ToggleEntryCommand(0) - result = command.execute(hosts_file) - - assert result.success is True - assert hosts_file.entries[0].is_active - assert "activated" in result.message - - def test_toggle_undo(self): - """Test undoing a toggle operation.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True) - hosts_file.entries.append(entry) - - command = ToggleEntryCommand(0) - command.execute(hosts_file) # Toggle to inactive - result = command.undo(hosts_file) # Toggle back to active - - assert result.success is True - assert hosts_file.entries[0].is_active - assert "Undid toggle" in result.message - - def test_toggle_invalid_index(self): - """Test toggle with invalid index.""" - hosts_file = HostsFile() - command = ToggleEntryCommand(0) - result = command.execute(hosts_file) - - assert result.success is False - assert "Invalid entry index" in result.message - - def test_toggle_get_description(self): - """Test getting command description.""" - command = ToggleEntryCommand(0) - description = command.get_description() - assert "Toggle entry at index 0" in description - - -class TestMoveEntryCommand: - """Test the MoveEntryCommand class.""" - - def test_move_entry_up(self): - """Test moving an entry up.""" - hosts_file = HostsFile() - entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"]) - entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"]) - hosts_file.entries.extend([entry1, entry2]) - - command = MoveEntryCommand(1, 0) # Move from index 1 to index 0 - result = command.execute(hosts_file) - - assert result.success is True - assert hosts_file.entries[0] == entry2 - assert hosts_file.entries[1] == entry1 - assert "up" in result.message - - def test_move_entry_down(self): - """Test moving an entry down.""" - hosts_file = HostsFile() - entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"]) - entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"]) - hosts_file.entries.extend([entry1, entry2]) - - command = MoveEntryCommand(0, 1) # Move from index 0 to index 1 - result = command.execute(hosts_file) - - assert result.success is True - assert hosts_file.entries[0] == entry2 - assert hosts_file.entries[1] == entry1 - assert "down" in result.message - - def test_move_undo(self): - """Test undoing a move operation.""" - hosts_file = HostsFile() - entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"]) - entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"]) - hosts_file.entries.extend([entry1, entry2]) - - command = MoveEntryCommand(1, 0) # Move entry2 up - command.execute(hosts_file) - result = command.undo(hosts_file) # Move back down - - assert result.success is True - assert hosts_file.entries[0] == entry1 - assert hosts_file.entries[1] == entry2 - assert "Undid move" in result.message - - def test_move_invalid_indices(self): - """Test move with invalid indices.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - hosts_file.entries.append(entry) - - command = MoveEntryCommand(0, 5) # Invalid target index - result = command.execute(hosts_file) - - assert result.success is False - assert "Invalid move" in result.message - - def test_move_same_position(self): - """Test moving entry to same position.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - hosts_file.entries.append(entry) - - command = MoveEntryCommand(0, 0) # Same position - result = command.execute(hosts_file) - - assert result.success is True - assert "No movement needed" in result.message - - def test_move_get_description(self): - """Test getting command description.""" - command = MoveEntryCommand(1, 0) - description = command.get_description() - assert "Move entry up" in description - - -class TestAddEntryCommand: - """Test the AddEntryCommand class.""" - - def test_add_entry(self): - """Test adding an entry.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - - command = AddEntryCommand(entry) - result = command.execute(hosts_file) - - assert result.success is True - assert len(hosts_file.entries) == 1 - assert hosts_file.entries[0] == entry - assert "Added entry" in result.message - - def test_add_entry_at_specific_index(self): - """Test adding an entry at a specific index.""" - hosts_file = HostsFile() - entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"]) - entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"]) - hosts_file.entries.append(entry1) - - command = AddEntryCommand(entry2, index=0) # Insert at beginning - result = command.execute(hosts_file) - - assert result.success is True - assert len(hosts_file.entries) == 2 - assert hosts_file.entries[0] == entry2 - assert hosts_file.entries[1] == entry1 - - def test_add_entry_undo(self): - """Test undoing an add operation.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - - command = AddEntryCommand(entry) - command.execute(hosts_file) - result = command.undo(hosts_file) - - assert result.success is True - assert len(hosts_file.entries) == 0 - assert "Undid add" in result.message - - def test_add_entry_invalid_index(self): - """Test adding entry with invalid index.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - - command = AddEntryCommand(entry, index=5) # Invalid index - result = command.execute(hosts_file) - - assert result.success is False - assert "Invalid insertion index" in result.message - - def test_add_get_description(self): - """Test getting command description.""" - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - command = AddEntryCommand(entry) - description = command.get_description() - assert "Add entry: 192.168.1.1 test.local" in description - - -class TestDeleteEntryCommand: - """Test the DeleteEntryCommand class.""" - - def test_delete_entry(self): - """Test deleting an entry.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - hosts_file.entries.append(entry) - - command = DeleteEntryCommand(0) - result = command.execute(hosts_file) - - assert result.success is True - assert len(hosts_file.entries) == 0 - assert "Deleted entry" in result.message - - def test_delete_entry_undo(self): - """Test undoing a delete operation.""" - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - hosts_file.entries.append(entry) - - command = DeleteEntryCommand(0) - command.execute(hosts_file) - result = command.undo(hosts_file) - - assert result.success is True - assert len(hosts_file.entries) == 1 - assert hosts_file.entries[0].ip_address == "192.168.1.1" - assert "Undid delete" in result.message - - def test_delete_invalid_index(self): - """Test delete with invalid index.""" - hosts_file = HostsFile() - command = DeleteEntryCommand(0) - result = command.execute(hosts_file) - - assert result.success is False - assert "Invalid entry index" in result.message - - def test_delete_get_description(self): - """Test getting command description.""" - command = DeleteEntryCommand(0) - description = command.get_description() - assert "Delete entry at index 0" in description - - -class TestUpdateEntryCommand: - """Test the UpdateEntryCommand class.""" - - def test_update_entry(self): - """Test updating an entry.""" - hosts_file = HostsFile() - old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True) - hosts_file.entries.append(old_entry) - - command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False) - result = command.execute(hosts_file) - - assert result.success is True - assert hosts_file.entries[0].ip_address == "192.168.1.2" - assert hosts_file.entries[0].hostnames == ["updated.local"] - assert hosts_file.entries[0].comment == "new comment" - assert hosts_file.entries[0].is_active is False - assert "Updated entry" in result.message - - def test_update_entry_undo(self): - """Test undoing an update operation.""" - hosts_file = HostsFile() - old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True) - hosts_file.entries.append(old_entry) - - command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False) - command.execute(hosts_file) - result = command.undo(hosts_file) - - assert result.success is True - assert hosts_file.entries[0].ip_address == "192.168.1.1" - assert hosts_file.entries[0].hostnames == ["test.local"] - assert hosts_file.entries[0].comment == "old comment" - assert hosts_file.entries[0].is_active is True - assert "Undid update" in result.message - - def test_update_invalid_index(self): - """Test update with invalid index.""" - hosts_file = HostsFile() - - command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], None, True) - result = command.execute(hosts_file) - - assert result.success is False - assert "Invalid entry index" in result.message - - def test_update_get_description(self): - """Test getting command description.""" - command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], None, True) - description = command.get_description() - assert "Update entry at index 0" in description - - -class TestHostsManagerIntegration: - """Test integration of commands with HostsManager.""" - - def test_manager_undo_redo_properties(self): - """Test undo/redo availability properties.""" - manager = HostsManager() - - # Initially no undo/redo available - assert not manager.can_undo() - assert not manager.can_redo() - assert manager.get_undo_description() is None - assert manager.get_redo_description() is None - - def test_manager_undo_without_edit_mode(self): - """Test undo when not in edit mode.""" - manager = HostsManager() - hosts_file = HostsFile() - result = manager.undo_last_operation(hosts_file) - - assert result.success is False - assert "Not in edit mode" in result.message - - def test_manager_redo_without_edit_mode(self): - """Test redo when not in edit mode.""" - manager = HostsManager() - hosts_file = HostsFile() - result = manager.redo_last_operation(hosts_file) - - assert result.success is False - assert "Not in edit mode" in result.message - - @patch('src.hosts.core.manager.HostsManager.enter_edit_mode') - def test_manager_execute_toggle_command(self, mock_enter_edit): - """Test executing a toggle command through the manager.""" - mock_enter_edit.return_value = (True, "Edit mode enabled") - - manager = HostsManager() - manager.edit_mode = True # Simulate edit mode - - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True) - hosts_file.entries.append(entry) - - result = manager.execute_toggle_command(hosts_file, 0) - - assert result.success is True - assert not hosts_file.entries[0].is_active - - @patch('src.hosts.core.manager.HostsManager.enter_edit_mode') - def test_manager_execute_move_command(self, mock_enter_edit): - """Test executing a move command through the manager.""" - mock_enter_edit.return_value = (True, "Edit mode enabled") - - manager = HostsManager() - manager.edit_mode = True # Simulate edit mode - - hosts_file = HostsFile() - entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"]) - entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"]) - hosts_file.entries.extend([entry1, entry2]) - - result = manager.execute_move_command(hosts_file, 1, "up") - - assert result.success is True - assert hosts_file.entries[0] == entry2 - - @patch('src.hosts.core.manager.HostsManager.enter_edit_mode') - def test_manager_command_not_in_edit_mode(self, mock_enter_edit): - """Test executing commands when not in edit mode.""" - manager = HostsManager() - hosts_file = HostsFile() - entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"]) - - result = manager.execute_add_command(hosts_file, entry) - - assert result.success is False - assert "Not in edit mode" in result.message - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_main.py b/tests/test_main.py index 810243d..f3389ed 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -267,10 +267,6 @@ class TestHostsManagerApp: ): app = HostsManagerApp() - # Mock the query_one method to avoid UI dependencies - mock_footer = Mock() - app.query_one = Mock(return_value=mock_footer) - # Add test entries app.hosts_file = HostsFile() app.hosts_file.add_entry( @@ -284,12 +280,10 @@ class TestHostsManagerApp: app.update_status() - # Verify footer status was updated - mock_footer.set_status.assert_called_once() - status_call = mock_footer.set_status.call_args[0][0] - assert "Read-only" in status_call - assert "2 entries" in status_call - assert "1 active" in status_call + # Verify sub_title was set correctly + assert "Read-only mode" in app.sub_title + assert "2 entries" in app.sub_title + assert "1 active" in app.sub_title def test_update_status_custom_message(self): """Test status bar update with custom message.""" @@ -305,18 +299,9 @@ class TestHostsManagerApp: # Mock set_timer and query_one to avoid event loop and UI issues app.set_timer = Mock() mock_status_bar = Mock() - mock_footer = Mock() + app.query_one = Mock(return_value=mock_status_bar) - def mock_query_one(selector, widget_type=None): - if selector == "#status-bar": - return mock_status_bar - elif selector == "#custom-footer": - return mock_footer - return Mock() - - app.query_one = mock_query_one - - # Add test hosts_file for footer status generation + # Add test hosts_file for subtitle generation app.hosts_file = HostsFile() app.hosts_file.add_entry( HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) @@ -332,11 +317,9 @@ class TestHostsManagerApp: # Verify status bar was updated with custom message mock_status_bar.update.assert_called_with("Custom status message") mock_status_bar.remove_class.assert_called_with("hidden") - # Verify footer status was updated with current status (not the custom message) - mock_footer.set_status.assert_called_once() - footer_status = mock_footer.set_status.call_args[0][0] - assert "2 entries" in footer_status - assert "Read-only" in footer_status + # Verify subtitle shows current status (not the custom message) + assert "2 entries" in app.sub_title + assert "Read-only mode" in app.sub_title # Verify timer was set for auto-clearing app.set_timer.assert_called_once()