diff --git a/src/vorta/assets/UI/dialogs/repo/change_passphrase.ui b/src/vorta/assets/UI/dialogs/repo/change_passphrase.ui index fd3e5765f..e1dae663e 100644 --- a/src/vorta/assets/UI/dialogs/repo/change_passphrase.ui +++ b/src/vorta/assets/UI/dialogs/repo/change_passphrase.ui @@ -55,6 +55,39 @@ + + + + + 0 + 0 + + + + + 0 + 20 + + + + + 11 + + + + + + + Qt::PlainText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 24bc6fc7b..64c9b8c12 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -13,6 +13,7 @@ QLayout, QMenu, QMessageBox, + QProgressDialog, QStyledItemDelegate, QTableView, QTableWidgetItem, @@ -23,7 +24,9 @@ from vorta.borg.compact import BorgCompactJob from vorta.borg.delete import BorgDeleteJob from vorta.borg.diff import BorgDiffJob +from vorta.borg.extract import BorgExtractJob from vorta.borg.info_archive import BorgInfoArchiveJob +from vorta.borg.list_archive import BorgListArchiveJob from vorta.borg.list_repo import BorgListRepoJob from vorta.borg.prune import BorgPruneJob from vorta.borg.rename import BorgRenameJob @@ -32,17 +35,19 @@ from vorta.store.models import ArchiveModel, SettingsModel from vorta.utils import ( borg_compat, + choose_file_dialog, find_best_unit_for_sizes, format_archive_name, get_asset, get_mount_points, pretty_bytes, ) -from vorta.views.archive.archive_extract import ArchiveExtract from vorta.views.archive.archive_mount import ArchiveMount from vorta.views.base_tab import BaseTab from vorta.views.dialogs.archive import diff_result +from vorta.views.dialogs.archive import extract as extract_dialog from vorta.views.dialogs.archive.diff_result import DiffResultDialog, DiffTree +from vorta.views.dialogs.archive.extract import ExtractDialog, ExtractTree from vorta.views.source_tab import SizeItem from vorta.views.utils import get_colored_icon @@ -83,7 +88,6 @@ def __init__(self, parent=None, app=None, profile_provider=None): ) self.archive_mount = ArchiveMount(self) - self.archive_extract = ArchiveExtract(self) #: Tooltip dict to save the tooltips set in the designer self.tooltip_dict: Dict[QWidget, str] = {} @@ -142,7 +146,7 @@ def __init__(self, parent=None, app=None, profile_provider=None): self.bRefreshArchive.clicked.connect(self.refresh_archive_info) self.bRename.clicked.connect(self.cell_double_clicked) self.bDelete.clicked.connect(self.delete_action) - self.bExtract.clicked.connect(self.archive_extract.extract_action) + self.bExtract.clicked.connect(self.extract_action) self.compactButton.clicked.connect(self.compact_action) # other signals @@ -212,7 +216,7 @@ def archiveitem_contextmenu(self, pos: QPoint): (self.bRefreshArchive, self.refresh_archive_info), (self.bDiff, self.diff_action), (self.bMountArchive, self.archive_mount.bmountarchive_clicked), - (self.bExtract, self.archive_extract.extract_action), + (self.bExtract, self.extract_action), (self.bRename, self.cell_double_clicked), (self.bDelete, self.delete_action), ] @@ -487,7 +491,8 @@ def check_action(self): archive_cell = self.archiveTable.item(row_selected[0].row(), 4) if archive_cell: archive_name = archive_cell.text() - params['cmd'][-1] += f'::{archive_name}' + cmd: list = params['cmd'] # type: ignore[index] + cmd[-1] += f'::{archive_name}' job = BorgCheckJob(params['cmd'], params, self.profile().repo.id) job.updated.connect(self._set_status) @@ -591,6 +596,85 @@ def save_prune_setting(self, new_value=None): profile.prune_keep_within = self.prune_keep_within.text() profile.save() + def extract_action(self): + """ + Open a dialog for choosing what to extract from the selected archive. + """ + profile = self.profile() + + row_selected = self.archiveTable.selectionModel().selectedRows() + if row_selected: + archive_cell = self.archiveTable.item(row_selected[0].row(), 4) + if archive_cell: + archive_name = archive_cell.text() + params = BorgListArchiveJob.prepare(profile, archive_name) + + if not params['ok']: + self._set_status(params['message']) + return + self._set_status('') + self._toggle_all_buttons(False) + + job = BorgListArchiveJob(params['cmd'], params, self.profile().repo.id) + job.updated.connect(self.mountErrors.setText) + job.result.connect(self.extract_list_result) + self.app.jobs_manager.add_job(job) + return job + else: + self._set_status(self.tr('Select an archive to restore first.')) + + def extract_list_result(self, result): + """Process the contents of the archive to extract.""" + self._set_status('') + if result['returncode'] == 0: + archive = ArchiveModel.get(name=result['params']['archive_name']) + model = ExtractTree() + + progress = QProgressDialog(self.tr("Processing archive contents…"), None, 0, 0, self) + progress.setWindowTitle(self.tr("Please wait")) + progress.setWindowModality(Qt.WindowModality.WindowModal) + progress.setMinimumDuration(0) + progress.setValue(0) + self._extract_progress = progress + + self._t = extract_dialog.ParseThread(result['data'], model) + self._t.finished.connect(self._extract_progress.close) + self._t.finished.connect(self._extract_progress.deleteLater) + self._t.finished.connect(lambda: self.extract_show_dialog(archive, model)) + self._t.start() + + def extract_show_dialog(self, archive, model): + """Show the dialog for choosing the archive contents to extract.""" + self._set_status('') + + def process_result(): + def receive(): + extraction_folder = dialog.selectedFiles() + if extraction_folder: + params = BorgExtractJob.prepare(self.profile(), archive.name, model, extraction_folder[0]) + if params['ok']: + self._toggle_all_buttons(False) + job = BorgExtractJob(params['cmd'], params, self.profile().repo.id) + job.updated.connect(self.mountErrors.setText) + job.result.connect(self.extract_archive_result) + self.app.jobs_manager.add_job(job) + else: + self._set_status(params['message']) + + dialog = choose_file_dialog(self, self.tr("Choose Extraction Point"), want_folder=True) + dialog.open(receive) + + window = ExtractDialog(archive, model) + self._toggle_all_buttons(True) + window.setParent(self, QtCore.Qt.WindowType.Sheet) + self._window = window # for testing + window.show() + window.accepted.connect(process_result) + + def extract_archive_result(self, result): + """Finished extraction.""" + self._toggle_all_buttons(True) + def cell_double_clicked(self, row=None, column=None): if not self.bRename.isEnabled(): return diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index 88d5ad6cb..b76483c41 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -3,7 +3,7 @@ import psutil import pytest from PyQt6 import QtCore -from PyQt6.QtWidgets import QMenu +from PyQt6.QtWidgets import QMenu, QProgressDialog from test_constants import TEST_TEMP_DIR import vorta.borg @@ -124,7 +124,7 @@ def test_archive_extract(qapp, qtbot, mocker, borg_json_output, archive_env): stdout, stderr = borg_json_output('list_archive') popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) - tab.archive_extract.extract_action() + tab.extract_action() qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults) @@ -133,6 +133,33 @@ def test_archive_extract(qapp, qtbot, mocker, borg_json_output, archive_env): assert 'test-archive, 2000' in tab._window.archiveNameLabel.text() +def test_archive_extract_progress_dialog(qapp, qtbot, mocker, borg_json_output, archive_env): + """Progress dialog is shown while parsing archive contents and closed when done.""" + main, tab = archive_env + tab.archiveTable.selectRow(0) + stdout, stderr = borg_json_output('list_archive') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + # Spy on QProgressDialog.show to verify dialog is shown + show_spy = mocker.spy(QProgressDialog, 'show') + + tab.extract_action() + + # Wait for progress dialog to appear + qtbot.waitUntil(lambda: hasattr(tab, '_extract_progress'), **pytest._wait_defaults) + progress = tab._extract_progress + + # Wait for extraction to complete (window appears) + qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults) + + # Verify dialog was shown (spy caught the show call) + assert show_spy.called, "QProgressDialog.show() was not called" + + # Verify dialog is closed after extraction completes + assert not progress.isVisible(), "Progress dialog should be closed after extraction" + + def test_archive_delete(qapp, qtbot, mocker, borg_json_output, archive_env): main, tab = archive_env diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py index ced9200ec..c04a6a3e4 100644 --- a/tests/unit/test_repo.py +++ b/tests/unit/test_repo.py @@ -10,6 +10,7 @@ import vorta.borg.borg_job from vorta.keyring.abc import VortaKeyring from vorta.store.models import ArchiveModel, BackupProfileModel, EventLogModel, RepoModel +from vorta.views.dialogs.repo.repo_change_passphrase import ChangeBorgPassphraseWindow LONG_PASSWORD = 'long-password-long' SHORT_PASSWORD = 'hunter2' @@ -388,3 +389,18 @@ def test_handle_passphrase_change_result(qapp, qtbot, mocker, result, expected_t mock_instance.setWindowTitle.assert_called_once_with(tab.tr(expected_title)) mock_instance.setText.assert_called_once_with(tab.tr(expected_text)) mock_instance.show.assert_called_once() + + +def test_change_passphrase_window_error_text(qapp, qtbot): + """Regression test: errorText widget must exist so _set_status() doesn't crash.""" + mock_profile = MagicMock() + mock_profile.repo.url = 'test-repo.example.com:repo' + + window = ChangeBorgPassphraseWindow(mock_profile) + qtbot.addWidget(window) + + window._set_status('Unable to change the borg passphrase.') + assert window.errorText.text() == 'Unable to change the borg passphrase.' + + window._set_status('') + assert window.errorText.text() == ''