diff --git a/src/vorta/assets/UI/diffresult.ui b/src/vorta/assets/UI/diffresult.ui
index 0907fe0b6..97fb25e39 100644
--- a/src/vorta/assets/UI/diffresult.ui
+++ b/src/vorta/assets/UI/diffresult.ui
@@ -115,6 +115,54 @@
+
+ -
+
+
+ 4
+
+
+ 0
+
+
-
+
+
+
+ Search Pattern
+
+
+ Search Pattern
+
+
+
+
+
+ -
+
+
+ Qt::NoFocus
+
+
+ Submit Search
+
+
+
+
+ -
+
+
+ Qt::NoFocus
+
+
+ Help
+
+
+
+
+
+
+
+
-
diff --git a/src/vorta/assets/UI/extractdialog.ui b/src/vorta/assets/UI/extractdialog.ui
index a6113aafd..7f7e8e452 100644
--- a/src/vorta/assets/UI/extractdialog.ui
+++ b/src/vorta/assets/UI/extractdialog.ui
@@ -106,6 +106,55 @@
+
+ -
+
+
+ 4
+
+
+ 0
+
+
-
+
+
+
+ Search Pattern
+
+
+ Search Pattern
+
+
+
+
+
+ -
+
+
+ Qt::NoFocus
+
+
+ Submit Search
+
+
+
+
+ -
+
+
+ Qt::NoFocus
+
+
+ Help
+
+
+
+
+
+
+
+
+
-
diff --git a/src/vorta/assets/icons/search.svg b/src/vorta/assets/icons/search.svg
new file mode 100644
index 000000000..79a4a3a43
--- /dev/null
+++ b/src/vorta/assets/icons/search.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/src/vorta/views/diff_result.py b/src/vorta/views/diff_result.py
index 6ed8841a0..07caf8204 100644
--- a/src/vorta/views/diff_result.py
+++ b/src/vorta/views/diff_result.py
@@ -2,6 +2,7 @@
import json
import logging
import re
+import webbrowser
from dataclasses import dataclass
from pathlib import PurePath
from typing import List, Optional, Tuple
@@ -10,26 +11,24 @@
from PyQt6.QtCore import (
QDateTime,
QLocale,
- QMimeData,
QModelIndex,
- QPoint,
Qt,
QThread,
- QUrl,
)
-from PyQt6.QtGui import QColor, QKeySequence, QShortcut
-from PyQt6.QtWidgets import QApplication, QHeaderView, QMenu, QTreeView
+from PyQt6.QtGui import QColor
+from PyQt6.QtWidgets import QHeaderView
from vorta.store.models import SettingsModel
from vorta.utils import get_asset, pretty_bytes, uses_dark_mode
+from vorta.views.partials.file_dialog import BaseFileDialog
from vorta.views.partials.treemodel import (
FileSystemItem,
FileTreeModel,
- FileTreeSortProxyModel,
+ FileTreeSortFilterProxyModel,
path_to_str,
relative_path,
)
-from vorta.views.utils import get_colored_icon
+from vorta.views.utils import compare_values_with_sign, get_colored_icon
uifile = get_asset('UI/diffresult.ui')
DiffResultUI, DiffResultBase = uic.loadUiType(uifile)
@@ -65,121 +64,40 @@ def run(self) -> None:
parse_diff_lines(lines, self.model)
-class DiffResultDialog(DiffResultBase, DiffResultUI):
+class DiffResultDialog(BaseFileDialog, DiffResultBase, DiffResultUI):
"""Display the results of `borg diff`."""
- def __init__(self, archive_newer, archive_older, model: 'DiffTree'):
- """Init."""
- super().__init__()
- self.setupUi(self)
-
- self.model = model
- self.model.setParent(self)
-
- self.treeView: QTreeView
- self.treeView.setUniformRowHeights(True) # Allows for scrolling optimizations.
- self.treeView.setAlternatingRowColors(True)
- self.treeView.setTextElideMode(Qt.TextElideMode.ElideMiddle) # to better see name of paths
-
- # custom context menu
- self.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self.treeView.customContextMenuRequested.connect(self.treeview_context_menu)
-
- # shortcuts
- shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView)
- shortcut_copy.activated.connect(self.diff_item_copy)
+ def __init__(self, archive_newer, archive_older, model):
+ super().__init__(model)
- # add sort proxy model
- self.sortproxy = DiffSortProxyModel(self)
- self.sortproxy.setSourceModel(self.model)
- self.treeView.setModel(self.sortproxy)
- self.sortproxy.sorted.connect(self.slot_sorted)
-
- self.treeView.setSortingEnabled(True)
-
- # header
header = self.treeView.header()
header.setStretchLastSection(False) # stretch only first section
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
- # signals
-
self.archiveNameLabel_1.setText(f'{archive_newer.name}')
self.archiveNameLabel_2.setText(f'{archive_older.name}')
- self.comboBoxDisplayMode.currentIndexChanged.connect(self.change_display_mode)
- diff_result_display_mode = SettingsModel.get(key='diff_files_display_mode').str_value
- self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode))
- self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop)
- self.bCollapseAll.clicked.connect(self.treeView.collapseAll)
+ self.bHelp.clicked.connect(lambda: webbrowser.open('https://vorta.borgbase.com/usage/search/'))
- self.buttonBox.accepted.connect(self.accept)
- self.buttonBox.rejected.connect(self.reject)
+ def get_sort_proxy_model(self):
+ """Return the sort proxy model for the tree view."""
+ return DiffSortFilterProxyModel(self)
- self.set_icons()
-
- # Connect to palette change
- QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
+ def get_diff_result_display_mode(self):
+ return SettingsModel.get(key='diff_files_display_mode').str_value
def set_icons(self):
"""Set or update the icons in the right color scheme."""
self.bCollapseAll.setIcon(get_colored_icon('angle-up-solid'))
self.bFoldersOnTop.setIcon(get_colored_icon('folder-on-top'))
+ self.bSearch.setIcon(get_colored_icon('search'))
+ self.bHelp.setIcon(get_colored_icon('help-about'))
self.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree"))
self.comboBoxDisplayMode.setItemIcon(1, get_colored_icon("view-list-tree"))
self.comboBoxDisplayMode.setItemIcon(2, get_colored_icon("view-list-details"))
- def treeview_context_menu(self, pos: QPoint):
- """Display a context menu for `treeView`."""
- index = self.treeView.indexAt(pos)
- if not index.isValid():
- # popup only for items
- return
-
- menu = QMenu(self.treeView)
-
- menu.addAction(
- get_colored_icon('copy'),
- self.tr("Copy"),
- lambda: self.diff_item_copy(index),
- )
-
- if self.model.getMode() != self.model.DisplayMode.FLAT:
- menu.addSeparator()
- menu.addAction(
- get_colored_icon('angle-down-solid'),
- self.tr("Expand recursively"),
- lambda: self.treeView.expandRecursively(index),
- )
-
- menu.popup(self.treeView.viewport().mapToGlobal(pos))
-
- def diff_item_copy(self, index: QModelIndex = None):
- """
- Copy a diff item path to the clipboard.
-
- Copies the first selected item if no index is specified.
- """
- if index is None or (not index.isValid()):
- indexes = self.treeView.selectionModel().selectedRows()
-
- if not indexes:
- return
-
- index = indexes[0]
-
- index = self.sortproxy.mapToSource(index)
- item: DiffItem = index.internalPointer()
- path = PurePath('/', *item.path)
-
- data = QMimeData()
- data.setUrls([QUrl(path.as_uri())])
- data.setText(str(path))
-
- QApplication.clipboard().setMimeData(data)
-
def change_display_mode(self, selection: int):
"""
Change the display mode of the tree view
@@ -206,13 +124,6 @@ def change_display_mode(self, selection: int):
self.model.setMode(mode)
- def slot_sorted(self, column, order):
- """React the tree view being sorted."""
- # reveal selection
- selectedRows = self.treeView.selectionModel().selectedRows()
- if selectedRows:
- self.treeView.scrollTo(selectedRows[0])
-
# ---- Output parsing --------------------------------------------------------
@@ -488,11 +399,53 @@ def size_to_byte(significand: str, unit: str) -> int:
# ---- Sorting ---------------------------------------------------------------
-class DiffSortProxyModel(FileTreeSortProxyModel):
+class DiffSortFilterProxyModel(FileTreeSortFilterProxyModel):
"""
Sort a DiffTree model.
"""
+ def __init__(self, parent=None) -> None:
+ super().__init__(parent)
+
+ def get_parser(self):
+ """Add Diff view specific arguments to the parser."""
+ parser = super().get_parser()
+ parser.add_argument(
+ "-b", "--balance", type=FileTreeSortFilterProxyModel.valid_size, help="Match by balance size."
+ )
+ parser.add_argument("-c", "--change", choices=["A", "D", "M"], help="Only available in Diff View.")
+
+ return parser
+
+ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool:
+ """
+ Return whether the row should be accepted.
+ """
+
+ if not self.searchPattern:
+ return True
+
+ if not super().filterAcceptsRow(sourceRow, sourceParent):
+ return False
+
+ model = self.sourceModel()
+ item = model.index(sourceRow, 0, sourceParent).internalPointer()
+
+ if self.searchPattern.balance:
+ item_balance = item.data.size
+
+ for filter_balance in self.searchPattern.balance:
+ if not compare_values_with_sign(item_balance, filter_balance[1], filter_balance[0]):
+ return False
+
+ if self.searchPattern.change:
+ item_change = item.data.change_type.short()
+
+ if item_change != self.searchPattern.change:
+ return False
+
+ return True
+
def choose_data(self, index: QModelIndex):
"""Choose the data of index used for comparison."""
item: DiffItem = index.internalPointer()
diff --git a/src/vorta/views/extract_dialog.py b/src/vorta/views/extract_dialog.py
index 8c3874048..127bd05cc 100644
--- a/src/vorta/views/extract_dialog.py
+++ b/src/vorta/views/extract_dialog.py
@@ -1,39 +1,37 @@
+import argparse
import enum
import json
import logging
+import webbrowser
from dataclasses import dataclass
from datetime import datetime
from pathlib import PurePath
-from typing import Optional, Union
+from typing import List, Optional, Tuple, Union
from PyQt6 import uic
from PyQt6.QtCore import (
QDateTime,
QLocale,
- QMimeData,
QModelIndex,
- QPoint,
Qt,
QThread,
- QUrl,
)
-from PyQt6.QtGui import QColor, QKeySequence, QShortcut
+from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
- QApplication,
QDialogButtonBox,
QHeaderView,
- QMenu,
QPushButton,
)
from vorta.store.models import SettingsModel
from vorta.utils import borg_compat, get_asset, pretty_bytes, uses_dark_mode
-from vorta.views.utils import get_colored_icon
+from vorta.views.partials.file_dialog import BaseFileDialog
+from vorta.views.utils import compare_values_with_sign, get_colored_icon
from .partials.treemodel import (
FileSystemItem,
FileTreeModel,
- FileTreeSortProxyModel,
+ FileTreeSortFilterProxyModel,
path_to_str,
relative_path,
)
@@ -64,70 +62,43 @@ def run(self) -> None:
parse_json_lines(lines, self.model)
-class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
+class ExtractDialog(BaseFileDialog, ExtractDialogBase, ExtractDialogUI):
"""
Show the contents of an archive and allow choosing what to extract.
"""
def __init__(self, archive, model):
"""Init."""
- super().__init__()
- self.setupUi(self)
-
- self.model = model
- self.model.setParent(self)
-
- view = self.treeView
- view.setAlternatingRowColors(True)
- view.setUniformRowHeights(True) # Allows for scrolling optimizations.
-
- # custom context menu
- self.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self.treeView.customContextMenuRequested.connect(self.treeview_context_menu)
-
- # add sort proxy model
- self.sortproxy = ExtractSortProxyModel(self)
- self.sortproxy.setSourceModel(self.model)
- view.setModel(self.sortproxy)
- self.sortproxy.sorted.connect(self.slot_sorted)
-
- view.setSortingEnabled(True)
+ super().__init__(model)
# header
- header = view.header()
+ header = self.treeView.header()
header.setStretchLastSection(False)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
- # shortcuts
- shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView)
- shortcut_copy.activated.connect(self.copy_item)
-
# add extract button to button box
self.extractButton = QPushButton(self)
self.extractButton.setObjectName("extractButton")
self.extractButton.setText(self.tr("Extract"))
-
self.buttonBox.addButton(self.extractButton, QDialogButtonBox.ButtonRole.AcceptRole)
self.archiveNameLabel.setText(f"{archive.name}, {archive.time}")
- diff_result_display_mode = SettingsModel.get(key='extract_files_display_mode').str_value
-
- # connect signals
- self.comboBoxDisplayMode.currentIndexChanged.connect(self.change_display_mode)
- self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode))
- self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop)
- self.bCollapseAll.clicked.connect(self.treeView.collapseAll)
+ self.buttonBox.rejected.connect(self.reject)
self.buttonBox.rejected.connect(self.close)
- self.buttonBox.accepted.connect(self.accept)
- self.set_icons()
+ self.bHelp.clicked.connect(lambda: webbrowser.open('https://vorta.borgbase.com/usage/search/'))
+
+ def get_sort_proxy_model(self):
+ """Get the sort proxy model for this dialog."""
+ return ExtractSortFilterProxyModel()
- # Connect to palette change
- QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
+ def get_diff_result_display_mode(self):
+ """Get the display mode for this dialog."""
+ return SettingsModel.get(key='extract_files_display_mode').str_value
def retranslateUi(self, dialog):
"""Retranslate strings in ui."""
@@ -139,42 +110,13 @@ def retranslateUi(self, dialog):
def set_icons(self):
"""Set or update the icons in the right color scheme."""
+ self.bSearch.setIcon(get_colored_icon('search'))
self.bFoldersOnTop.setIcon(get_colored_icon('folder-on-top'))
self.bCollapseAll.setIcon(get_colored_icon('angle-up-solid'))
+ self.bHelp.setIcon(get_colored_icon('help-about'))
self.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree"))
self.comboBoxDisplayMode.setItemIcon(1, get_colored_icon("view-list-tree"))
- def slot_sorted(self, column, order):
- """React to the tree view being sorted."""
- # reveal selection
- selectedRows = self.treeView.selectionModel().selectedRows()
- if selectedRows:
- self.treeView.scrollTo(selectedRows[0])
-
- def copy_item(self, index: QModelIndex = None):
- """
- Copy an item path to the clipboard.
-
- Copies the first selected item if no index is specified.
- """
- if index is None or (not index.isValid()):
- indexes = self.treeView.selectionModel().selectedRows()
-
- if not indexes:
- return
-
- index = indexes[0]
-
- index = self.sortproxy.mapToSource(index)
- item: ExtractFileItem = index.internalPointer()
- path = PurePath('/', *item.path)
-
- data = QMimeData()
- data.setUrls([QUrl(path.as_uri())])
- data.setText(str(path))
-
- QApplication.clipboard().setMimeData(data)
-
def change_display_mode(self, selection: int):
"""
Change the display mode of the tree view
@@ -198,27 +140,6 @@ def change_display_mode(self, selection: int):
self.model.setMode(mode)
- def treeview_context_menu(self, pos: QPoint):
- """Display a context menu for `treeView`."""
- index = self.treeView.indexAt(pos)
- if not index.isValid():
- # popup only for items
- return
-
- menu = QMenu(self.treeView)
-
- menu.addAction(get_colored_icon('copy'), self.tr("Copy"), lambda: self.copy_item(index))
-
- if self.model.getMode() != self.model.DisplayMode.FLAT:
- menu.addSeparator()
- menu.addAction(
- get_colored_icon('angle-down-solid'),
- self.tr("Expand recursively"),
- lambda: self.treeView.expandRecursively(index),
- )
-
- menu.popup(self.treeView.viewport().mapToGlobal(pos))
-
def parse_json_lines(lines, model: "ExtractTree"):
"""Parse json output of `borg list`."""
@@ -256,11 +177,100 @@ def parse_json_lines(lines, model: "ExtractTree"):
# ---- Sorting ---------------------------------------------------------------
-class ExtractSortProxyModel(FileTreeSortProxyModel):
+class ExtractSortFilterProxyModel(FileTreeSortFilterProxyModel):
"""
Sort a ExtractTree model.
"""
+ def __init__(self, parent=None) -> None:
+ super().__init__(parent)
+
+ @staticmethod
+ def parse_date(value: str) -> Tuple[str, datetime]:
+ """
+ Parse the date string into a tuple of two values.
+ """
+
+ comparison_sign = ['<', '>', '<=', '>=', '=']
+
+ if not any(value.startswith(unit) for unit in comparison_sign):
+ raise argparse.ArgumentTypeError("Invalid date format. Supported comparison signs: <, >, <=, >=, =")
+
+ if value[1] == '=':
+ date = value[2:]
+ sign = value[:2]
+ else:
+ date = value[1:]
+ sign = value[:1]
+
+ try:
+ date = datetime.strptime(date, '%Y-%m-%d')
+ except ValueError:
+ raise argparse.ArgumentTypeError("Invalid date format. Must be YYYY-MM-DD.")
+
+ return (sign, date)
+
+ @classmethod
+ def valid_date_range(cls, value: str) -> List[Tuple[str, datetime]]:
+ """Parse the date range string."""
+ date = value.split(',')
+
+ if len(date) == 1:
+ return [cls.parse_date(date[0])]
+ elif len(date) == 2:
+ return [
+ cls.parse_date(date[0]),
+ cls.parse_date(date[1]),
+ ]
+ else:
+ raise argparse.ArgumentTypeError("Invalid date format. Can only accept two values.")
+
+ def get_parser(self):
+ """Add Extract view specific arguments to the parser."""
+ parser = super().get_parser()
+
+ health_group = parser.add_mutually_exclusive_group()
+ health_group.add_argument(
+ "--healthy", default=None, action="store_true", dest="healthy", help="Match only healthy items."
+ )
+ health_group.add_argument(
+ "--unhealthy", default=None, action="store_false", dest="healthy", help="Match only unhealthy items."
+ )
+
+ parser.add_argument(
+ "--last-modified",
+ type=self.valid_date_range,
+ help="Match by last modified date.",
+ )
+
+ return parser
+
+ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool:
+ """
+ Return whether the row should be accepted.
+ """
+
+ if not self.searchPattern:
+ return True
+
+ if not super().filterAcceptsRow(sourceRow, sourceParent):
+ return False
+
+ model = self.sourceModel()
+ item = model.index(sourceRow, 0, sourceParent).internalPointer()
+
+ if self.searchPattern.healthy is not None:
+ return item.data.health == self.searchPattern.healthy
+
+ if self.searchPattern.last_modified:
+ item_last_modified = item.data.last_modified
+
+ for filter_last_modified in self.searchPattern.last_modified:
+ if not compare_values_with_sign(item_last_modified, filter_last_modified[1], filter_last_modified[0]):
+ return False
+
+ return True
+
def choose_data(self, index: QModelIndex):
"""Choose the data of index used for comparison."""
item: ExtractFileItem = index.internalPointer()
diff --git a/src/vorta/views/partials/file_dialog.py b/src/vorta/views/partials/file_dialog.py
new file mode 100644
index 000000000..05cef229c
--- /dev/null
+++ b/src/vorta/views/partials/file_dialog.py
@@ -0,0 +1,156 @@
+from abc import ABCMeta, abstractmethod
+from pathlib import PurePath
+
+from PyQt6.QtCore import (
+ QMimeData,
+ QModelIndex,
+ QPoint,
+ Qt,
+ QUrl,
+)
+from PyQt6.QtGui import QKeySequence, QShortcut
+from PyQt6.QtWidgets import QApplication, QDialog, QMenu, QTreeView
+
+from vorta.views.utils import get_colored_icon
+
+
+class BaseFileDialog(QDialog):
+ """
+ Base class for all file view dialogs.
+ Attributes:
+ model: The model to use for the file view.
+ """
+
+ __metaclass__ = ABCMeta
+
+ def __init__(self, model):
+ """
+ Initialize the file view dialog with model, setup UI and connect signals.
+ """
+
+ super().__init__()
+ self.setupUi(self)
+
+ self.model = model
+ self.model.setParent(self)
+
+ self.treeView: QTreeView
+ self.treeView.setUniformRowHeights(True) # Allows for scrolling optimizations.
+ self.treeView.setAlternatingRowColors(True)
+ self.treeView.setTextElideMode(Qt.TextElideMode.ElideMiddle) # to better see name of paths
+
+ # custom context menu
+ self.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.treeView.customContextMenuRequested.connect(self.treeview_context_menu)
+
+ # shortcuts
+ shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView)
+ shortcut_copy.activated.connect(self.copy_item)
+
+ # add sort proxy model
+ self.sortproxy = self.get_sort_proxy_model()
+ self.sortproxy.setSourceModel(self.model)
+ self.treeView.setModel(self.sortproxy)
+ self.sortproxy.sorted.connect(self.slot_sorted)
+
+ self.treeView.setSortingEnabled(True)
+
+ # signals
+
+ self.comboBoxDisplayMode.currentIndexChanged.connect(self.change_display_mode)
+ diff_result_display_mode = self.get_diff_result_display_mode()
+ self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode))
+ self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop)
+ self.bCollapseAll.clicked.connect(self.treeView.collapseAll)
+
+ self.bSearch.clicked.connect(self.submitSearchPattern)
+ self.sortproxy.searchStringError.connect(self.searchStringError)
+
+ self.buttonBox.accepted.connect(self.accept)
+ self.buttonBox.rejected.connect(self.reject)
+
+ # Add a cross icon inside the search field to clear the search string
+ self.searchWidget.setClearButtonEnabled(True)
+ # self.searchLineEdit.textChanged.connect(self.searchLineEditChanged)
+
+ self.set_icons()
+
+ # Connect to palette change
+ QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
+
+ @abstractmethod
+ def get_sort_proxy_model(self):
+ """Return a sort proxy model for the file view."""
+ pass
+
+ @abstractmethod
+ def get_diff_result_display_mode(self):
+ """Return the display mode for the diff result."""
+ pass
+
+ def copy_item(self, index: QModelIndex = None):
+ """
+ Copy a diff item path to the clipboard.
+
+ Copies the first selected item if no index is specified.
+ """
+ if index is None or (not index.isValid()):
+ indexes = self.treeView.selectionModel().selectedRows()
+
+ if not indexes:
+ return
+
+ index = indexes[0]
+
+ index = self.sortproxy.mapToSource(index)
+ item = index.internalPointer()
+ path = PurePath('/', *item.path)
+
+ data = QMimeData()
+ data.setUrls([QUrl(path.as_uri())])
+ data.setText(str(path))
+
+ QApplication.clipboard().setMimeData(data)
+
+ def treeview_context_menu(self, pos: QPoint):
+ """Display a context menu for `treeView`."""
+ index = self.treeView.indexAt(pos)
+ if not index.isValid():
+ # popup only for items
+ return
+
+ menu = QMenu(self.treeView)
+
+ menu.addAction(get_colored_icon('copy'), self.tr("Copy"), lambda: self.copy_item(index))
+
+ if self.model.getMode() != self.model.DisplayMode.FLAT:
+ menu.addSeparator()
+ menu.addAction(
+ get_colored_icon('angle-down-solid'),
+ self.tr("Expand recursively"),
+ lambda: self.treeView.expandRecursively(index),
+ )
+
+ menu.popup(self.treeView.viewport().mapToGlobal(pos))
+
+ def slot_sorted(self, column, order):
+ """React to the tree view being sorted."""
+ # reveal selection
+ selectedRows = self.treeView.selectionModel().selectedRows()
+ if selectedRows:
+ self.treeView.scrollTo(selectedRows[0])
+
+ def keyPressEvent(self, event):
+ """React to Enter key press in search field."""
+ if event.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter] and self.searchWidget.hasFocus():
+ self.submitSearchPattern()
+ else:
+ super().keyPressEvent(event)
+
+ def submitSearchPattern(self):
+ """Submit the search pattern to the sort proxy model."""
+ self.sortproxy.setSearchString(self.searchWidget.text())
+
+ def searchStringError(self, error: bool):
+ """Handle search string errors."""
+ self.searchWidget.setStyleSheet("QLineEdit { border: 2px solid red; }" if error else "")
diff --git a/src/vorta/views/partials/treemodel.py b/src/vorta/views/partials/treemodel.py
index ceff3eb46..7be5da84d 100644
--- a/src/vorta/views/partials/treemodel.py
+++ b/src/vorta/views/partials/treemodel.py
@@ -2,10 +2,12 @@
Implementation of a tree model for use with `QTreeView` based on (file) paths.
"""
-
+import argparse
import bisect
import enum
import os.path as osp
+import re
+from fnmatch import fnmatch
from functools import reduce
from pathlib import PurePath
from typing import Generic, List, Optional, Sequence, Tuple, TypeVar, Union, overload
@@ -19,6 +21,8 @@
pyqtSignal,
)
+from vorta.views.utils import compare_values_with_sign
+
#: A representation of a path
Path = Tuple[str, ...]
PathLike = Union[Path, Sequence[str]]
@@ -896,17 +900,60 @@ def headerData(
return super().headerData(section, orientation, role)
-class FileTreeSortProxyModel(QSortFilterProxyModel):
+class FileTreeSortFilterProxyModel(QSortFilterProxyModel):
"""
- Sort a FileTreeModel.
+ Sort and Filter a FileTreeModel.
"""
sorted = pyqtSignal(int, Qt.SortOrder)
+ searchStringError = pyqtSignal(bool)
def __init__(self, parent=None) -> None:
"""Init."""
super().__init__(parent)
+
+ self.setRecursiveFilteringEnabled(True)
+ self.setAutoAcceptChildRows(True)
+
self.folders_on_top = False
+ self.searchPattern = None
+
+ @staticmethod
+ def parse_size(size: str) -> Tuple[str, int]:
+ """
+ Parse the size string into a tuple of two values.
+ """
+
+ comparison_sign = ['<', '>', '<=', '>=', '=']
+ size_units = ['KB', 'MB', 'GB']
+
+ # TODO: Should we just use regex? ^([<>]=?|)(\d+(\.\d+)?)([KMG]B)?$
+ if not any(size.startswith(unit) for unit in comparison_sign):
+ raise argparse.ArgumentTypeError("Invalid size format. Supported comparison signs: <, >, <=, >=")
+
+ if not any(size.endswith(unit) for unit in size_units):
+ raise argparse.ArgumentTypeError("Invalid size format. Supported units: KB, MB, GB")
+
+ try:
+ unit = size[-2:]
+ if size[1] == '=':
+ return (size[:2], int(size[2:-2]) * 1024 ** (size_units.index(unit) + 1))
+ else:
+ return (size[0], int(size[1:-2]) * 1024 ** (size_units.index(unit) + 1))
+ except ValueError:
+ raise argparse.ArgumentTypeError("Invalid size format. Must be a number.")
+
+ @classmethod
+ def valid_size(cls, value: str) -> List[Tuple[str, int]]:
+ """Validate the size string."""
+ size = value.split(',')
+
+ if len(size) == 1:
+ return [cls.parse_size(size[0])]
+ elif len(size) == 2:
+ return [cls.parse_size(size[0]), cls.parse_size(size[1])]
+ else:
+ raise argparse.ArgumentTypeError("Invalid size format. Can only accept two values.")
@overload
def keepFoldersOnTop(self) -> bool:
@@ -963,7 +1010,7 @@ def extract_path(self, index: QModelIndex):
def choose_data(self, index: QModelIndex):
"""Choose the data of index used for comparison."""
raise NotImplementedError(
- "Method `choose_data` of " + "FileTreeSortProxyModel" + " must be implemented by subclasses."
+ "Method `choose_data` of " + "FileTreeSortFilterProxyModel" + " must be implemented by subclasses."
)
def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool:
@@ -994,3 +1041,107 @@ def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool:
data1 = self.choose_data(left)
data2 = self.choose_data(right)
return data1 < data2
+
+ def setSearchString(self, pattern: str):
+ """
+ Set the pattern to filter for.
+ """
+
+ try:
+ self.searchPattern = self.parse_search_string(pattern)
+ except SystemExit:
+ self.searchStringError.emit(True)
+ return None
+
+ self.searchStringError.emit(False)
+ self.invalidateRowsFilter()
+
+ def get_parser(self):
+ """
+ Creates and returns the parser for the search string.
+ """
+ parser = argparse.ArgumentParser(description="Search files and folders based on various options.")
+ parser.add_argument("search_string", nargs="*", default=[], help="String to search in the name.")
+ parser.add_argument(
+ "-m", "--match", choices=["in", "ex", "re", "fm"], default=None, help="Type of match query."
+ )
+ parser.add_argument("-i", "--ignore-case", action="store_true", help="Ignore case.")
+ parser.add_argument("-p", "--path", action="store_true", help="Match by path.")
+ parser.add_argument("-s", "--size", type=FileTreeSortFilterProxyModel.valid_size, help="Match by size.")
+ parser.add_argument("--exclude-parents", action="store_true", help="Match only items without children.")
+
+ return parser
+
+ def parse_search_string(self, pattern: str):
+ """
+ Parse the search string into a list of tokens.
+
+ May raise `SystemExit`.
+ """
+
+ parser = self.get_parser()
+ return parser.parse_args(pattern.split())
+
+ def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool:
+ """
+ Return whether the row should be accepted.
+ """
+
+ if not self.searchPattern:
+ return True
+
+ model = self.sourceModel()
+ item = model.index(sourceRow, 0, sourceParent).internalPointer()
+
+ item_path = path_to_str(item.path)
+ item_name = item.subpath
+
+ # Exclude Parents
+ if self.searchPattern.exclude_parents:
+ if item.children:
+ return False
+
+ # Set default values
+ if self.searchPattern.match is None:
+ self.searchPattern.match = "fm" if self.searchPattern.path else "in"
+
+ if self.searchPattern.path:
+ search_item = item_path
+ else:
+ search_item = item_name
+
+ if self.searchPattern.search_string:
+ search_string = " ".join(self.searchPattern.search_string)
+
+ # Ignore Case?
+ if self.searchPattern.ignore_case:
+ search_item = search_item.lower()
+ search_string = search_string.lower()
+
+ if self.searchPattern.match == "in" and search_string not in search_item:
+ return False
+ elif self.searchPattern.match == "ex" and search_string != search_item:
+ return False
+ elif self.searchPattern.match == "re":
+ try:
+ if not re.search(search_string, search_item):
+ return False
+ except re.error:
+ self.searchStringError.emit(True)
+ return False
+ elif self.searchPattern.match == "fm" and not fnmatch(search_item, search_string):
+ return False
+
+ if self.searchPattern.size:
+ # Diff view has size column corresponding to the changed_size while
+ # Extract view has size column corresponding to the size
+ if hasattr(item.data, 'changed_size'):
+ item_size = item.data.changed_size
+ else:
+ item_size = item.data.size
+
+ for filter_size in self.searchPattern.size:
+ if not compare_values_with_sign(item_size, filter_size[1], filter_size[0]):
+ return False
+
+ return True
diff --git a/src/vorta/views/utils.py b/src/vorta/views/utils.py
index 5a9697a73..02249b102 100644
--- a/src/vorta/views/utils.py
+++ b/src/vorta/views/utils.py
@@ -49,3 +49,19 @@ def get_exclusion_presets():
'tags': preset['tags'],
}
return allPresets
+
+
+def compare_values_with_sign(item_value: int, filter_value: int, comparison_sign: str) -> bool:
+ """
+ Compare two values with a comparison sign.
+ """
+ if comparison_sign == '<':
+ return item_value < filter_value
+ elif comparison_sign == '>':
+ return item_value > filter_value
+ elif comparison_sign == '<=':
+ return item_value <= filter_value
+ elif comparison_sign == '>=':
+ return item_value >= filter_value
+ elif comparison_sign == '=':
+ return item_value == filter_value
diff --git a/tests/unit/borg_json_output/diff_archives_search_stderr.json b/tests/unit/borg_json_output/diff_archives_search_stderr.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/borg_json_output/diff_archives_search_stdout.json b/tests/unit/borg_json_output/diff_archives_search_stdout.json
new file mode 100644
index 000000000..a5beee78d
--- /dev/null
+++ b/tests/unit/borg_json_output/diff_archives_search_stdout.json
@@ -0,0 +1,5 @@
+{"changes": [{"new_ctime": "2023-09-25T15:15:45.857656", "old_ctime": "2023-08-08T13:29:52.873997", "type": "ctime"}, {"new_mtime": "2023-09-25T15:15:45.857656", "old_mtime": "2023-08-08T13:29:52.873997", "type": "mtime"}], "path": "home/kali/vorta/source1"}
+{"changes": [{"added": 5061, "removed": 2530, "type": "modified"}, {"new_ctime": "2023-09-25T15:15:03.824651", "old_ctime": "2023-08-08T13:29:52.873997", "type": "ctime"}, {"new_mtime": "2023-09-25T15:15:03.824651", "old_mtime": "2023-08-08T13:29:52.869997", "type": "mtime"}], "path": "home/kali/vorta/source1/hello.txt"}
+{"changes": [{"size": 0, "type": "added"}], "path": "home/kali/vorta/source1/emptyfile.bin"}
+{"changes": [{"size": 56, "type": "added"}], "path": "home/kali/vorta/source1/notemptyfile.bin"}
+{"changes": [{"size": 0, "type": "removed"}], "path": "home/kali/vorta/source1/file1.txt"}
diff --git a/tests/unit/borg_json_output/extract_archives_search_stderr.json b/tests/unit/borg_json_output/extract_archives_search_stderr.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/borg_json_output/extract_archives_search_stdout.json b/tests/unit/borg_json_output/extract_archives_search_stdout.json
new file mode 100644
index 000000000..338f6812b
--- /dev/null
+++ b/tests/unit/borg_json_output/extract_archives_search_stdout.json
@@ -0,0 +1,5 @@
+{"type": "d", "mode": "drwx------", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "home/kali/vorta/source1", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-08-08T13:29:52.873997", "size": 0}
+{"type": "-", "mode": "-rw-r--r--", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "home/kali/vorta/source1/file1.txt", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-03-27T10:36:25.418000", "size": 0}
+{"type": "-", "mode": "-rw-r--r--", "user": "root", "group": "root", "uid": 0, "gid": 0, "path": "home/kali/vorta/source1/file.txt", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2030-08-15T21:20:12.188002", "size": 0}
+{"type": "-", "mode": "-rw-r--r--", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "home/kali/vorta/source1/hello.txt", "healthy": true, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-08-08T13:29:52.869997", "size": 2530}
+{"type": "-", "mode": "-rw-r--r--", "user": "kali", "group": "kali", "uid": 1000, "gid": 1000, "path": "work/abigfile.pdf", "healthy": false, "source": "", "linktarget": "", "flags": 0, "isomtime": "2023-08-17T00:00:00.000000", "size": 10000000}
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index e622a2118..b72be081a 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -6,6 +6,7 @@
import vorta.application
import vorta.borg.jobs_manager
from peewee import SqliteDatabase
+from PyQt6.QtCore import Qt
from vorta.store.models import (
ArchiveModel,
BackupProfileModel,
@@ -68,11 +69,12 @@ def init_db(qapp, qtbot, tmpdir_factory):
del qapp.main_window
qapp.main_window = MainWindow(qapp) # Re-open main window to apply mock data in UI
+ qapp.scheduler.schedule_changed.disconnect()
+
yield
qapp.jobs_manager.cancel_all_jobs()
qapp.backup_finished_event.disconnect()
- qapp.scheduler.schedule_changed.disconnect()
qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults)
mock_db.close()
@@ -118,3 +120,26 @@ def archive_env(qapp, qtbot):
tab.populate_from_profile()
qtbot.waitUntil(lambda: tab.archiveTable.rowCount() == 2, **pytest._wait_defaults)
return main, tab
+
+
+@pytest.fixture
+def search_visible_items_in_tree():
+ """ "
+ Returns a function that searches for visible items in a QTreeView.
+ """
+
+ def inner_search_visible_items_in_tree(model, parent_index):
+ filtered_items = []
+
+ def recursive_search_visible_items_in_tree(model, parent_index):
+ for row in range(model.rowCount(parent_index)):
+ index = model.index(row, 0, parent_index)
+ if model.data(index, Qt.ItemDataRole.DisplayRole) is not None:
+ if model.rowCount(index) == 0:
+ filtered_items.append(model.data(index, Qt.ItemDataRole.DisplayRole))
+ recursive_search_visible_items_in_tree(model, index)
+
+ recursive_search_visible_items_in_tree(model, parent_index)
+ return filtered_items
+
+ return inner_search_visible_items_in_tree
diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py
index e7ef9fd9b..bcd846537 100644
--- a/tests/unit/test_diff.py
+++ b/tests/unit/test_diff.py
@@ -4,7 +4,7 @@
import vorta.borg
import vorta.utils
import vorta.views.archive_tab
-from PyQt6.QtCore import QDateTime, QItemSelectionModel, Qt
+from PyQt6.QtCore import QDateTime, QItemSelectionModel, QModelIndex, Qt
from PyQt6.QtWidgets import QMenu
from vorta.store.models import ArchiveModel
from vorta.views.diff_result import (
@@ -75,7 +75,7 @@ def test_diff_item_copy(qapp, qtbot, mocker, borg_json_output, archive_env):
# test 'diff_item_copy()' by passing it an item to copy
index = tab._resultwindow.treeView.model().index(0, 0)
assert index is not None
- tab._resultwindow.diff_item_copy(index)
+ tab._resultwindow.copy_item(index)
clipboard_data = clipboard_spy.call_args[0][0]
assert clipboard_data.hasText()
assert clipboard_data.text() == "/test"
@@ -86,7 +86,7 @@ def test_diff_item_copy(qapp, qtbot, mocker, borg_json_output, archive_env):
flags = QItemSelectionModel.SelectionFlag.Rows
flags |= QItemSelectionModel.SelectionFlag.Select
tab._resultwindow.treeView.selectionModel().select(tab._resultwindow.treeView.model().index(0, 0), flags)
- tab._resultwindow.diff_item_copy()
+ tab._resultwindow.copy_item()
clipboard_data = clipboard_spy.call_args[0][0]
assert clipboard_data.hasText()
assert clipboard_data.text() == "/test"
@@ -476,3 +476,104 @@ def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAl
assert dialog.model.mode == expected_mode
assert dialog.bCollapseAll.isEnabled() == expected_bCollapseAllEnabled
+
+
+@pytest.mark.parametrize(
+ 'search_string,expected_search_results,emit_error',
+ [
+ # Normal "in" search
+ ('txt', ['hello.txt', 'file1.txt'], False),
+ # Ignore Case
+ ('HELLO.txt -i', ['hello.txt'], False),
+ ('HELLO.txt', [], False),
+ # Size Match
+ ('--size >=15MB', [], False),
+ ('--size >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin'], False),
+ ('--size >1KB,<1MB --exclude-parents', ['hello.txt'], False),
+ # Path Match Type
+ ('home/kali/vorta/source1/hello.txt --path', ['hello.txt'], False),
+ ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt'], False),
+ ('home/kali/vorta/source1/*.bin --path -m fm', ['notemptyfile.bin', 'emptyfile.bin'], False),
+ # Regex Match Type
+ ("file[^/]*\\.txt|\\.bin -m re", ['file1.txt', 'notemptyfile.bin', 'emptyfile.bin'], False),
+ ("[ -m re", [], True),
+ # Exact Match Type
+ ('hello', ['hello.txt'], False),
+ ('hello -m ex', [], False),
+ # Diff Specific Filters #
+ # Balance Match
+ ('--balance >1KB,<1MB', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin'], False),
+ ('--balance >1KB,<1MB --exclude-parents', ['hello.txt'], False),
+ ('--balance >10GB', [], False),
+ # Change Type
+ ('--change A', ['notemptyfile.bin', 'emptyfile.bin'], False),
+ ('--change D', ['file1.txt'], False),
+ ('--change M', ['notemptyfile.bin', 'hello.txt', 'file1.txt', 'emptyfile.bin'], False),
+ ],
+)
+def test_archive_diff_filters(
+ qtbot,
+ mocker,
+ borg_json_output,
+ search_visible_items_in_tree,
+ archive_env,
+ search_string,
+ expected_search_results,
+ emit_error,
+):
+ """
+ Tests the supported search filters for the diff window.
+ """
+
+ vorta.utils.borg_compat.version = '1.2.4'
+
+ # _, tab = archive_env
+ main, tab = archive_env
+ main.show()
+ tab.archiveTable.selectRow(0)
+
+ selection_model: QItemSelectionModel = tab.archiveTable.selectionModel()
+ model = tab.archiveTable.model()
+
+ flags = QItemSelectionModel.SelectionFlag.Rows
+ flags |= QItemSelectionModel.SelectionFlag.Select
+
+ selection_model.select(model.index(0, 0), flags)
+ selection_model.select(model.index(1, 0), flags)
+
+ stdout, stderr = borg_json_output('diff_archives_search')
+ popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
+ mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)
+
+ # click on diff button
+ qtbot.mouseClick(tab.bDiff, Qt.MouseButton.LeftButton)
+
+ # Wait for window to open
+ qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults)
+ qtbot.waitUntil(lambda: tab._resultwindow.treeView.model().rowCount(QModelIndex()) > 0, **pytest._wait_defaults)
+
+ tab._resultwindow.searchWidget.setText(search_string)
+ qtbot.mouseClick(tab._resultwindow.bSearch, Qt.MouseButton.LeftButton)
+
+ qtbot.waitUntil(
+ lambda: (tab._resultwindow.treeView.model().rowCount(QModelIndex()) > 0) or (len(expected_search_results) == 0),
+ **pytest._wait_defaults,
+ )
+
+ proxy_model = tab._resultwindow.treeView.model()
+
+ filtered_items = search_visible_items_in_tree(proxy_model, QModelIndex())
+
+ # sort both lists to make sure the order is not important
+ filtered_items.sort()
+ expected_search_results.sort()
+
+ assert filtered_items == expected_search_results
+
+ # Check if error is emitted
+ if emit_error:
+ assert tab._resultwindow.searchWidget.styleSheet() == 'QLineEdit { border: 2px solid red; }'
+ else:
+ assert tab._resultwindow.searchWidget.styleSheet() == ''
+
+ vorta.utils.borg_compat.version = '1.1.0'
diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py
index e68da0d28..7f8442947 100644
--- a/tests/unit/test_extract.py
+++ b/tests/unit/test_extract.py
@@ -30,6 +30,7 @@ def prepare_borg(mocker, borg_json_output):
"linktarget": "",
"flags": None,
"mtime": "2022-05-13T14:33:57.305797",
+ "isomtime": "2023-08-17T00:00:00.000000",
"size": 0,
}
@@ -197,3 +198,90 @@ def test_change_display_mode(selection: int, expected_mode, expected_bCollapseAl
assert dialog.model.mode == expected_mode
assert dialog.bCollapseAll.isEnabled() == expected_bCollapseAllEnabled
+
+
+@pytest.mark.parametrize(
+ 'search_string,expected_search_results,emit_error',
+ [
+ # Normal "in" search
+ ('txt', ['hello.txt', 'file1.txt', 'file.txt'], False),
+ # Ignore Case
+ ('HELLO.txt -i', ['hello.txt'], False),
+ ('HELLO.txt', [], False),
+ # Size Match
+ ('--size >=15MB', [], False),
+ ('--size >9MB,<11MB', ['abigfile.pdf'], False),
+ ('--size >1KB,<4KB --exclude-parents', ['hello.txt'], False),
+ # Path Match Type
+ ('home/kali/vorta/source1/hello.txt --path', ['hello.txt'], False),
+ ('home/kali/vorta/source1/file*.txt --path -m fm', ['file1.txt', 'file.txt'], False),
+ # Regex Match Type
+ ("file[^/]*\\.txt|\\.pdf -m re", ['file1.txt', 'file.txt', 'abigfile.pdf'], False),
+ ("[ -m re", [], True),
+ # Exact Match Type
+ ('hello', ['hello.txt'], False),
+ ('hello -m ex', [], False),
+ # Extract Specific Filters #
+ # Date Filter
+ ('--last-modified >2025-01-01', ['file.txt'], False),
+ ('--last-modified <2025-01-01 --exclude-parents', ['hello.txt', 'file1.txt', 'abigfile.pdf'], False),
+ # Health match
+ ('--unhealthy', ['abigfile.pdf'], False),
+ ('--healthy', ['hello.txt', 'file1.txt', 'file.txt', 'abigfile.pdf'], False),
+ ],
+)
+def test_archive_extract_filters(
+ qtbot,
+ mocker,
+ borg_json_output,
+ search_visible_items_in_tree,
+ archive_env,
+ search_string,
+ expected_search_results,
+ emit_error,
+):
+ """
+ Tests the supported search filters for the extract window.
+ """
+
+ vorta.utils.borg_compat.version = '1.2.4'
+
+ _, tab = archive_env
+ tab.archiveTable.selectRow(0)
+
+ stdout, stderr = borg_json_output('extract_archives_search')
+ popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
+ mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)
+
+ # click on extract button
+ qtbot.mouseClick(tab.bExtract, Qt.MouseButton.LeftButton)
+
+ # Wait for window to open
+ qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults)
+ qtbot.waitUntil(lambda: tab._window.treeView.model().rowCount(QModelIndex()) > 0, **pytest._wait_defaults)
+
+ tab._window.searchWidget.setText(search_string)
+ qtbot.mouseClick(tab._window.bSearch, Qt.MouseButton.LeftButton)
+
+ qtbot.waitUntil(
+ lambda: (tab._window.treeView.model().rowCount(QModelIndex()) > 0) or (len(expected_search_results) == 0),
+ **pytest._wait_defaults,
+ )
+
+ proxy_model = tab._window.treeView.model()
+
+ filtered_items = search_visible_items_in_tree(proxy_model, QModelIndex())
+
+ # sort both lists to make sure the order is not important
+ filtered_items.sort()
+ expected_search_results.sort()
+
+ assert filtered_items == expected_search_results
+
+ # Check if error is emitted
+ if emit_error:
+ assert tab._window.searchWidget.styleSheet() == 'QLineEdit { border: 2px solid red; }'
+ else:
+ assert tab._window.searchWidget.styleSheet() == ''
+
+ vorta.utils.borg_compat.version = '1.1.0'