Compare commits
No commits in common. "e6f3e9f3d4db4754d1870118237aff68fde176ba" and "77d4a2e95530a14774ba03d5b5fb7b00c3096ec5" have entirely different histories.
e6f3e9f3d4
...
77d4a2e955
9 changed files with 64 additions and 1487 deletions
|
@ -2,20 +2,25 @@
|
||||||
|
|
||||||
## Current Work Focus
|
## 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
|
## 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
|
1. **DNS resolution**: Resolve hostnames to IP addresses with comparison
|
||||||
2. **CNAME support**: Store DNS names alongside IP addresses
|
2. **CNAME support**: Store DNS names alongside IP addresses
|
||||||
3. **Advanced filtering**: Filter by active/inactive status
|
3. **Advanced filtering**: Filter by active/inactive status
|
||||||
4. **Import/Export**: Support for different file formats
|
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
|
1. **Bulk operations**: Select and modify multiple entries
|
||||||
2. **Performance optimization**: Testing with large hosts files
|
2. **Undo/Redo functionality**: Command pattern for operation history
|
||||||
3. **Accessibility**: Screen reader support and keyboard accessibility
|
3. **Performance optimization**: Testing with large hosts files
|
||||||
|
4. **Accessibility**: Screen reader support and keyboard accessibility
|
||||||
|
|
||||||
## Recent Changes
|
## 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
|
- **Labeled rows**: Uses DataTable labeled rows feature for clean presentation
|
||||||
- **Professional appearance**: Table format matching main entries table
|
- **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
|
### Phase 3 Edit Mode Complete ✅ COMPLETE
|
||||||
- ✅ **Permission management**: Complete PermissionManager class with sudo request and validation
|
- ✅ **Permission management**: Complete PermissionManager class with sudo request and validation
|
||||||
- ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key
|
- ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key
|
||||||
|
|
|
@ -72,13 +72,13 @@
|
||||||
|
|
||||||
## What's Left to Build
|
## 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
|
- ✅ **Add new entries**: Complete AddEntryModal with validation
|
||||||
- ✅ **Delete entries**: Complete DeleteConfirmationModal with safety checks
|
- ✅ **Delete entries**: Complete DeleteConfirmationModal with safety checks
|
||||||
- ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status
|
- ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status
|
||||||
- ✅ **Search functionality**: Complete search/filter by hostname, IP address, or comment
|
- ✅ **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 (planned)
|
||||||
- ❌ ~~**Bulk operations**: Select and modify multiple entries (won't be implemented)~~
|
- ❌ **Undo/Redo**: Command pattern implementation for operation history (planned)
|
||||||
|
|
||||||
### Phase 5: Advanced Features
|
### Phase 5: Advanced Features
|
||||||
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
|
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
|
||||||
|
@ -127,8 +127,7 @@
|
||||||
- **UI Components**: 28 tests for TUI application and modal dialogs
|
- **UI Components**: 28 tests for TUI application and modal dialogs
|
||||||
- **Save Confirmation**: 13 tests for save confirmation modal functionality
|
- **Save Confirmation**: 13 tests for save confirmation modal functionality
|
||||||
- **Config Modal**: 6 tests for configuration modal interface
|
- **Config Modal**: 6 tests for configuration modal interface
|
||||||
- **Commands**: 43 tests for command pattern and undo/redo functionality
|
- **Total**: 149 tests with 100% pass rate and comprehensive edge case coverage
|
||||||
- **Total**: 192 tests with 100% pass rate and comprehensive edge case coverage
|
|
||||||
|
|
||||||
### Code Quality Standards
|
### Code Quality Standards
|
||||||
- **Linting**: All ruff checks passing with clean code
|
- **Linting**: All ruff checks passing with clean code
|
||||||
|
@ -139,25 +138,6 @@
|
||||||
|
|
||||||
## Phase Completion Summaries
|
## 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: Edit Mode Foundation ✅ EXCEPTIONAL SUCCESS
|
||||||
Phase 3 exceeded all objectives with comprehensive edit mode implementation:
|
Phase 3 exceeded all objectives with comprehensive edit mode implementation:
|
||||||
|
|
||||||
|
|
|
@ -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}"
|
|
|
@ -12,15 +12,7 @@ from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
from .models import HostEntry, HostsFile
|
from .models import HostEntry, HostsFile
|
||||||
from .parser import HostsParser
|
from .parser import HostsParser
|
||||||
from .commands import (
|
|
||||||
UndoRedoHistory,
|
|
||||||
ToggleEntryCommand,
|
|
||||||
MoveEntryCommand,
|
|
||||||
AddEntryCommand,
|
|
||||||
DeleteEntryCommand,
|
|
||||||
UpdateEntryCommand,
|
|
||||||
OperationResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
class PermissionManager:
|
class PermissionManager:
|
||||||
"""
|
"""
|
||||||
|
@ -120,6 +112,7 @@ class PermissionManager:
|
||||||
self.has_sudo = False
|
self.has_sudo = False
|
||||||
self._sudo_validated = False
|
self._sudo_validated = False
|
||||||
|
|
||||||
|
|
||||||
class HostsManager:
|
class HostsManager:
|
||||||
"""
|
"""
|
||||||
Main manager for hosts file edit operations.
|
Main manager for hosts file edit operations.
|
||||||
|
@ -133,7 +126,6 @@ class HostsManager:
|
||||||
self.permission_manager = PermissionManager()
|
self.permission_manager = PermissionManager()
|
||||||
self.edit_mode = False
|
self.edit_mode = False
|
||||||
self._backup_path: Optional[Path] = None
|
self._backup_path: Optional[Path] = None
|
||||||
self.undo_redo_history = UndoRedoHistory()
|
|
||||||
|
|
||||||
def enter_edit_mode(self, password: str = None) -> Tuple[bool, str]:
|
def enter_edit_mode(self, password: str = None) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
|
@ -179,7 +171,6 @@ class HostsManager:
|
||||||
self.permission_manager.release_sudo()
|
self.permission_manager.release_sudo()
|
||||||
self.edit_mode = False
|
self.edit_mode = False
|
||||||
self._backup_path = None
|
self._backup_path = None
|
||||||
self.undo_redo_history.clear_history() # Clear undo/redo history when exiting edit mode
|
|
||||||
return True, "Edit mode disabled"
|
return True, "Edit mode disabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Error exiting edit mode: {e}"
|
return False, f"Error exiting edit mode: {e}"
|
||||||
|
@ -421,162 +412,6 @@ class HostsManager:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Error updating entry: {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]:
|
def save_hosts_file(self, hosts_file: HostsFile) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Save the hosts file to disk with sudo permissions.
|
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
|
["sudo", "chmod", "644", str(self._backup_path)], capture_output=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EditModeError(Exception):
|
class EditModeError(Exception):
|
||||||
"""Base exception for edit mode errors."""
|
"""Base exception for edit mode errors."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PermissionError(EditModeError):
|
class PermissionError(EditModeError):
|
||||||
"""Raised when there are permission issues."""
|
"""Raised when there are permission issues."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(EditModeError):
|
class ValidationError(EditModeError):
|
||||||
"""Raised when validation fails."""
|
"""Raised when validation fails."""
|
||||||
|
|
||||||
|
|
|
@ -237,20 +237,7 @@ class HostsManagerApp(App):
|
||||||
mode = "Edit" if self.edit_mode else "Read-only"
|
mode = "Edit" if self.edit_mode else "Read-only"
|
||||||
entry_count = len(self.hosts_file.entries)
|
entry_count = len(self.hosts_file.entries)
|
||||||
active_count = len(self.hosts_file.get_active_entries())
|
active_count = len(self.hosts_file.get_active_entries())
|
||||||
|
status = f"{entry_count} entries ({active_count} active) | {mode}"
|
||||||
# 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}"
|
|
||||||
footer.set_status(status)
|
footer.set_status(status)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Footer not ready yet
|
pass # Footer not ready yet
|
||||||
|
@ -522,22 +509,17 @@ class HostsManagerApp(App):
|
||||||
self.update_status("Entry creation cancelled")
|
self.update_status("Entry creation cancelled")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add the entry using the command-based manager method
|
# Add the entry using the manager
|
||||||
result = self.manager.execute_add_command(self.hosts_file, new_entry)
|
success, message = self.manager.add_entry(self.hosts_file, new_entry)
|
||||||
if result.success:
|
if success:
|
||||||
# Save the changes
|
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
|
||||||
if save_success:
|
|
||||||
# Refresh the table
|
# Refresh the table
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
# Move cursor to the newly added entry (last entry)
|
# Move cursor to the newly added entry (last entry)
|
||||||
self.selected_entry_index = len(self.hosts_file.entries) - 1
|
self.selected_entry_index = len(self.hosts_file.entries) - 1
|
||||||
self.table_handler.restore_cursor_position(new_entry)
|
self.table_handler.restore_cursor_position(new_entry)
|
||||||
self.update_status(f"✅ {result.message} - Changes saved automatically")
|
self.update_status(f"✅ {message}")
|
||||||
else:
|
else:
|
||||||
self.update_status(f"Entry added but save failed: {save_message}")
|
self.update_status(f"❌ {message}")
|
||||||
else:
|
|
||||||
self.update_status(f"❌ {result.message}")
|
|
||||||
|
|
||||||
self.push_screen(AddEntryModal(), handle_add_entry_result)
|
self.push_screen(AddEntryModal(), handle_add_entry_result)
|
||||||
|
|
||||||
|
@ -567,14 +549,11 @@ class HostsManagerApp(App):
|
||||||
self.update_status("Entry deletion cancelled")
|
self.update_status("Entry deletion cancelled")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Delete the entry using the command-based manager method
|
# Delete the entry using the manager
|
||||||
result = self.manager.execute_delete_command(
|
success, message = self.manager.delete_entry(
|
||||||
self.hosts_file, self.selected_entry_index
|
self.hosts_file, self.selected_entry_index
|
||||||
)
|
)
|
||||||
if result.success:
|
if success:
|
||||||
# Save the changes
|
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
|
||||||
if save_success:
|
|
||||||
# Adjust selected index if needed
|
# Adjust selected index if needed
|
||||||
if self.selected_entry_index >= len(self.hosts_file.entries):
|
if self.selected_entry_index >= len(self.hosts_file.entries):
|
||||||
self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1)
|
self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1)
|
||||||
|
@ -582,11 +561,9 @@ class HostsManagerApp(App):
|
||||||
# Refresh the table
|
# Refresh the table
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
self.update_status(f"✅ {result.message} - Changes saved automatically")
|
self.update_status(f"✅ {message}")
|
||||||
else:
|
else:
|
||||||
self.update_status(f"Entry deleted but save failed: {save_message}")
|
self.update_status(f"❌ {message}")
|
||||||
else:
|
|
||||||
self.update_status(f"❌ {result.message}")
|
|
||||||
|
|
||||||
self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation)
|
self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation)
|
||||||
|
|
||||||
|
@ -594,52 +571,6 @@ class HostsManagerApp(App):
|
||||||
"""Quit the application."""
|
"""Quit the application."""
|
||||||
self.navigation_handler.quit_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
|
# Delegated methods for backward compatibility with tests
|
||||||
def has_entry_changes(self) -> bool:
|
def has_entry_changes(self) -> bool:
|
||||||
"""Check if the current entry has been modified from its original values."""
|
"""Check if the current entry has been modified from its original values."""
|
||||||
|
|
|
@ -42,8 +42,6 @@ HOSTS_MANAGER_BINDINGS = [
|
||||||
Binding("ctrl+s", "save_file", "Save hosts file", show=False),
|
Binding("ctrl+s", "save_file", "Save hosts file", show=False),
|
||||||
Binding("shift+up", "move_entry_up", "Move entry up", 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("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("escape", "exit_edit_entry", "Exit edit mode", show=False),
|
||||||
Binding("tab", "next_field", "Next field", show=False),
|
Binding("tab", "next_field", "Next field", show=False),
|
||||||
Binding("shift+tab", "prev_field", "Previous field", show=False),
|
Binding("shift+tab", "prev_field", "Previous field", show=False),
|
||||||
|
|
|
@ -30,11 +30,10 @@ class NavigationHandler:
|
||||||
# Remember current entry for cursor position restoration
|
# Remember current entry for cursor position restoration
|
||||||
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
current_entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
|
||||||
# Use command-based method for undo/redo support
|
success, message = self.app.manager.toggle_entry(
|
||||||
result = self.app.manager.execute_toggle_command(
|
|
||||||
self.app.hosts_file, self.app.selected_entry_index
|
self.app.hosts_file, self.app.selected_entry_index
|
||||||
)
|
)
|
||||||
if result.success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
save_success, save_message = self.app.manager.save_hosts_file(
|
||||||
self.app.hosts_file
|
self.app.hosts_file
|
||||||
|
@ -49,11 +48,11 @@ class NavigationHandler:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.app.details_handler.update_entry_details()
|
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:
|
else:
|
||||||
self.app.update_status(f"Entry toggled but save failed: {save_message}")
|
self.app.update_status(f"Entry toggled but save failed: {save_message}")
|
||||||
else:
|
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:
|
def move_entry_up(self) -> None:
|
||||||
"""Move the selected entry up in the list."""
|
"""Move the selected entry up in the list."""
|
||||||
|
@ -67,11 +66,10 @@ class NavigationHandler:
|
||||||
self.app.update_status("No entries to move")
|
self.app.update_status("No entries to move")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use command-based method for undo/redo support
|
success, message = self.app.manager.move_entry_up(
|
||||||
result = self.app.manager.execute_move_command(
|
self.app.hosts_file, self.app.selected_entry_index
|
||||||
self.app.hosts_file, self.app.selected_entry_index, "up"
|
|
||||||
)
|
)
|
||||||
if result.success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
save_success, save_message = self.app.manager.save_hosts_file(
|
||||||
self.app.hosts_file
|
self.app.hosts_file
|
||||||
|
@ -89,11 +87,11 @@ class NavigationHandler:
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
self.app.details_handler.update_entry_details()
|
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:
|
else:
|
||||||
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
||||||
else:
|
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:
|
def move_entry_down(self) -> None:
|
||||||
"""Move the selected entry down in the list."""
|
"""Move the selected entry down in the list."""
|
||||||
|
@ -107,11 +105,10 @@ class NavigationHandler:
|
||||||
self.app.update_status("No entries to move")
|
self.app.update_status("No entries to move")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use command-based method for undo/redo support
|
success, message = self.app.manager.move_entry_down(
|
||||||
result = self.app.manager.execute_move_command(
|
self.app.hosts_file, self.app.selected_entry_index
|
||||||
self.app.hosts_file, self.app.selected_entry_index, "down"
|
|
||||||
)
|
)
|
||||||
if result.success:
|
if success:
|
||||||
# Auto-save the changes immediately
|
# Auto-save the changes immediately
|
||||||
save_success, save_message = self.app.manager.save_hosts_file(
|
save_success, save_message = self.app.manager.save_hosts_file(
|
||||||
self.app.hosts_file
|
self.app.hosts_file
|
||||||
|
@ -129,11 +126,11 @@ class NavigationHandler:
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
self.app.details_handler.update_entry_details()
|
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:
|
else:
|
||||||
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
||||||
else:
|
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:
|
def save_hosts_file(self) -> None:
|
||||||
"""Save the hosts file to disk."""
|
"""Save the hosts file to disk."""
|
||||||
|
|
|
@ -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__])
|
|
|
@ -267,10 +267,6 @@ class TestHostsManagerApp:
|
||||||
):
|
):
|
||||||
app = HostsManagerApp()
|
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
|
# Add test entries
|
||||||
app.hosts_file = HostsFile()
|
app.hosts_file = HostsFile()
|
||||||
app.hosts_file.add_entry(
|
app.hosts_file.add_entry(
|
||||||
|
@ -284,12 +280,10 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
app.update_status()
|
app.update_status()
|
||||||
|
|
||||||
# Verify footer status was updated
|
# Verify sub_title was set correctly
|
||||||
mock_footer.set_status.assert_called_once()
|
assert "Read-only mode" in app.sub_title
|
||||||
status_call = mock_footer.set_status.call_args[0][0]
|
assert "2 entries" in app.sub_title
|
||||||
assert "Read-only" in status_call
|
assert "1 active" in app.sub_title
|
||||||
assert "2 entries" in status_call
|
|
||||||
assert "1 active" in status_call
|
|
||||||
|
|
||||||
def test_update_status_custom_message(self):
|
def test_update_status_custom_message(self):
|
||||||
"""Test status bar update with custom message."""
|
"""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
|
# Mock set_timer and query_one to avoid event loop and UI issues
|
||||||
app.set_timer = Mock()
|
app.set_timer = Mock()
|
||||||
mock_status_bar = 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):
|
# Add test hosts_file for subtitle generation
|
||||||
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
|
|
||||||
app.hosts_file = HostsFile()
|
app.hosts_file = HostsFile()
|
||||||
app.hosts_file.add_entry(
|
app.hosts_file.add_entry(
|
||||||
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
@ -332,11 +317,9 @@ class TestHostsManagerApp:
|
||||||
# Verify status bar was updated with custom message
|
# Verify status bar was updated with custom message
|
||||||
mock_status_bar.update.assert_called_with("Custom status message")
|
mock_status_bar.update.assert_called_with("Custom status message")
|
||||||
mock_status_bar.remove_class.assert_called_with("hidden")
|
mock_status_bar.remove_class.assert_called_with("hidden")
|
||||||
# Verify footer status was updated with current status (not the custom message)
|
# Verify subtitle shows current status (not the custom message)
|
||||||
mock_footer.set_status.assert_called_once()
|
assert "2 entries" in app.sub_title
|
||||||
footer_status = mock_footer.set_status.call_args[0][0]
|
assert "Read-only mode" in app.sub_title
|
||||||
assert "2 entries" in footer_status
|
|
||||||
assert "Read-only" in footer_status
|
|
||||||
# Verify timer was set for auto-clearing
|
# Verify timer was set for auto-clearing
|
||||||
app.set_timer.assert_called_once()
|
app.set_timer.assert_called_once()
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue