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() == ''