Fix #13: Expose Pre-edit Backup restoration
Preserve full diff opcodes before grouping, route modal navigation to a two-axis viewport-sized scroller, and calculate side-by-side layout using terminal cell widths so tabbed Hosts File lines keep a stable divider.
This commit is contained in:
parent
6a3e1e0d5a
commit
e268794564
13 changed files with 1103 additions and 19 deletions
|
|
@ -20,6 +20,7 @@ on macOS and Linux.
|
||||||
- Creates DNS Entries and manually refreshes their resolved IP addresses.
|
- Creates DNS Entries and manually refreshes their resolved IP addresses.
|
||||||
- Gates every `/etc/hosts` mutation behind an explicit Privileged Mode.
|
- Gates every `/etc/hosts` mutation behind an explicit Privileged Mode.
|
||||||
- Creates a timestamped Pre-edit Backup before Privileged Mode begins.
|
- Creates a timestamped Pre-edit Backup before Privileged Mode begins.
|
||||||
|
- Restores the current session's Pre-edit Backup after explicit confirmation.
|
||||||
- Supports undo and redo during the current Privileged Mode session.
|
- Supports undo and redo during the current Privileged Mode session.
|
||||||
|
|
||||||
The application starts in Read-only Mode. Default Entries cannot be edited,
|
The application starts in Read-only Mode. Default Entries cannot be edited,
|
||||||
|
|
@ -63,6 +64,7 @@ including persistence behavior and manual recovery.
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Up` / `Down` | Select a Host Entry |
|
| `Up` / `Down` | Select a Host Entry |
|
||||||
| `Ctrl+E` | Enter or leave Privileged Mode |
|
| `Ctrl+E` | Enter or leave Privileged Mode |
|
||||||
|
| `b` | Review and restore the session Pre-edit Backup |
|
||||||
| `n` | Add a Host Entry |
|
| `n` | Add a Host Entry |
|
||||||
| `e` | Open the selected Host Entry in the Entry Editor |
|
| `e` | Open the selected Host Entry in the Entry Editor |
|
||||||
| `d` | Delete the selected Host Entry |
|
| `d` | Delete the selected Host Entry |
|
||||||
|
|
@ -79,8 +81,8 @@ sorting, DNS refresh, movement, undo, and redo.
|
||||||
## Safety and known limitations
|
## Safety and known limitations
|
||||||
|
|
||||||
- Reloading discards unsaved in-memory state.
|
- Reloading discards unsaved in-memory state.
|
||||||
- Pre-edit Backups are not listed or restored by the TUI and have no retention
|
- The TUI can restore only the Pre-edit Backup associated with the current
|
||||||
management. Manual recovery is documented in the user guide.
|
Privileged Mode session; it does not list old backups or manage retention.
|
||||||
- Leaving Privileged Mode clears the application's session state but does not
|
- Leaving Privileged Mode clears the application's session state but does not
|
||||||
alter the user's cached sudo timestamp.
|
alter the user's cached sudo timestamp.
|
||||||
- Serialization preserves Host Entries and comments semantically, but
|
- Serialization preserves Host Entries and comments semantically, but
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,9 @@ The application starts read-only and permits changes to `/etc/hosts` only after
|
||||||
sudo access has been validated and a timestamped backup has been created. All
|
sudo access has been validated and a timestamped backup has been created. All
|
||||||
privileged writes go through `HostsManager` during that mode. This makes the
|
privileged writes go through `HostsManager` during that mode. This makes the
|
||||||
authorization boundary visible to the user and guarantees a pre-edit safety
|
authorization boundary visible to the user and guarantees a pre-edit safety
|
||||||
snapshot; the temporary backup is not a user-facing recovery system.
|
snapshot. While Privileged Mode remains active, the TUI exposes that session's
|
||||||
|
exact Pre-edit Backup path and can restore it after explicit confirmation. The
|
||||||
|
restore confirmation identifies both the backup source and Hosts File target;
|
||||||
|
it presents an exact-file Git-style diff and rechecks both reviewed files before
|
||||||
|
restoring. On success the application reloads the Hosts File and clears
|
||||||
|
undo/redo history.
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,8 @@ enabling mutations it verifies write access and creates a Pre-edit Backup.
|
||||||
If authorization, permission validation, or backup creation fails, the
|
If authorization, permission validation, or backup creation fails, the
|
||||||
application remains in Read-only Mode.
|
application remains in Read-only Mode.
|
||||||
|
|
||||||
The footer shows `Edit` while Privileged Mode is active. This label refers to
|
The footer shows `PRIVILEGED` while Privileged Mode is active. This label is
|
||||||
Privileged Mode; it is separate from the Entry Editor used to change one Host
|
separate from the Entry Editor used to change one Host Entry.
|
||||||
Entry.
|
|
||||||
|
|
||||||
## Change a Host Entry
|
## Change a Host Entry
|
||||||
|
|
||||||
|
|
@ -127,11 +126,29 @@ If an immediate save fails, the application restores the complete pre-action
|
||||||
in-memory state, visible selection, and undo/redo history. A later mutation
|
in-memory state, visible selection, and undo/redo history. A later mutation
|
||||||
therefore cannot accidentally persist the failed change.
|
therefore cannot accidentally persist the failed change.
|
||||||
|
|
||||||
### Locate a Pre-edit Backup
|
### Restore the session Pre-edit Backup
|
||||||
|
|
||||||
Entering Privileged Mode creates one timestamped Pre-edit Backup below the
|
Entering Privileged Mode creates one timestamped Pre-edit Backup below the
|
||||||
operating system's temporary directory. The application does not currently show
|
operating system's temporary directory. While that Privileged Mode session is
|
||||||
this path. Ask Python for the directory used on your system:
|
active, press `b` to open the restore review. It displays the exact backup
|
||||||
|
source path and `/etc/hosts` target path, then shows a Git-style diff of the
|
||||||
|
current Hosts File (before) and Pre-edit Backup (after restoration). At 120 or
|
||||||
|
more columns the diff is side by side; at smaller supported sizes it is a
|
||||||
|
unified diff. Use `[` and `]` to move between changed hunks, `f` to toggle the
|
||||||
|
full diff, and `r` to refresh it.
|
||||||
|
|
||||||
|
Choose **Restore Hosts File** only when those paths are the ones you intend.
|
||||||
|
The application copies the displayed backup over the Hosts File, reloads the
|
||||||
|
on-disk contents into the interface, and clears undo/redo history. If the copy
|
||||||
|
fails, the current Hosts File and interface remain unchanged and the status
|
||||||
|
message names the failure. The application checks both displayed files again
|
||||||
|
when you confirm; if either changed, it refreshes the diff and requires a new
|
||||||
|
confirmation. Cancel leaves both files unchanged.
|
||||||
|
|
||||||
|
The TUI does not list old backups, apply retention rules, or retain the session
|
||||||
|
association after leaving Privileged Mode. For recovery outside that session,
|
||||||
|
locate and restore a backup manually. Ask Python for the temporary directory on
|
||||||
|
your system:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -c 'import tempfile; print(tempfile.gettempdir() + "/hosts-manager-backups")'
|
python3 -c 'import tempfile; print(tempfile.gettempdir() + "/hosts-manager-backups")'
|
||||||
|
|
@ -148,9 +165,10 @@ The application does not remove old Pre-edit Backups, apply retention rules, or
|
||||||
record which file belongs to a later session. Do not choose a backup solely
|
record which file belongs to a later session. Do not choose a backup solely
|
||||||
because it is the newest; inspect its timestamp and contents first.
|
because it is the newest; inspect its timestamp and contents first.
|
||||||
|
|
||||||
### Restore manually
|
### Restore manually outside the session
|
||||||
|
|
||||||
Restoration is not available through the TUI. To restore manually:
|
To restore a backup that is no longer associated with an active Privileged Mode
|
||||||
|
session:
|
||||||
|
|
||||||
1. Leave the application or return it to Read-only Mode.
|
1. Leave the application or return it to Read-only Mode.
|
||||||
2. Locate and inspect the intended Pre-edit Backup.
|
2. Locate and inspect the intended Pre-edit Backup.
|
||||||
|
|
@ -243,6 +261,7 @@ continually retrying it.
|
||||||
| `Shift+R` | Refresh all DNS Entries |
|
| `Shift+R` | Refresh all DNS Entries |
|
||||||
| `Ctrl+Z` / `Ctrl+Y` | Undo or redo and save the result |
|
| `Ctrl+Z` / `Ctrl+Y` | Undo or redo and save the result |
|
||||||
| `Ctrl+S` | Save the current in-memory state |
|
| `Ctrl+S` | Save the current in-memory state |
|
||||||
|
| `b` | Review session backup changes and confirm restoration |
|
||||||
|
|
||||||
Within the Entry Editor, use `Tab` and `Shift+Tab` to move between fields and
|
Within the Entry Editor, use `Tab` and `Shift+Tab` to move between fields and
|
||||||
`Escape` to leave the form. If values changed, the application asks whether to
|
`Escape` to leave the form. If values changed, the application asks whether to
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ and safe file modifications with backup and validation.
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from uuid import uuid4
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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 .restore_preview import RestorePreviewError, fingerprint_file
|
||||||
from .commands import (
|
from .commands import (
|
||||||
UndoRedoHistory,
|
UndoRedoHistory,
|
||||||
ToggleEntryCommand,
|
ToggleEntryCommand,
|
||||||
|
|
@ -132,6 +134,11 @@ class HostsManager:
|
||||||
self._backup_path: Optional[Path] = None
|
self._backup_path: Optional[Path] = None
|
||||||
self.undo_redo_history = UndoRedoHistory()
|
self.undo_redo_history = UndoRedoHistory()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_path(self) -> Optional[Path]:
|
||||||
|
"""Return the Pre-edit Backup associated with this Privileged Mode session."""
|
||||||
|
return self._backup_path
|
||||||
|
|
||||||
def enter_edit_mode(self) -> Tuple[bool, str]:
|
def enter_edit_mode(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enter edit mode with proper permission management.
|
Enter edit mode with proper permission management.
|
||||||
|
|
@ -668,7 +675,11 @@ class HostsManager:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Error saving hosts file: {e}"
|
return False, f"Error saving hosts file: {e}"
|
||||||
|
|
||||||
def restore_backup(self) -> Tuple[bool, str]:
|
def restore_backup(
|
||||||
|
self,
|
||||||
|
expected_backup_digest: str | None = None,
|
||||||
|
expected_hosts_file_digest: str | None = None,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Restore the hosts file from backup.
|
Restore the hosts file from backup.
|
||||||
|
|
||||||
|
|
@ -681,19 +692,48 @@ class HostsManager:
|
||||||
if not self._backup_path or not self._backup_path.exists():
|
if not self._backup_path or not self._backup_path.exists():
|
||||||
return False, "No backup available"
|
return False, "No backup available"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
expected_backup_digest is not None
|
||||||
|
and fingerprint_file(self._backup_path, "Pre-edit Backup")
|
||||||
|
!= expected_backup_digest
|
||||||
|
):
|
||||||
|
return False, "Pre-edit Backup changed since review"
|
||||||
|
if (
|
||||||
|
expected_hosts_file_digest is not None
|
||||||
|
and fingerprint_file(self.parser.file_path, "Hosts File")
|
||||||
|
!= expected_hosts_file_digest
|
||||||
|
):
|
||||||
|
return False, "Hosts File changed since review"
|
||||||
|
except RestorePreviewError as error:
|
||||||
|
return False, str(error)
|
||||||
|
|
||||||
|
restore_path = self.parser.file_path.with_name(
|
||||||
|
f".{self.parser.file_path.name}.restore-{uuid4().hex}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "cp", str(self._backup_path), str(self.parser.file_path)],
|
["sudo", "cp", str(self._backup_path), str(restore_path)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode != 0:
|
||||||
return True, "Backup restored successfully"
|
|
||||||
else:
|
|
||||||
return False, f"Failed to restore backup: {result.stderr}"
|
return False, f"Failed to restore backup: {result.stderr}"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["sudo", "mv", str(restore_path), str(self.parser.file_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, f"Failed to replace hosts file: {result.stderr}"
|
||||||
|
|
||||||
|
self.undo_redo_history.clear_history()
|
||||||
|
return True, "Backup restored successfully"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Error restoring backup: {e}"
|
return False, f"Error restoring backup: {e}"
|
||||||
|
|
||||||
|
|
|
||||||
178
src/hosts/core/restore_preview.py
Normal file
178
src/hosts/core/restore_preview.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Exact-file diffing and freshness checks for Pre-edit Backup restoration."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from hashlib import sha256
|
||||||
|
from itertools import zip_longest
|
||||||
|
from pathlib import Path
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class RestorePreviewError(Exception):
|
||||||
|
"""A file cannot safely be shown in a restore preview."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RestoreDiffRow:
|
||||||
|
"""One aligned current-to-restored line pair."""
|
||||||
|
|
||||||
|
current_line_number: int | None
|
||||||
|
restored_line_number: int | None
|
||||||
|
current_text: str | None
|
||||||
|
restored_text: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RestoreDiffHunk:
|
||||||
|
"""A contiguous group of aligned diff rows."""
|
||||||
|
|
||||||
|
current_start: int
|
||||||
|
current_count: int
|
||||||
|
restored_start: int
|
||||||
|
restored_count: int
|
||||||
|
rows: tuple[RestoreDiffRow, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RestorePreview:
|
||||||
|
"""The reviewed source, target, and deterministic line-level diff."""
|
||||||
|
|
||||||
|
backup_path: Path
|
||||||
|
hosts_file_path: Path
|
||||||
|
backup_digest: str
|
||||||
|
hosts_file_digest: str
|
||||||
|
current_has_final_newline: bool
|
||||||
|
restored_has_final_newline: bool
|
||||||
|
changed_hunks: tuple[RestoreDiffHunk, ...]
|
||||||
|
full_hunks: tuple[RestoreDiffHunk, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_changes(self) -> bool:
|
||||||
|
"""Whether copying the Pre-edit Backup would alter the Hosts File."""
|
||||||
|
return self.backup_digest != self.hosts_file_digest
|
||||||
|
|
||||||
|
def matches_files(self) -> bool:
|
||||||
|
"""Return whether both files still match the reviewed contents."""
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
fingerprint_file(self.backup_path, "Pre-edit Backup")
|
||||||
|
== self.backup_digest
|
||||||
|
and fingerprint_file(self.hosts_file_path, "Hosts File")
|
||||||
|
== self.hosts_file_digest
|
||||||
|
)
|
||||||
|
except RestorePreviewError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint_file(path: Path, label: str) -> str:
|
||||||
|
"""Read and fingerprint a file, raising an actionable preview error."""
|
||||||
|
try:
|
||||||
|
return sha256(path.read_bytes()).hexdigest()
|
||||||
|
except OSError as error:
|
||||||
|
raise RestorePreviewError(f"Cannot read {label} at {path}: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def create_restore_preview(backup_path: Path, hosts_file_path: Path) -> RestorePreview:
|
||||||
|
"""Read both exact files and prepare changed and full line-level hunks."""
|
||||||
|
backup_bytes = _read_bytes(backup_path, "Pre-edit Backup")
|
||||||
|
hosts_file_bytes = _read_bytes(hosts_file_path, "Hosts File")
|
||||||
|
backup_text = _decode(backup_bytes, backup_path, "Pre-edit Backup")
|
||||||
|
hosts_file_text = _decode(hosts_file_bytes, hosts_file_path, "Hosts File")
|
||||||
|
restored_lines = backup_text.splitlines()
|
||||||
|
current_lines = hosts_file_text.splitlines()
|
||||||
|
matcher = SequenceMatcher(None, current_lines, restored_lines, autojunk=False)
|
||||||
|
# get_grouped_opcodes() trims the cached opcode list in place. Preserve an
|
||||||
|
# immutable copy first so Full diff always retains the complete files.
|
||||||
|
opcodes = tuple(matcher.get_opcodes())
|
||||||
|
|
||||||
|
return RestorePreview(
|
||||||
|
backup_path=backup_path,
|
||||||
|
hosts_file_path=hosts_file_path,
|
||||||
|
backup_digest=sha256(backup_bytes).hexdigest(),
|
||||||
|
hosts_file_digest=sha256(hosts_file_bytes).hexdigest(),
|
||||||
|
current_has_final_newline=hosts_file_bytes.endswith(b"\n"),
|
||||||
|
restored_has_final_newline=backup_bytes.endswith(b"\n"),
|
||||||
|
changed_hunks=tuple(
|
||||||
|
_make_hunk(group, current_lines, restored_lines)
|
||||||
|
for group in matcher.get_grouped_opcodes(3)
|
||||||
|
),
|
||||||
|
full_hunks=(_make_hunk(opcodes, current_lines, restored_lines),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bytes(path: Path, label: str) -> bytes:
|
||||||
|
try:
|
||||||
|
return path.read_bytes()
|
||||||
|
except OSError as error:
|
||||||
|
raise RestorePreviewError(f"Cannot read {label} at {path}: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(contents: bytes, path: Path, label: str) -> str:
|
||||||
|
try:
|
||||||
|
return contents.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as error:
|
||||||
|
raise RestorePreviewError(
|
||||||
|
f"Cannot preview {label} at {path}: it is not valid UTF-8 text"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hunk(
|
||||||
|
opcodes: Sequence[tuple[str, int, int, int, int]],
|
||||||
|
current_lines: list[str],
|
||||||
|
restored_lines: list[str],
|
||||||
|
) -> RestoreDiffHunk:
|
||||||
|
current_start = opcodes[0][1] + 1 if opcodes else 1
|
||||||
|
restored_start = opcodes[0][3] + 1 if opcodes else 1
|
||||||
|
current_end = opcodes[-1][2] if opcodes else 0
|
||||||
|
restored_end = opcodes[-1][4] if opcodes else 0
|
||||||
|
rows: list[RestoreDiffRow] = []
|
||||||
|
for (
|
||||||
|
tag,
|
||||||
|
current_start_index,
|
||||||
|
current_end_index,
|
||||||
|
restored_start_index,
|
||||||
|
restored_end_index,
|
||||||
|
) in opcodes:
|
||||||
|
current_range = range(current_start_index, current_end_index)
|
||||||
|
restored_range = range(restored_start_index, restored_end_index)
|
||||||
|
if tag == "equal":
|
||||||
|
rows.extend(
|
||||||
|
RestoreDiffRow(
|
||||||
|
current_index + 1,
|
||||||
|
restored_index + 1,
|
||||||
|
current_lines[current_index],
|
||||||
|
restored_lines[restored_index],
|
||||||
|
)
|
||||||
|
for current_index, restored_index in zip(current_range, restored_range)
|
||||||
|
)
|
||||||
|
elif tag == "delete":
|
||||||
|
rows.extend(
|
||||||
|
RestoreDiffRow(index + 1, None, current_lines[index], None)
|
||||||
|
for index in current_range
|
||||||
|
)
|
||||||
|
elif tag == "insert":
|
||||||
|
rows.extend(
|
||||||
|
RestoreDiffRow(None, index + 1, None, restored_lines[index])
|
||||||
|
for index in restored_range
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows.extend(
|
||||||
|
RestoreDiffRow(
|
||||||
|
current_index + 1 if current_index is not None else None,
|
||||||
|
restored_index + 1 if restored_index is not None else None,
|
||||||
|
current_lines[current_index] if current_index is not None else None,
|
||||||
|
restored_lines[restored_index]
|
||||||
|
if restored_index is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for current_index, restored_index in zip_longest(
|
||||||
|
current_range, restored_range
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return RestoreDiffHunk(
|
||||||
|
current_start=current_start,
|
||||||
|
current_count=current_end - current_start + 1,
|
||||||
|
restored_start=restored_start,
|
||||||
|
restored_count=restored_end - restored_start + 1,
|
||||||
|
rows=tuple(rows),
|
||||||
|
)
|
||||||
|
|
@ -5,6 +5,7 @@ This module contains the main application class that orchestrates
|
||||||
all the handlers and provides the primary user interface.
|
all the handlers and provides the primary user interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from textual.app import App, ComposeResult, SuspendNotSupported
|
from textual.app import App, ComposeResult, SuspendNotSupported
|
||||||
|
|
@ -25,10 +26,12 @@ from ..core.parser import HostsParser
|
||||||
from ..core.models import HostEntry, HostsFile
|
from ..core.models import HostEntry, HostsFile
|
||||||
from ..core.config import Config
|
from ..core.config import Config
|
||||||
from ..core.manager import HostsManager, MutationState
|
from ..core.manager import HostsManager, MutationState
|
||||||
|
from ..core.restore_preview import RestorePreviewError, create_restore_preview
|
||||||
from ..core.dns import DNSService
|
from ..core.dns import DNSService
|
||||||
from ..core.filters import EntryFilter, FilterOptions
|
from ..core.filters import EntryFilter, FilterOptions
|
||||||
from .config_modal import ConfigModal
|
from .config_modal import ConfigModal
|
||||||
from .add_entry_modal import AddEntryModal
|
from .add_entry_modal import AddEntryModal
|
||||||
|
from .backup_restore_modal import BackupRestoreModal
|
||||||
from .delete_confirmation_modal import DeleteConfirmationModal
|
from .delete_confirmation_modal import DeleteConfirmationModal
|
||||||
from .filter_modal import FilterModal
|
from .filter_modal import FilterModal
|
||||||
from .help_modal import HelpModal
|
from .help_modal import HelpModal
|
||||||
|
|
@ -718,6 +721,65 @@ class HostsManagerApp(App):
|
||||||
else:
|
else:
|
||||||
self.update_status(f"Error entering edit mode: {message}")
|
self.update_status(f"Error entering edit mode: {message}")
|
||||||
|
|
||||||
|
def action_restore_backup(self) -> None:
|
||||||
|
"""Prepare a reviewed restore flow for the Pre-edit Backup session."""
|
||||||
|
if not self._allow_mutation_action():
|
||||||
|
return
|
||||||
|
if self.entry_edit_mode:
|
||||||
|
self.update_status(
|
||||||
|
"Finish or cancel the Entry Editor before restoring the Pre-edit Backup."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
backup_path = self.manager.backup_path
|
||||||
|
if not self.edit_mode or backup_path is None:
|
||||||
|
self.update_status(
|
||||||
|
"No session Pre-edit Backup is available. Enter Privileged Mode first."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
hosts_file_path = self.manager.parser.file_path
|
||||||
|
|
||||||
|
async def prepare_restore_preview() -> None:
|
||||||
|
try:
|
||||||
|
preview = await asyncio.to_thread(
|
||||||
|
create_restore_preview, backup_path, hosts_file_path
|
||||||
|
)
|
||||||
|
except RestorePreviewError as error:
|
||||||
|
self.update_status(f"Cannot preview Pre-edit Backup: {error}")
|
||||||
|
return
|
||||||
|
|
||||||
|
async def refresh_preview():
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
create_restore_preview, backup_path, hosts_file_path
|
||||||
|
)
|
||||||
|
|
||||||
|
modal = BackupRestoreModal(preview, refresh_preview)
|
||||||
|
|
||||||
|
def handle_restore_confirmation(confirmed: bool | None) -> None:
|
||||||
|
if not confirmed:
|
||||||
|
return
|
||||||
|
|
||||||
|
success, message = self.manager.restore_backup(
|
||||||
|
modal.preview.backup_digest, modal.preview.hosts_file_digest
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
self.load_hosts_file()
|
||||||
|
self.update_status(
|
||||||
|
f"Pre-edit Backup restored from {backup_path} to {hosts_file_path}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.update_status(
|
||||||
|
"Failed to restore Pre-edit Backup from "
|
||||||
|
f"{backup_path} to {hosts_file_path}: {message}. "
|
||||||
|
"The Hosts File and interface are unchanged. "
|
||||||
|
"Check write access and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.push_screen(modal, handle_restore_confirmation)
|
||||||
|
|
||||||
|
self.run_worker(prepare_restore_preview(), exclusive=False)
|
||||||
|
self.update_status("Preparing Pre-edit Backup diff…")
|
||||||
|
|
||||||
def _enter_edit_mode_interactively(self) -> None:
|
def _enter_edit_mode_interactively(self) -> None:
|
||||||
"""Run one foreground sudo/PAM conversation outside the TUI."""
|
"""Run one foreground sudo/PAM conversation outside the TUI."""
|
||||||
interrupted = False
|
interrupted = False
|
||||||
|
|
|
||||||
358
src/hosts/tui/backup_restore_modal.py
Normal file
358
src/hosts/tui/backup_restore_modal.py
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
"""Git-style review and confirmation for Pre-edit Backup restoration."""
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
from rich.cells import cell_len
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Button, Static
|
||||||
|
|
||||||
|
from ..core.restore_preview import RestoreDiffRow, RestorePreview, RestorePreviewError
|
||||||
|
|
||||||
|
|
||||||
|
class BackupRestoreModal(ModalScreen[bool]):
|
||||||
|
"""Show exactly what restoring the session backup would change."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "cancel", "Cancel"),
|
||||||
|
Binding("r", "refresh", "Refresh preview"),
|
||||||
|
Binding("f", "toggle_full", "Toggle full diff"),
|
||||||
|
Binding("[", "previous_hunk", "Previous hunk"),
|
||||||
|
Binding("]", "next_hunk", "Next hunk"),
|
||||||
|
Binding("up", "scroll_up", show=False, priority=True),
|
||||||
|
Binding("down", "scroll_down", show=False, priority=True),
|
||||||
|
Binding("left", "scroll_left", show=False, priority=True),
|
||||||
|
Binding("right", "scroll_right", show=False, priority=True),
|
||||||
|
Binding("pageup", "page_up", show=False, priority=True),
|
||||||
|
Binding("pagedown", "page_down", show=False, priority=True),
|
||||||
|
Binding("home", "scroll_home", show=False, priority=True),
|
||||||
|
Binding("end", "scroll_end", show=False, priority=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
BackupRestoreModal { align: center middle; }
|
||||||
|
#backup-restore-container {
|
||||||
|
width: 1fr; height: 90%; max-height: 90%; margin: 0 2;
|
||||||
|
background: $surface; border: thick $error; padding: 1 2;
|
||||||
|
}
|
||||||
|
.backup-restore-title { text-align: center; text-style: bold; color: $error; }
|
||||||
|
.backup-restore-copy { margin-top: 1; color: $text-muted; }
|
||||||
|
.backup-restore-path { color: $primary; text-style: bold; }
|
||||||
|
#backup-restore-status { height: 1; margin-top: 1; color: $warning; }
|
||||||
|
#backup-diff-scroll { height: 1fr; margin-top: 1; border: round $primary; }
|
||||||
|
#backup-diff { width: auto; height: auto; }
|
||||||
|
#restore-button { margin-left: 1; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
preview: RestorePreview,
|
||||||
|
refresh_preview: Callable[[], Awaitable[RestorePreview]],
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.preview = preview
|
||||||
|
self._refresh_preview = refresh_preview
|
||||||
|
self._show_full = False
|
||||||
|
self._hunk_index = 0
|
||||||
|
self._hunk_offsets: list[int] = []
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="backup-restore-container"):
|
||||||
|
yield Static("Restore Pre-edit Backup", classes="backup-restore-title")
|
||||||
|
yield Static(
|
||||||
|
"Review the exact current Hosts File before replacing it with the session backup.",
|
||||||
|
classes="backup-restore-copy",
|
||||||
|
)
|
||||||
|
yield Static(id="backup-current-path", classes="backup-restore-path")
|
||||||
|
yield Static(id="backup-restored-path", classes="backup-restore-path")
|
||||||
|
yield Static(id="backup-restore-status")
|
||||||
|
with ScrollableContainer(id="backup-diff-scroll"):
|
||||||
|
yield Static(id="backup-diff", expand=False, shrink=False)
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield Button("Cancel", id="cancel-button", variant="default")
|
||||||
|
yield Button("Restore Hosts File", id="restore-button", variant="error")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#cancel-button", Button).focus()
|
||||||
|
self._render_preview()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "restore-button":
|
||||||
|
self.action_confirm()
|
||||||
|
elif event.button.id == "cancel-button":
|
||||||
|
self.action_cancel()
|
||||||
|
|
||||||
|
def action_refresh(self) -> None:
|
||||||
|
self.run_worker(self._refresh(), exclusive=True)
|
||||||
|
|
||||||
|
def action_toggle_full(self) -> None:
|
||||||
|
self._show_full = not self._show_full
|
||||||
|
self._hunk_index = 0
|
||||||
|
self._render_preview()
|
||||||
|
|
||||||
|
def action_next_hunk(self) -> None:
|
||||||
|
self._jump_hunk(1)
|
||||||
|
|
||||||
|
def action_previous_hunk(self) -> None:
|
||||||
|
self._jump_hunk(-1)
|
||||||
|
|
||||||
|
def action_scroll_up(self) -> None:
|
||||||
|
self._scroller().scroll_up(animate=False)
|
||||||
|
|
||||||
|
def action_scroll_down(self) -> None:
|
||||||
|
self._scroller().scroll_down(animate=False)
|
||||||
|
|
||||||
|
def action_scroll_left(self) -> None:
|
||||||
|
self._scroller().scroll_left(animate=False)
|
||||||
|
|
||||||
|
def action_scroll_right(self) -> None:
|
||||||
|
self._scroller().scroll_right(animate=False)
|
||||||
|
|
||||||
|
def action_page_up(self) -> None:
|
||||||
|
scroller = self._scroller()
|
||||||
|
scroller.scroll_to(y=scroller.scroll_y - scroller.size.height, animate=False)
|
||||||
|
|
||||||
|
def action_page_down(self) -> None:
|
||||||
|
scroller = self._scroller()
|
||||||
|
scroller.scroll_to(y=scroller.scroll_y + scroller.size.height, animate=False)
|
||||||
|
|
||||||
|
def action_scroll_home(self) -> None:
|
||||||
|
self._scroller().scroll_to(x=0, y=0, animate=False)
|
||||||
|
|
||||||
|
def action_scroll_end(self) -> None:
|
||||||
|
scroller = self._scroller()
|
||||||
|
scroller.scroll_to(y=scroller.max_scroll_y, animate=False)
|
||||||
|
|
||||||
|
def action_confirm(self) -> None:
|
||||||
|
if self.preview.has_changes:
|
||||||
|
self.run_worker(self._confirm(), exclusive=True)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
async def _refresh(self) -> None:
|
||||||
|
self._set_status("Refreshing exact-file diff…")
|
||||||
|
try:
|
||||||
|
self.preview = await self._refresh_preview()
|
||||||
|
except RestorePreviewError as error:
|
||||||
|
self._set_status(str(error))
|
||||||
|
self.query_one("#restore-button", Button).disabled = True
|
||||||
|
return
|
||||||
|
self._hunk_index = 0
|
||||||
|
self._render_preview()
|
||||||
|
self._set_status("Preview refreshed. Review it before restoring.")
|
||||||
|
|
||||||
|
async def _confirm(self) -> None:
|
||||||
|
self._set_status("Checking the reviewed files…")
|
||||||
|
try:
|
||||||
|
refreshed = await self._refresh_preview()
|
||||||
|
except RestorePreviewError as error:
|
||||||
|
self._set_status(str(error))
|
||||||
|
self.query_one("#restore-button", Button).disabled = True
|
||||||
|
return
|
||||||
|
if (refreshed.backup_digest, refreshed.hosts_file_digest) != (
|
||||||
|
self.preview.backup_digest,
|
||||||
|
self.preview.hosts_file_digest,
|
||||||
|
):
|
||||||
|
self.preview = refreshed
|
||||||
|
self._hunk_index = 0
|
||||||
|
self._render_preview()
|
||||||
|
self._set_status(
|
||||||
|
"A reviewed file changed. The diff was refreshed; confirm Restore again."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.dismiss(True)
|
||||||
|
|
||||||
|
def _render_preview(self) -> None:
|
||||||
|
self.query_one("#backup-current-path", Static).update(
|
||||||
|
f"Current Hosts File (before): {self.preview.hosts_file_path}"
|
||||||
|
)
|
||||||
|
self.query_one("#backup-restored-path", Static).update(
|
||||||
|
f"Pre-edit Backup (after restore): {self.preview.backup_path}"
|
||||||
|
)
|
||||||
|
restore_button = self.query_one("#restore-button", Button)
|
||||||
|
restore_button.disabled = not self.preview.has_changes
|
||||||
|
if not self.preview.has_changes:
|
||||||
|
self._set_status("Restoration would make no changes.")
|
||||||
|
elif self._show_full:
|
||||||
|
self._set_status("Full diff · f changed hunks · r refresh")
|
||||||
|
else:
|
||||||
|
self._set_status(
|
||||||
|
"Changed hunks · [ previous · ] next · f full diff · r refresh"
|
||||||
|
)
|
||||||
|
self.query_one("#backup-diff", Static).update(self._render_diff())
|
||||||
|
|
||||||
|
def _render_diff(self) -> Text:
|
||||||
|
hunks = (
|
||||||
|
self.preview.full_hunks if self._show_full else self.preview.changed_hunks
|
||||||
|
)
|
||||||
|
if not hunks:
|
||||||
|
if self.preview.has_changes:
|
||||||
|
text = Text(no_wrap=True, overflow="ignore")
|
||||||
|
self._append_final_newline_markers(text)
|
||||||
|
return text
|
||||||
|
return Text(
|
||||||
|
"No changes between the current Hosts File and Pre-edit Backup."
|
||||||
|
)
|
||||||
|
text = Text(no_wrap=True, overflow="ignore")
|
||||||
|
self._hunk_offsets = []
|
||||||
|
offset = 0
|
||||||
|
for index, hunk in enumerate(hunks):
|
||||||
|
self._hunk_offsets.append(offset)
|
||||||
|
if self.size.width >= 120:
|
||||||
|
text.append(
|
||||||
|
f"@@ current -{hunk.current_start},{hunk.current_count} "
|
||||||
|
f"restored +{hunk.restored_start},{hunk.restored_count} @@\n",
|
||||||
|
style="bold",
|
||||||
|
)
|
||||||
|
offset += 1
|
||||||
|
for row in hunk.rows:
|
||||||
|
self._append_side_by_side_row(text, row)
|
||||||
|
text.append("\n")
|
||||||
|
offset += 1
|
||||||
|
else:
|
||||||
|
text.append(
|
||||||
|
f"@@ -{hunk.current_start},{hunk.current_count} "
|
||||||
|
f"+{hunk.restored_start},{hunk.restored_count} @@\n",
|
||||||
|
style="bold",
|
||||||
|
)
|
||||||
|
offset += 1
|
||||||
|
for row in hunk.rows:
|
||||||
|
self._append_unified_row(text, row)
|
||||||
|
text.append("\n")
|
||||||
|
offset += 2 if self._is_replacement(row) else 1
|
||||||
|
if index < len(hunks) - 1:
|
||||||
|
text.append("\n")
|
||||||
|
offset += 1
|
||||||
|
self._append_final_newline_markers(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _append_side_by_side_row(self, text: Text, row: RestoreDiffRow) -> None:
|
||||||
|
width = self._side_width()
|
||||||
|
self._append_side_cell(
|
||||||
|
text,
|
||||||
|
row.current_line_number,
|
||||||
|
row.current_text,
|
||||||
|
row.restored_text,
|
||||||
|
"-",
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
text.append(" │ ", style="dim")
|
||||||
|
self._append_side_cell(
|
||||||
|
text,
|
||||||
|
row.restored_line_number,
|
||||||
|
row.restored_text,
|
||||||
|
row.current_text,
|
||||||
|
"+",
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _append_side_cell(
|
||||||
|
self,
|
||||||
|
text: Text,
|
||||||
|
number: int | None,
|
||||||
|
value: str | None,
|
||||||
|
paired: str | None,
|
||||||
|
marker: str,
|
||||||
|
width: int,
|
||||||
|
) -> None:
|
||||||
|
changed = value is not None and value != paired
|
||||||
|
prefix = f"{marker if changed else ' '} " + (
|
||||||
|
f"{number:>4} " if number is not None else " "
|
||||||
|
)
|
||||||
|
text.append(prefix, style="bold" if changed else "")
|
||||||
|
rendered = self._display_line(value)
|
||||||
|
paired_rendered = self._display_line(paired) if paired is not None else None
|
||||||
|
self._append_intraline_text(text, rendered, paired_rendered, changed)
|
||||||
|
text.append(" " * max(0, width - cell_len(prefix) - cell_len(rendered)))
|
||||||
|
|
||||||
|
def _append_unified_row(self, text: Text, row: RestoreDiffRow) -> None:
|
||||||
|
if self._is_replacement(row):
|
||||||
|
text.append(f"- {row.current_line_number:>4} ", style="bold")
|
||||||
|
self._append_intraline_text(
|
||||||
|
text,
|
||||||
|
self._display_line(row.current_text),
|
||||||
|
self._display_line(row.restored_text),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
text.append("\n+ " + f"{row.restored_line_number:>4} ", style="bold")
|
||||||
|
self._append_intraline_text(
|
||||||
|
text,
|
||||||
|
self._display_line(row.restored_text),
|
||||||
|
self._display_line(row.current_text),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
elif row.current_text is not None:
|
||||||
|
marker = " " if row.restored_text == row.current_text else "-"
|
||||||
|
text.append(
|
||||||
|
f"{marker} {row.current_line_number:>4} "
|
||||||
|
f"{self._display_line(row.current_text)}",
|
||||||
|
style="bold" if marker == "-" else "",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text.append(
|
||||||
|
f"+ {row.restored_line_number:>4} "
|
||||||
|
f"{self._display_line(row.restored_text)}",
|
||||||
|
style="bold",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _append_intraline_text(
|
||||||
|
self, text: Text, value: str, paired: str | None, changed: bool
|
||||||
|
) -> None:
|
||||||
|
if not changed or paired is None or value == paired:
|
||||||
|
text.append(value)
|
||||||
|
return
|
||||||
|
for tag, start, end, _, _ in SequenceMatcher(
|
||||||
|
None, value, paired, autojunk=False
|
||||||
|
).get_opcodes():
|
||||||
|
text.append(value[start:end], style="bold" if tag != "equal" else "")
|
||||||
|
|
||||||
|
def _append_final_newline_markers(self, text: Text) -> None:
|
||||||
|
if not self.preview.current_has_final_newline:
|
||||||
|
text.append("\\ No newline at end of current Hosts File\n", style="bold")
|
||||||
|
if not self.preview.restored_has_final_newline:
|
||||||
|
text.append(
|
||||||
|
"\\ No newline at end of restored Pre-edit Backup", style="bold"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _side_width(self) -> int:
|
||||||
|
longest = max(
|
||||||
|
(
|
||||||
|
cell_len(self._display_line(value))
|
||||||
|
for hunk in self.preview.full_hunks
|
||||||
|
for row in hunk.rows
|
||||||
|
for value in (row.current_text, row.restored_text)
|
||||||
|
),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
return max(50, longest + 7)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_line(value: str | None) -> str:
|
||||||
|
"""Expand tabs before adding diff gutters so columns remain deterministic."""
|
||||||
|
return (value or "").expandtabs(8)
|
||||||
|
|
||||||
|
def _is_replacement(self, row: RestoreDiffRow) -> bool:
|
||||||
|
return (
|
||||||
|
row.current_text is not None
|
||||||
|
and row.restored_text is not None
|
||||||
|
and row.current_text != row.restored_text
|
||||||
|
)
|
||||||
|
|
||||||
|
def _jump_hunk(self, direction: int) -> None:
|
||||||
|
if self._show_full or not self._hunk_offsets:
|
||||||
|
return
|
||||||
|
self._hunk_index = (self._hunk_index + direction) % len(self._hunk_offsets)
|
||||||
|
self._scroller().scroll_to(
|
||||||
|
y=self._hunk_offsets[self._hunk_index], animate=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _scroller(self) -> ScrollableContainer:
|
||||||
|
return self.query_one("#backup-diff-scroll", ScrollableContainer)
|
||||||
|
|
||||||
|
def _set_status(self, message: str) -> None:
|
||||||
|
self.query_one("#backup-restore-status", Static).update(Text(message))
|
||||||
|
|
@ -50,7 +50,7 @@ class HelpModal(ModalScreen[None]):
|
||||||
)
|
)
|
||||||
yield Static("Privileged Mode", classes="help-section")
|
yield Static("Privileged Mode", classes="help-section")
|
||||||
yield Static(
|
yield Static(
|
||||||
"Ctrl+E Enter or leave Privileged Mode n New e Edit d Delete",
|
"Ctrl+E Enter or leave Privileged Mode b Review Pre-edit Backup",
|
||||||
classes="help-copy",
|
classes="help-copy",
|
||||||
)
|
)
|
||||||
yield Button("Close", id="help-close", variant="primary")
|
yield Button("Close", id="help-close", variant="primary")
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ HOSTS_MANAGER_BINDINGS = [
|
||||||
Binding("i", "sort_by_ip", "Sort by IP address", show=False),
|
Binding("i", "sort_by_ip", "Sort by IP address", show=False),
|
||||||
Binding("h", "sort_by_hostname", "Sort by hostname", show=False),
|
Binding("h", "sort_by_hostname", "Sort by hostname", show=False),
|
||||||
Binding("ctrl+s", "save_file", "Save hosts file", show=False),
|
Binding("ctrl+s", "save_file", "Save hosts file", show=False),
|
||||||
|
Binding("b", "restore_backup", "Restore Pre-edit Backup", 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+z", "undo", "Undo", show=False, id="left:undo"),
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
"""Tests for application-level authorization flow."""
|
"""Tests for application-level authorization flow."""
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
import asyncio
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from rich.cells import cell_len
|
||||||
from textual.app import SuspendNotSupported
|
from textual.app import SuspendNotSupported
|
||||||
from textual.widgets import Input, RadioButton, Static
|
from textual.widgets import Button, Input, RadioButton, Static
|
||||||
|
|
||||||
from src.hosts.core.filters import FilterOptions
|
from src.hosts.core.filters import FilterOptions
|
||||||
from src.hosts.core.models import HostEntry, HostsFile
|
from src.hosts.core.models import HostEntry, HostsFile
|
||||||
from src.hosts.tui.app import HostsManagerApp
|
from src.hosts.tui.app import HostsManagerApp
|
||||||
from src.hosts.tui.custom_footer import CustomFooter
|
from src.hosts.tui.custom_footer import CustomFooter
|
||||||
|
from src.hosts.tui.backup_restore_modal import BackupRestoreModal
|
||||||
from src.hosts.tui.filter_modal import FilterModal
|
from src.hosts.tui.filter_modal import FilterModal
|
||||||
from src.hosts.tui.help_modal import HelpModal
|
from src.hosts.tui.help_modal import HelpModal
|
||||||
from src.hosts.tui.privilege_prompt import (
|
from src.hosts.tui.privilege_prompt import (
|
||||||
|
|
@ -246,6 +249,147 @@ async def test_small_viewport_blocks_hidden_mutation_shortcuts():
|
||||||
app.manager.enter_edit_mode.assert_not_called()
|
app.manager.enter_edit_mode.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_opens_a_side_by_side_preview_with_exact_paths(tmp_path):
|
||||||
|
"""The recovery workflow makes its before-and-after files reviewable."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
backup_path = tmp_path / "hosts.backup"
|
||||||
|
hosts_file_path = tmp_path / "hosts"
|
||||||
|
backup_path.write_text("127.0.0.1 restored.test\n")
|
||||||
|
hosts_file_path.write_text("127.0.0.1 current.test\n")
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager = Mock()
|
||||||
|
app.manager.backup_path = backup_path
|
||||||
|
app.manager.parser.file_path = hosts_file_path
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
app.action_restore_backup()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert isinstance(app.screen, BackupRestoreModal)
|
||||||
|
assert str(backup_path) in str(
|
||||||
|
app.screen.query_one("#backup-restored-path", Static).render()
|
||||||
|
)
|
||||||
|
assert str(hosts_file_path) in str(
|
||||||
|
app.screen.query_one("#backup-current-path", Static).render()
|
||||||
|
)
|
||||||
|
diff = str(app.screen.query_one("#backup-diff", Static).render())
|
||||||
|
assert "current.test" in diff
|
||||||
|
assert "restored.test" in diff
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_side_by_side_divider_uses_terminal_cell_widths(tmp_path):
|
||||||
|
"""Tabs in Hosts File lines cannot move the right-hand file between rows."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
backup_path = tmp_path / "hosts.backup"
|
||||||
|
hosts_file_path = tmp_path / "hosts"
|
||||||
|
hosts_file_path.write_text(
|
||||||
|
"127.0.0.1\tlocalhost\n"
|
||||||
|
"255.255.255.255\tbroadcasthost\n"
|
||||||
|
"::1\tlocalhost\n"
|
||||||
|
"192.0.2.10\tcurrent.test\n"
|
||||||
|
)
|
||||||
|
backup_path.write_text(
|
||||||
|
"127.0.0.1\tlocalhost\n"
|
||||||
|
"255.255.255.255\tbroadcasthost\n"
|
||||||
|
"::1\tlocalhost\n"
|
||||||
|
"192.0.2.10\trestored.test\n"
|
||||||
|
)
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager = Mock()
|
||||||
|
app.manager.backup_path = backup_path
|
||||||
|
app.manager.parser.file_path = hosts_file_path
|
||||||
|
|
||||||
|
async with app.run_test(size=(160, 40)) as pilot:
|
||||||
|
app.action_restore_backup()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
modal = app.screen
|
||||||
|
assert isinstance(modal, BackupRestoreModal)
|
||||||
|
lines = [
|
||||||
|
line for line in modal._render_diff().plain.splitlines() if " │ " in line
|
||||||
|
]
|
||||||
|
divider_columns = {
|
||||||
|
cell_len(line[: line.index("│")].expandtabs(8)) for line in lines
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "\t" not in "\n".join(lines)
|
||||||
|
assert len(divider_columns) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_preview_disables_a_no_change_operation(tmp_path):
|
||||||
|
"""A no-op restore cannot perform an unnecessary privileged write."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
backup_path = tmp_path / "hosts.backup"
|
||||||
|
hosts_file_path = tmp_path / "hosts"
|
||||||
|
contents = "127.0.0.1 localhost\n"
|
||||||
|
backup_path.write_text(contents)
|
||||||
|
hosts_file_path.write_text(contents)
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager = Mock()
|
||||||
|
app.manager.backup_path = backup_path
|
||||||
|
app.manager.parser.file_path = hosts_file_path
|
||||||
|
|
||||||
|
async with app.run_test(size=(120, 40)) as pilot:
|
||||||
|
app.action_restore_backup()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
modal = app.screen
|
||||||
|
assert isinstance(modal, BackupRestoreModal)
|
||||||
|
assert modal.query_one("#restore-button", Button).disabled
|
||||||
|
assert "would make no changes" in str(
|
||||||
|
modal.query_one("#backup-restore-status", Static).render()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_preview_fills_width_and_routes_navigation_from_cancel(tmp_path):
|
||||||
|
"""The safe initial focus does not prevent full mode or two-axis scrolling."""
|
||||||
|
app = app_with_filterable_entries()
|
||||||
|
backup_path = tmp_path / "hosts.backup"
|
||||||
|
hosts_file_path = tmp_path / "hosts"
|
||||||
|
current_lines = [
|
||||||
|
f"192.0.2.{index} host-{index}.test " + "x" * 120 for index in range(1, 81)
|
||||||
|
]
|
||||||
|
restored_lines = current_lines.copy()
|
||||||
|
restored_lines[39] = "192.0.2.40 restored.test " + "y" * 120
|
||||||
|
hosts_file_path.write_text("\n".join(current_lines) + "\n")
|
||||||
|
backup_path.write_text("\n".join(restored_lines) + "\n")
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager = Mock()
|
||||||
|
app.manager.backup_path = backup_path
|
||||||
|
app.manager.parser.file_path = hosts_file_path
|
||||||
|
|
||||||
|
async with app.run_test(size=(160, 40)) as pilot:
|
||||||
|
app.action_restore_backup()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
modal = app.screen
|
||||||
|
assert isinstance(modal, BackupRestoreModal)
|
||||||
|
container = modal.query_one("#backup-restore-container")
|
||||||
|
assert container.outer_size.width == 156
|
||||||
|
|
||||||
|
changed_diff = str(modal.query_one("#backup-diff", Static).render())
|
||||||
|
await pilot.press("f")
|
||||||
|
await pilot.pause()
|
||||||
|
full_diff = str(modal.query_one("#backup-diff", Static).render())
|
||||||
|
assert full_diff != changed_diff
|
||||||
|
assert "host-1.test" in full_diff
|
||||||
|
|
||||||
|
scroller = modal.query_one("#backup-diff-scroll")
|
||||||
|
assert app.focused is modal.query_one("#cancel-button")
|
||||||
|
await pilot.press("down", "right")
|
||||||
|
await pilot.pause()
|
||||||
|
assert scroller.scroll_y > 0
|
||||||
|
assert scroller.scroll_x > 0
|
||||||
|
|
||||||
|
|
||||||
class TestPrivilegedModeAuthorization:
|
class TestPrivilegedModeAuthorization:
|
||||||
"""Test the user-visible privileged-mode authorization flow."""
|
"""Test the user-visible privileged-mode authorization flow."""
|
||||||
|
|
||||||
|
|
@ -474,3 +618,80 @@ class TestPrivilegedModeAuthorization:
|
||||||
assert not app.manager.permission_manager._sudo_validated
|
assert not app.manager.permission_manager._sudo_validated
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
app.update_status.assert_called_once_with("Edit mode disabled")
|
app.update_status.assert_called_once_with("Edit mode disabled")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreEditBackupRestoration:
|
||||||
|
"""Test the user-reachable Pre-edit Backup recovery flow."""
|
||||||
|
|
||||||
|
def test_restore_action_confirms_paths_then_reloads_after_success(self, tmp_path):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager = Mock()
|
||||||
|
backup_path = tmp_path / "hosts.backup.123"
|
||||||
|
hosts_file_path = tmp_path / "hosts"
|
||||||
|
backup_path.write_text("127.0.0.1 restored.test\n")
|
||||||
|
hosts_file_path.write_text("127.0.0.1 current.test\n")
|
||||||
|
app.manager.backup_path = backup_path
|
||||||
|
app.manager.parser.file_path = hosts_file_path
|
||||||
|
app.manager.restore_backup.return_value = (True, "Backup restored successfully")
|
||||||
|
app.push_screen = Mock()
|
||||||
|
app.load_hosts_file = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
|
||||||
|
app.run_worker = Mock(side_effect=lambda coroutine, **_: asyncio.run(coroutine))
|
||||||
|
app.action_restore_backup()
|
||||||
|
|
||||||
|
modal, callback = app.push_screen.call_args.args
|
||||||
|
assert modal.preview.backup_path == backup_path
|
||||||
|
assert modal.preview.hosts_file_path == hosts_file_path
|
||||||
|
|
||||||
|
callback(True)
|
||||||
|
|
||||||
|
app.manager.restore_backup.assert_called_once_with(
|
||||||
|
modal.preview.backup_digest, modal.preview.hosts_file_digest
|
||||||
|
)
|
||||||
|
app.load_hosts_file.assert_called_once_with()
|
||||||
|
assert app.update_status.call_args.args == (
|
||||||
|
f"Pre-edit Backup restored from {backup_path} to {hosts_file_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_restore_keeps_interface_state_and_reports_paths(self, tmp_path):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.edit_mode = True
|
||||||
|
app.manager = Mock()
|
||||||
|
backup_path = tmp_path / "hosts.backup.123"
|
||||||
|
hosts_file_path = tmp_path / "hosts"
|
||||||
|
backup_path.write_text("127.0.0.1 restored.test\n")
|
||||||
|
hosts_file_path.write_text("127.0.0.1 current.test\n")
|
||||||
|
app.manager.backup_path = backup_path
|
||||||
|
app.manager.parser.file_path = hosts_file_path
|
||||||
|
app.manager.restore_backup.return_value = (False, "Permission denied")
|
||||||
|
app.push_screen = Mock()
|
||||||
|
app.load_hosts_file = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
|
||||||
|
app.run_worker = Mock(side_effect=lambda coroutine, **_: asyncio.run(coroutine))
|
||||||
|
app.action_restore_backup()
|
||||||
|
_, callback = app.push_screen.call_args.args
|
||||||
|
callback(True)
|
||||||
|
|
||||||
|
app.load_hosts_file.assert_not_called()
|
||||||
|
assert app.update_status.call_args.args == (
|
||||||
|
f"Failed to restore Pre-edit Backup from {backup_path} to {hosts_file_path}: Permission denied. "
|
||||||
|
"The Hosts File and interface are unchanged. Check write access and try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_restore_is_unavailable_while_the_entry_editor_has_unsaved_state(self):
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.entry_edit_mode = True
|
||||||
|
app.manager = Mock()
|
||||||
|
app.push_screen = Mock()
|
||||||
|
app.update_status = Mock()
|
||||||
|
|
||||||
|
app.action_restore_backup()
|
||||||
|
|
||||||
|
app.push_screen.assert_not_called()
|
||||||
|
app.manager.restore_backup.assert_not_called()
|
||||||
|
app.update_status.assert_called_once_with(
|
||||||
|
"Finish or cancel the Entry Editor before restoring the Pre-edit Backup."
|
||||||
|
)
|
||||||
|
|
|
||||||
70
tests/test_backup_restore_modal.py
Normal file
70
tests/test_backup_restore_modal.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Tests for the Pre-edit Backup restoration confirmation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from textual.widgets import Button
|
||||||
|
|
||||||
|
from hosts.tui.backup_restore_modal import BackupRestoreModal
|
||||||
|
from hosts.core.restore_preview import create_restore_preview
|
||||||
|
|
||||||
|
|
||||||
|
async def unchanged_preview(preview):
|
||||||
|
"""Return the already reviewed files for modal unit tests."""
|
||||||
|
return preview
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupRestoreModal:
|
||||||
|
"""The modal identifies both paths and requires an explicit choice."""
|
||||||
|
|
||||||
|
def test_modal_exposes_backup_and_hosts_file_paths(self, tmp_path):
|
||||||
|
backup = tmp_path / "hosts.backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
backup.write_text("127.0.0.1 localhost\n")
|
||||||
|
hosts_file.write_text("192.0.2.1 current.test\n")
|
||||||
|
preview = create_restore_preview(backup, hosts_file)
|
||||||
|
modal = BackupRestoreModal(preview, lambda: unchanged_preview(preview))
|
||||||
|
|
||||||
|
assert modal.preview.backup_path == backup
|
||||||
|
assert modal.preview.hosts_file_path == hosts_file
|
||||||
|
|
||||||
|
def test_confirm_dismisses_true_after_freshness_check(self, tmp_path):
|
||||||
|
backup = tmp_path / "backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
backup.write_text("127.0.0.1 restored.test\n")
|
||||||
|
hosts_file.write_text("127.0.0.1 current.test\n")
|
||||||
|
preview = create_restore_preview(backup, hosts_file)
|
||||||
|
modal = BackupRestoreModal(preview, lambda: unchanged_preview(preview))
|
||||||
|
modal.dismiss = Mock()
|
||||||
|
modal._set_status = Mock()
|
||||||
|
|
||||||
|
asyncio.run(modal._confirm())
|
||||||
|
|
||||||
|
modal.dismiss.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_cancel_is_focused_and_dismisses_false(self):
|
||||||
|
preview = Mock()
|
||||||
|
modal = BackupRestoreModal(preview, Mock())
|
||||||
|
modal.dismiss = Mock()
|
||||||
|
cancel = Mock()
|
||||||
|
modal.query_one = Mock(return_value=cancel)
|
||||||
|
modal._render_preview = Mock()
|
||||||
|
|
||||||
|
modal.on_mount()
|
||||||
|
modal.action_cancel()
|
||||||
|
|
||||||
|
modal.query_one.assert_called_once_with("#cancel-button", Button)
|
||||||
|
cancel.focus.assert_called_once_with()
|
||||||
|
modal.dismiss.assert_called_once_with(False)
|
||||||
|
|
||||||
|
def test_final_newline_difference_is_visible_without_changed_lines(self, tmp_path):
|
||||||
|
backup = tmp_path / "backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
backup.write_bytes(b"127.0.0.1 localhost\n")
|
||||||
|
hosts_file.write_bytes(b"127.0.0.1 localhost")
|
||||||
|
preview = create_restore_preview(backup, hosts_file)
|
||||||
|
modal = BackupRestoreModal(preview, lambda: unchanged_preview(preview))
|
||||||
|
|
||||||
|
rendered = modal._render_diff()
|
||||||
|
|
||||||
|
assert "No newline at end of current Hosts File" in str(rendered)
|
||||||
|
|
@ -13,6 +13,7 @@ from unittest.mock import Mock, patch
|
||||||
from src.hosts.core.manager import PermissionManager, HostsManager
|
from src.hosts.core.manager import PermissionManager, HostsManager
|
||||||
from src.hosts.core.models import HostEntry, HostsFile
|
from src.hosts.core.models import HostEntry, HostsFile
|
||||||
from src.hosts.core.commands import ToggleEntryCommand
|
from src.hosts.core.commands import ToggleEntryCommand
|
||||||
|
from src.hosts.core.restore_preview import fingerprint_file
|
||||||
|
|
||||||
|
|
||||||
class TestPermissionManager:
|
class TestPermissionManager:
|
||||||
|
|
@ -592,15 +593,69 @@ class TestHostsManager:
|
||||||
manager._backup_path = Path(backup_file.name)
|
manager._backup_path = Path(backup_file.name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
manager.undo_redo_history.clear_history = Mock()
|
||||||
success, message = manager.restore_backup()
|
success, message = manager.restore_backup()
|
||||||
|
|
||||||
assert success
|
assert success
|
||||||
assert "restored successfully" in message
|
assert "restored successfully" in message
|
||||||
mock_run.assert_called_once()
|
assert mock_run.call_count == 2
|
||||||
|
assert mock_run.call_args_list[0].args[0][:2] == ["sudo", "cp"]
|
||||||
|
assert mock_run.call_args_list[0].args[0][-1] != temp_file.name
|
||||||
|
assert mock_run.call_args_list[1].args[0][:2] == ["sudo", "mv"]
|
||||||
|
assert mock_run.call_args_list[1].args[0][-1] == temp_file.name
|
||||||
|
manager.undo_redo_history.clear_history.assert_called_once_with()
|
||||||
finally:
|
finally:
|
||||||
# Clean up
|
# Clean up
|
||||||
manager._backup_path.unlink()
|
manager._backup_path.unlink()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_restore_backup_copy_failure_leaves_hosts_file_target_untouched(
|
||||||
|
self, mock_run
|
||||||
|
):
|
||||||
|
"""A failed staged copy never opens the Hosts File target for writing."""
|
||||||
|
mock_run.return_value = Mock(returncode=1, stderr="Disk full")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as temp_file:
|
||||||
|
manager = HostsManager(temp_file.name)
|
||||||
|
manager.edit_mode = True
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as backup_file:
|
||||||
|
manager._backup_path = Path(backup_file.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message = manager.restore_backup()
|
||||||
|
|
||||||
|
assert not success
|
||||||
|
assert "Disk full" in message
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
assert mock_run.call_args.args[0][-1] != temp_file.name
|
||||||
|
finally:
|
||||||
|
manager._backup_path.unlink()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_restore_backup_refuses_files_that_changed_since_preview(self, mock_run):
|
||||||
|
"""The privileged write uses the exact source and target the user reviewed."""
|
||||||
|
with tempfile.NamedTemporaryFile() as temp_file:
|
||||||
|
manager = HostsManager(temp_file.name)
|
||||||
|
manager.edit_mode = True
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as backup_file:
|
||||||
|
backup_file.write(b"127.0.0.1 restored.test\n")
|
||||||
|
manager._backup_path = Path(backup_file.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup_digest = fingerprint_file(
|
||||||
|
manager._backup_path, "Pre-edit Backup"
|
||||||
|
)
|
||||||
|
hosts_digest = fingerprint_file(manager.parser.file_path, "Hosts File")
|
||||||
|
Path(temp_file.name).write_text("192.0.2.2 changed.test\n")
|
||||||
|
|
||||||
|
success, message = manager.restore_backup(backup_digest, hosts_digest)
|
||||||
|
|
||||||
|
assert not success
|
||||||
|
assert "changed since review" in message
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
finally:
|
||||||
|
manager._backup_path.unlink()
|
||||||
|
|
||||||
def test_restore_backup_not_in_edit_mode(self):
|
def test_restore_backup_not_in_edit_mode(self):
|
||||||
"""Test restoring backup when not in edit mode."""
|
"""Test restoring backup when not in edit mode."""
|
||||||
with tempfile.NamedTemporaryFile() as temp_file:
|
with tempfile.NamedTemporaryFile() as temp_file:
|
||||||
|
|
|
||||||
73
tests/test_restore_preview.py
Normal file
73
tests/test_restore_preview.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""Tests for the exact-file preview shown before Pre-edit Backup restoration."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hosts.core.restore_preview import RestorePreviewError, create_restore_preview
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_pairs_current_and_restored_lines_in_changed_hunks(tmp_path):
|
||||||
|
"""The current Hosts File is the left/before side of the preview."""
|
||||||
|
backup = tmp_path / "hosts.backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
backup.write_text("127.0.0.1 localhost\n192.0.2.1 restored.test\n")
|
||||||
|
hosts_file.write_text("127.0.0.1 localhost\n192.0.2.2 current.test\n")
|
||||||
|
|
||||||
|
preview = create_restore_preview(backup, hosts_file)
|
||||||
|
|
||||||
|
assert preview.has_changes
|
||||||
|
row = preview.changed_hunks[0].rows[1]
|
||||||
|
assert row.current_line_number == 2
|
||||||
|
assert row.restored_line_number == 2
|
||||||
|
assert row.current_text == "192.0.2.2 current.test"
|
||||||
|
assert row.restored_text == "192.0.2.1 restored.test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_distinguishes_missing_final_newlines(tmp_path):
|
||||||
|
backup = tmp_path / "hosts.backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
backup.write_bytes(b"127.0.0.1 localhost\n")
|
||||||
|
hosts_file.write_bytes(b"127.0.0.1 localhost")
|
||||||
|
|
||||||
|
preview = create_restore_preview(backup, hosts_file)
|
||||||
|
|
||||||
|
assert preview.has_changes
|
||||||
|
assert preview.restored_has_final_newline
|
||||||
|
assert not preview.current_has_final_newline
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_rejects_files_that_cannot_be_decoded_as_hosts_text(tmp_path):
|
||||||
|
backup = tmp_path / "hosts.backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
backup.write_bytes(b"\xff")
|
||||||
|
hosts_file.write_text("127.0.0.1 localhost\n")
|
||||||
|
|
||||||
|
with pytest.raises(RestorePreviewError, match="Pre-edit Backup"):
|
||||||
|
create_restore_preview(backup, hosts_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_detects_when_either_reviewed_file_changes(tmp_path):
|
||||||
|
backup = tmp_path / "hosts.backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
backup.write_text("127.0.0.1 localhost\n")
|
||||||
|
hosts_file.write_text("192.0.2.2 current.test\n")
|
||||||
|
preview = create_restore_preview(backup, hosts_file)
|
||||||
|
|
||||||
|
hosts_file.write_text("192.0.2.3 changed.test\n")
|
||||||
|
|
||||||
|
assert not preview.matches_files()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_preview_keeps_lines_outside_changed_hunk_context(tmp_path):
|
||||||
|
"""Full mode retains the complete files after changed hunks are calculated."""
|
||||||
|
backup = tmp_path / "hosts.backup"
|
||||||
|
hosts_file = tmp_path / "hosts"
|
||||||
|
current_lines = [f"192.0.2.{index} host-{index}.test" for index in range(1, 41)]
|
||||||
|
restored_lines = current_lines.copy()
|
||||||
|
restored_lines[19] = "192.0.2.20 restored.test"
|
||||||
|
hosts_file.write_text("\n".join(current_lines) + "\n")
|
||||||
|
backup.write_text("\n".join(restored_lines) + "\n")
|
||||||
|
|
||||||
|
preview = create_restore_preview(backup, hosts_file)
|
||||||
|
|
||||||
|
assert len(preview.changed_hunks[0].rows) == 7
|
||||||
|
assert len(preview.full_hunks[0].rows) == 40
|
||||||
Loading…
Add table
Add a link
Reference in a new issue