From 270fa1829ae1ee39c603a0d33d20c53294058cc9 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Mon, 29 Jun 2026 22:22:55 +0300 Subject: [PATCH] refactor(archive): peel diff and rename into views/archive/ helpers Follows the stateless ArchiveExtract/ArchiveMount collaborator pattern: helpers hold only self.tab; transient state (_t, is_editing, renamed_archive_original_name) stays on the tab. Pure structural move, no behavior change. --- src/vorta/views/archive/archive_diff.py | 81 ++++++++++ src/vorta/views/archive/archive_rename.py | 92 ++++++++++++ src/vorta/views/archive_tab.py | 172 ++-------------------- tests/unit/test_archives.py | 4 +- tests/unit/test_diff.py | 2 +- 5 files changed, 187 insertions(+), 164 deletions(-) create mode 100644 src/vorta/views/archive/archive_diff.py create mode 100644 src/vorta/views/archive/archive_rename.py diff --git a/src/vorta/views/archive/archive_diff.py b/src/vorta/views/archive/archive_diff.py new file mode 100644 index 000000000..15f0f8647 --- /dev/null +++ b/src/vorta/views/archive/archive_diff.py @@ -0,0 +1,81 @@ +from PyQt6.QtCore import Qt + +from vorta.borg.diff import BorgDiffJob +from vorta.store.models import ArchiveModel +from vorta.views.dialogs.archive import diff_result +from vorta.views.dialogs.archive.diff_result import DiffResultDialog, DiffTree + + +class ArchiveDiff: + def __init__(self, tab): + self.tab = tab + + def diff_action(self): + """ + Handle the diff button being clicked. + + Exactly two archives must be selected in `archiveTable`. This is + usually enforced by `on_selection_change`. + """ + archives = self.tab.selected_archives() + profile = self.tab.profile() + + name1 = archives[0].name + name2 = archives[1].name + + archive1, archive2 = ( + profile.repo.archives.select() + .where((ArchiveModel.name == name1) | (ArchiveModel.name == name2)) + .order_by(ArchiveModel.time.desc()) + ) + + archive_name_newer = archive1.name + archive_name_older = archive2.name + + # Start diff job + params = BorgDiffJob.prepare(profile, archive_name_older, archive_name_newer) + + if params['ok']: + self.tab._toggle_all_buttons(False) + job = BorgDiffJob(params['cmd'], params, self.tab.profile().repo.id) + job.updated.connect(self.tab.mountErrors.setText) + job.result.connect(self.list_diff_result) + self.tab.app.jobs_manager.add_job(job) + else: + self.tab._set_status(params['message']) + + def list_diff_result(self, result): + """ + Process the result of the `BorgDiffJob`. + + The `BorgDiffJob` was initiated by `diff_action`. + + Parameters + ---------- + result : dict + The BorgJob result. + """ + self.tab._set_status('') + if result['returncode'] == 0: + archive_newer = ArchiveModel.get(name=result['params']['archive_name_newer']) + archive_older = ArchiveModel.get(name=result['params']['archive_name_older']) + self.tab._set_status(self.tab.tr("Processing diff results.")) + + model = DiffTree() + + self.tab._t = diff_result.ParseThread(result['data'], result['params']['json_lines'], model) + self.tab._t.finished.connect(lambda: self.show_diff_result(archive_newer, archive_older, model)) + self.tab._t.start() + + def show_diff_result(self, archive_newer, archive_older, model): + self.tab._t = None + + # show dialog + self.tab._toggle_all_buttons(True) + self.tab._set_status('') + window = DiffResultDialog(archive_newer, archive_older, model) + window.setParent(self.tab) + window.setWindowFlags(Qt.WindowType.Window) + window.setWindowModality(Qt.WindowModality.NonModal) + self.tab._resultwindow = window # for testing + window.show() diff --git a/src/vorta/views/archive/archive_rename.py b/src/vorta/views/archive/archive_rename.py new file mode 100644 index 000000000..3197e4b1c --- /dev/null +++ b/src/vorta/views/archive/archive_rename.py @@ -0,0 +1,92 @@ +from PyQt6 import QtCore +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QDesktopServices + +from vorta.borg.rename import BorgRenameJob +from vorta.store.models import ArchiveModel +from vorta.views.partials.archive_table_model import ArchiveTableModel + + +class ArchiveRename: + def __init__(self, tab): + self.tab = tab + + def cell_double_clicked(self, index=None): + """Open a mounted archive's folder, or start an in-place rename.""" + if isinstance(index, QtCore.QModelIndex) and index.isValid(): + column = index.column() + else: + index = self.tab.archiveTable.currentIndex() + column = ArchiveTableModel.COL_NAME + + if not index.isValid(): + return + + if column == ArchiveTableModel.COL_MOUNT: + archive = index.data(ArchiveTableModel.ArchiveRole) + mount_point = self.tab.mount_points.get(archive.name) if archive else None + if mount_point is not None: + QDesktopServices.openUrl(QtCore.QUrl(f'file:///{mount_point}')) + return + + if column == ArchiveTableModel.COL_NAME: + if not self.tab.bRename.isEnabled(): + return + archive = index.data(ArchiveTableModel.ArchiveRole) + if archive is None: + return + self.tab.renamed_archive_original_name = archive.name + self.tab.is_editing = True + self.tab.archiveTable.edit(index.siblingAtColumn(ArchiveTableModel.COL_NAME)) + + def on_name_edited(self, top_left, bottom_right, roles=None): + """Handle a committed in-place name edit (model `dataChanged` from the Name column).""" + if not self.tab.is_editing or top_left.column() != ArchiveTableModel.COL_NAME: + return + self.tab.is_editing = False + + archive = top_left.data(ArchiveTableModel.ArchiveRole) + original = self.tab.renamed_archive_original_name + new_name = archive.name if archive else '' + profile = self.tab.profile() + + if new_name == original: + return + + if not new_name: + self._revert_name(top_left, original) + self.tab._set_status(self.tab.tr('Archive name cannot be blank.')) + return + + if ArchiveModel.get_or_none(name=new_name, repo=profile.repo) is not None: + self.tab._set_status(self.tab.tr('An archive with this name already exists.')) + self._revert_name(top_left, original) + return + + params = BorgRenameJob.prepare(profile, original, new_name) + if not params['ok']: + self.tab._set_status(params['message']) + self._revert_name(top_left, original) + return + + self.tab._set_status(self.tab.tr('Renaming archive...')) + job = BorgRenameJob(params['cmd'], params, profile.repo.id) + job.updated.connect(self.tab._set_status) + job.result.connect(self.rename_result) + self.tab._toggle_all_buttons(False) + self.tab.app.jobs_manager.add_job(job) + + def _revert_name(self, index, original): + """Restore the model's name after a rejected edit.""" + self.tab.archive_model.setData(index, original, Qt.ItemDataRole.EditRole) + + def rename_result(self, result): + if result['returncode'] == 0: + self.tab.refresh_archive_info() + self.tab._set_status(self.tab.tr('Archive renamed.')) + self.tab.renamed_archive_original_name = None + self.tab.populate_from_profile() + else: + # rename failed: refresh from the DB to drop the optimistically-applied name + self.tab.renamed_archive_original_name = None + self.tab.populate_from_profile(preserve_view=True) diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 34e949329..46e742d2d 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -4,7 +4,7 @@ from PyQt6 import QtCore, uic from PyQt6.QtCore import QItemSelectionModel, QMimeData, QPoint, Qt, pyqtSlot -from PyQt6.QtGui import QDesktopServices, QKeySequence, QShortcut +from PyQt6.QtGui import QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, @@ -20,11 +20,9 @@ from vorta.borg.check import BorgCheckJob from vorta.borg.compact import BorgCompactJob from vorta.borg.delete import BorgDeleteJob -from vorta.borg.diff import BorgDiffJob from vorta.borg.info_archive import BorgInfoArchiveJob from vorta.borg.list_repo import BorgListRepoJob from vorta.borg.prune import BorgPruneJob -from vorta.borg.rename import BorgRenameJob from vorta.i18n import trans_late, translate from vorta.i18n.richtext import escape, format_richtext, link from vorta.store.models import ArchiveModel, SettingsModel @@ -34,11 +32,11 @@ get_asset, get_mount_points, ) +from vorta.views.archive.archive_diff import ArchiveDiff from vorta.views.archive.archive_extract import ArchiveExtract from vorta.views.archive.archive_mount import ArchiveMount +from vorta.views.archive.archive_rename import ArchiveRename from vorta.views.base_tab import BaseTab -from vorta.views.dialogs.archive import diff_result -from vorta.views.dialogs.archive.diff_result import DiffResultDialog, DiffTree from vorta.views.partials.archive_table_model import SIZE_DECIMAL_DIGITS, ArchiveSortProxyModel, ArchiveTableModel from vorta.views.utils import get_colored_icon @@ -76,6 +74,8 @@ def __init__(self, parent=None, app=None, profile_provider=None): self.archive_mount = ArchiveMount(self) self.archive_extract = ArchiveExtract(self) + self.archive_diff = ArchiveDiff(self) + self.archive_rename = ArchiveRename(self) #: Tooltip dict to save the tooltips set in the designer self.tooltip_dict: Dict[QWidget, str] = {} @@ -120,11 +120,11 @@ def __init__(self, parent=None, app=None, profile_provider=None): self.archiveTable.setWordWrap(False) self.archiveTable.setTextElideMode(QtCore.Qt.TextElideMode.ElideLeft) self.archiveTable.setAlternatingRowColors(True) - self.archiveTable.doubleClicked.connect(self.cell_double_clicked) + self.archiveTable.doubleClicked.connect(self.archive_rename.cell_double_clicked) self.archiveTable.setSortingEnabled(True) self.archiveTable.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.archiveTable.customContextMenuRequested.connect(self.archiveitem_contextmenu) - self.archive_model.dataChanged.connect(self.on_name_edited) + self.archive_model.dataChanged.connect(self.archive_rename.on_name_edited) # shortcuts shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.archiveTable) @@ -137,7 +137,7 @@ def __init__(self, parent=None, app=None, profile_provider=None): # connect archive actions self.bMountArchive.clicked.connect(self.archive_mount.bmountarchive_clicked) self.bRefreshArchive.clicked.connect(self.refresh_archive_info) - self.bRename.clicked.connect(self.cell_double_clicked) + self.bRename.clicked.connect(self.archive_rename.cell_double_clicked) self.bDelete.clicked.connect(self.delete_action) self.bExtract.clicked.connect(self.archive_extract.extract_action) self.compactButton.clicked.connect(self.compact_action) @@ -146,7 +146,7 @@ def __init__(self, parent=None, app=None, profile_provider=None): self.bList.clicked.connect(self.refresh_archive_list) self.bPrune.clicked.connect(self.prune_action) self.bCheck.clicked.connect(self.check_action) - self.bDiff.clicked.connect(self.diff_action) + self.bDiff.clicked.connect(self.archive_diff.diff_action) self.bMountRepo.clicked.connect(self.archive_mount.bmountrepo_clicked) self.archiveNameTemplate.textChanged.connect( @@ -207,10 +207,10 @@ def archiveitem_contextmenu(self, pos: QPoint): # archive actions button_connection_pairs = [ (self.bRefreshArchive, self.refresh_archive_info), - (self.bDiff, self.diff_action), + (self.bDiff, self.archive_diff.diff_action), (self.bMountArchive, self.archive_mount.bmountarchive_clicked), (self.bExtract, self.archive_extract.extract_action), - (self.bRename, self.cell_double_clicked), + (self.bRename, self.archive_rename.cell_double_clicked), (self.bDelete, self.delete_action), ] @@ -565,75 +565,6 @@ def save_prune_setting(self, new_value=None): profile.prune_keep_within = self.prune_keep_within.text() profile.save() - def cell_double_clicked(self, index=None): - """Open a mounted archive's folder, or start an in-place rename.""" - if isinstance(index, QtCore.QModelIndex) and index.isValid(): - column = index.column() - else: - index = self.archiveTable.currentIndex() - column = ArchiveTableModel.COL_NAME - - if not index.isValid(): - return - - if column == ArchiveTableModel.COL_MOUNT: - archive = index.data(ArchiveTableModel.ArchiveRole) - mount_point = self.mount_points.get(archive.name) if archive else None - if mount_point is not None: - QDesktopServices.openUrl(QtCore.QUrl(f'file:///{mount_point}')) - return - - if column == ArchiveTableModel.COL_NAME: - if not self.bRename.isEnabled(): - return - archive = index.data(ArchiveTableModel.ArchiveRole) - if archive is None: - return - self.renamed_archive_original_name = archive.name - self.is_editing = True - self.archiveTable.edit(index.siblingAtColumn(ArchiveTableModel.COL_NAME)) - - def on_name_edited(self, top_left, bottom_right, roles=None): - """Handle a committed in-place name edit (model `dataChanged` from the Name column).""" - if not self.is_editing or top_left.column() != ArchiveTableModel.COL_NAME: - return - self.is_editing = False - - archive = top_left.data(ArchiveTableModel.ArchiveRole) - original = self.renamed_archive_original_name - new_name = archive.name if archive else '' - profile = self.profile() - - if new_name == original: - return - - if not new_name: - self._revert_name(top_left, original) - self._set_status(self.tr('Archive name cannot be blank.')) - return - - if ArchiveModel.get_or_none(name=new_name, repo=profile.repo) is not None: - self._set_status(self.tr('An archive with this name already exists.')) - self._revert_name(top_left, original) - return - - params = BorgRenameJob.prepare(profile, original, new_name) - if not params['ok']: - self._set_status(params['message']) - self._revert_name(top_left, original) - return - - self._set_status(self.tr('Renaming archive...')) - job = BorgRenameJob(params['cmd'], params, profile.repo.id) - job.updated.connect(self._set_status) - job.result.connect(self.rename_result) - self._toggle_all_buttons(False) - self.app.jobs_manager.add_job(job) - - def _revert_name(self, index, original): - """Restore the model's name after a rejected edit.""" - self.archive_model.setData(index, original, Qt.ItemDataRole.EditRole) - def confirm_dialog(self, title, text): msg = QMessageBox() msg.setIcon(QMessageBox.Icon.Information) @@ -690,87 +621,6 @@ def delete_result(self, result): self._toggle_all_buttons(True) - def diff_action(self): - """ - Handle the diff button being clicked. - - Exactly two archives must be selected in `archiveTable`. This is - usually enforced by `on_selection_change`. - """ - archives = self.selected_archives() - profile = self.profile() - - name1 = archives[0].name - name2 = archives[1].name - - archive1, archive2 = ( - profile.repo.archives.select() - .where((ArchiveModel.name == name1) | (ArchiveModel.name == name2)) - .order_by(ArchiveModel.time.desc()) - ) - - archive_name_newer = archive1.name - archive_name_older = archive2.name - - # Start diff job - params = BorgDiffJob.prepare(profile, archive_name_older, archive_name_newer) - - if params['ok']: - self._toggle_all_buttons(False) - job = BorgDiffJob(params['cmd'], params, self.profile().repo.id) - job.updated.connect(self.mountErrors.setText) - job.result.connect(self.list_diff_result) - self.app.jobs_manager.add_job(job) - else: - self._set_status(params['message']) - - def list_diff_result(self, result): - """ - Process the result of the `BorgDiffJob`. - - The `BorgDiffJob` was initiated by `diff_action`. - - Parameters - ---------- - result : dict - The BorgJob result. - """ - self._set_status('') - if result['returncode'] == 0: - archive_newer = ArchiveModel.get(name=result['params']['archive_name_newer']) - archive_older = ArchiveModel.get(name=result['params']['archive_name_older']) - self._set_status(self.tr("Processing diff results.")) - - model = DiffTree() - - self._t = diff_result.ParseThread(result['data'], result['params']['json_lines'], model) - self._t.finished.connect(lambda: self.show_diff_result(archive_newer, archive_older, model)) - self._t.start() - - def show_diff_result(self, archive_newer, archive_older, model): - self._t = None - - # show dialog - self._toggle_all_buttons(True) - self._set_status('') - window = DiffResultDialog(archive_newer, archive_older, model) - window.setParent(self) - window.setWindowFlags(Qt.WindowType.Window) - window.setWindowModality(Qt.WindowModality.NonModal) - self._resultwindow = window # for testing - window.show() - - def rename_result(self, result): - if result['returncode'] == 0: - self.refresh_archive_info() - self._set_status(self.tr('Archive renamed.')) - self.renamed_archive_original_name = None - self.populate_from_profile() - else: - # rename failed: refresh from the DB to drop the optimistically-applied name - self.renamed_archive_original_name = None - self.populate_from_profile(preserve_view=True) - def toggle_compact_button_visibility(self): """ Enable or disable the compact button depending on the Borg version. diff --git a/tests/unit/test_archives.py b/tests/unit/test_archives.py index 370775b3c..0d1565e26 100644 --- a/tests/unit/test_archives.py +++ b/tests/unit/test_archives.py @@ -226,7 +226,7 @@ def test_rename_failure_reverts_optimistic_name(qapp, qtbot, archive_env): model.setData(model.index(0, col), 'optimistic-name') assert model.data(model.index(0, col)) == 'optimistic-name' - tab.rename_result({'returncode': 2}) + tab.archive_rename.rename_result({'returncode': 2}) assert model.data(model.index(0, col)) == original @@ -259,7 +259,7 @@ def test_inline_archive_rename(qapp, qtbot, mocker, borg_json_output, archive_en # Trigger inline editing through the real entry point so is_editing / original name are set. index = tab.archiveTable.model().index(0, 4) tab.archiveTable.setCurrentIndex(index) - tab.cell_double_clicked(index) + tab.archive_rename.cell_double_clicked(index) # Wait for edit mode to activate qtbot.waitUntil(lambda: tab.archiveTable.viewport().focusWidget() is not None, **pytest._wait_defaults) diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index 97393bf71..250023935 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -44,7 +44,7 @@ def check(feature_name): selection_model.select(model.index(0, 0), flags) selection_model.select(model.index(1, 0), flags) - tab.diff_action() + tab.archive_diff.diff_action() qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults) assert hasattr(tab, '_resultwindow')