Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/vorta/assets/UI/dialogs/repo/change_passphrase.ui
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,39 @@
</property>
</layout>
</item>
<item>
<widget class="QLabel" name="errorText">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>20</height>
</size>
</property>
<property name="font">
<font>
<pointsize>11</pointsize>
</font>
</property>
<property name="text">
<string/>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
Expand Down
94 changes: 89 additions & 5 deletions src/vorta/views/archive_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
QLayout,
QMenu,
QMessageBox,
QProgressDialog,
QStyledItemDelegate,
QTableView,
QTableWidgetItem,
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions tests/unit/test_archives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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

Expand Down
16 changes: 16 additions & 0 deletions tests/unit/test_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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() == ''