mirror of https://github.com/borgbase/vorta
Improve the extract and diff dialog. By @real-yfprojects (#1219)
This commit is contained in:
commit
d8e4a93cdd
|
@ -3,9 +3,10 @@ import signal
|
|||
import sys
|
||||
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from vorta._version import __version__
|
||||
from vorta.i18n import trans_late, translate
|
||||
from vorta.config import SETTINGS_DIR
|
||||
from vorta.i18n import trans_late, translate
|
||||
from vorta.log import init_logger, logger
|
||||
from vorta.store.connection import init_db
|
||||
from vorta.updater import get_updater
|
||||
|
@ -15,6 +16,7 @@ from vorta.utils import parse_args
|
|||
def main():
|
||||
def exception_handler(type, value, tb):
|
||||
from traceback import format_exception
|
||||
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
logger.critical("Uncaught exception, file a report at https://github.com/borgbase/vorta/issues/new",
|
||||
exc_info=(type, value, tb))
|
||||
|
|
|
@ -88,8 +88,6 @@ class VortaApp(QtSingleApplication):
|
|||
if sys.platform == 'darwin':
|
||||
self.check_darwin_permissions()
|
||||
|
||||
self.installEventFilter(self)
|
||||
|
||||
def create_backups_cmdline(self, profile_name):
|
||||
profile = BackupProfileModel.get_or_none(name=profile_name)
|
||||
if profile is not None:
|
||||
|
@ -99,17 +97,6 @@ class VortaApp(QtSingleApplication):
|
|||
else:
|
||||
logger.warning(f"Invalid profile name {profile_name}")
|
||||
|
||||
def eventFilter(self, source, event):
|
||||
if event.type() == QtCore.QEvent.ApplicationPaletteChange and isinstance(source, MainWindow):
|
||||
self.main_window.set_icons()
|
||||
self.main_window.repoTab.set_icons()
|
||||
self.main_window.archiveTab.set_icons()
|
||||
self.main_window.scheduleTab.set_icons()
|
||||
self.main_window.sourceTab.set_icons()
|
||||
if event.type() == QtCore.QEvent.ApplicationPaletteChange and source == self.tray.contextMenu():
|
||||
self.tray.set_tray_icon()
|
||||
return False
|
||||
|
||||
def quit_app_action(self):
|
||||
self.backup_cancelled_event.emit()
|
||||
del self.main_window
|
||||
|
|
|
@ -16,24 +16,14 @@
|
|||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="spacing">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>10</number>
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Difference between</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="archiveNameLabel_1">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>nyx2.local-2018-11-16T09:49:58 </string>
|
||||
</property>
|
||||
|
@ -42,18 +32,12 @@
|
|||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>and</string>
|
||||
<string>↔</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="archiveNameLabel_2">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>nyx2.local-2018-10-16T09:49:58 </string>
|
||||
</property>
|
||||
|
@ -72,6 +56,63 @@
|
|||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="bFoldersOnTop">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::NoFocus</enum>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Keep folders on top when sorting</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Folders On Top</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBoxDisplayMode">
|
||||
<property name="toolTip">
|
||||
<string>Set display mode of diff view</string>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QComboBox::AdjustToContents</enum>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Tree</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Tree, simplified</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Flat</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="bCollapseAll">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::NoFocus</enum>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Collapse All</string>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
|
@ -83,22 +124,9 @@
|
|||
<number>10</number>
|
||||
</property>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="okButton">
|
||||
<property name="text">
|
||||
<string>Ok</string>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Close</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
@ -106,6 +134,10 @@
|
|||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<tabstops>
|
||||
<tabstop>treeView</tabstop>
|
||||
<tabstop>comboBoxDisplayMode</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
|
|
|
@ -52,16 +52,65 @@
|
|||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="bFoldersOnTop">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::NoFocus</enum>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Keep folders on top when sorting</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Folders On Top</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBoxDisplayMode">
|
||||
<property name="toolTip">
|
||||
<string>Set display mode of diff view</string>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QComboBox::AdjustToContents</enum>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Tree</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Tree, simplified</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="bCollapseAll">
|
||||
<property name="focusPolicy">
|
||||
<enum>Qt::NoFocus</enum>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Collapse All</string>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTreeView" name="treeView"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Note: If you select a top-level folder and deselect its children, they will still be restored.</string>
|
||||
</property>
|
||||
<widget class="QTreeView" name="treeView">
|
||||
<attribute name="headerMinimumSectionSize">
|
||||
<number>10</number>
|
||||
</attribute>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg viewBox="0 0 512 512" version="1.1" id="svg3" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs id="defs7" />
|
||||
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path d="m 255.985,368 c -8.188,0 -16.38,-3.125 -22.62,-9.375 l -160,-160 c -12.5,-12.5 -12.5,-32.75 0,-45.25 12.5,-12.5 32.75,-12.5 45.25,0 l 137.37,137.425 137.4,-137.4 c 12.5,-12.5 32.75,-12.5 45.25,0 12.5,12.5 12.5,32.75 0,45.25 l -160,160 c -6.25,6.25 -14.45,9.35 -22.65,9.35 z" id="path2" style="fill:#000000;fill-opacity:1;stroke:none" />
|
||||
</svg>
|
After Width: | Height: | Size: 735 B |
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg viewBox="0 0 512 512" version="1.1" id="svg3" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
|
||||
<path fill="#000000" d="m 416.025,367.925 c -8.188,0 -16.38,-3.125 -22.62,-9.375 l -137.38,-137.325 -137.4,137.4 c -12.5,12.5 -32.75,12.5 -45.25,0 -12.5,-12.5 -12.5,-32.75 0,-45.25 l 160,-160 c 12.5,-12.5 32.75,-12.5 45.25,0 l 160,160 c 12.5,12.5 12.5,32.75 0,45.25 -6.2,6.2 -14.4,9.3 -22.6,9.3 z" id="path2" style="fill:#000000;fill-opacity:1;stroke:none;stroke-opacity:1" />
|
||||
</svg>
|
After Width: | Height: | Size: 742 B |
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="#000000" d="M 4 4 L 4 28 L 18 28 L 18 27 L 5 27 L 5 14 L 10 14 L 13 11 L 27 11 L 27 16 L 28 16 L 28 7 L 18 7 L 15 4 L 4 4 z M 23 16 L 18 21 L 18.707031 21.707031 L 22.5 17.914062 L 22.5 28.003906 L 23.5 28.003906 L 23.5 17.914062 L 27.292969 21.707031 L 28 21 L 24 17 L 23 16 z " id="path71" class="ColorScheme-Text" />
|
||||
</svg>
|
After Width: | Height: | Size: 401 B |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="#000000"
|
||||
d="M 2 2 L 2 7 L 7 7 L 7 2 L 2 2 z M 8 2 L 8 3 L 14 3 L 14 2 L 8 2 z M 3 3 L 6 3 L 6 6 L 3 6 L 3 3 z M 8 5 L 8 6 L 14 6 L 14 5 L 8 5 z M 2 9 L 2 14 L 7 14 L 7 9 L 2 9 z M 8 9 L 8 10 L 12 10 L 12 9 L 8 9 z M 8 12 L 8 13 L 12 13 L 12 12 L 8 12 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 381 B |
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="#000000"
|
||||
d="M 2 2 L 2 7 L 6 7 L 6 12 L 7 12 L 9 12 L 9 14 L 14 14 L 14 9 L 9 9 L 9 11 L 7 11 L 7 7 L 7 5 L 9 5 L 9 7 L 14 7 L 14 2 L 9 2 L 9 4 L 7 4 L 7 2 L 2 2 z M 3 3 L 6 3 L 6 6 L 3 6 L 3 3 z M 10 3 L 13 3 L 13 6 L 10 6 L 10 3 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 360 B |
|
@ -1,3 +1,10 @@
|
|||
import tempfile
|
||||
|
||||
from PyQt5.QtCore import QModelIndex, Qt
|
||||
|
||||
from vorta.views.extract_dialog import ExtractTree, FileData
|
||||
from vorta.views.partials.treemodel import FileSystemItem, path_to_str
|
||||
|
||||
from .borg_job import BorgJob
|
||||
|
||||
|
||||
|
@ -13,7 +20,7 @@ class BorgExtractJob(BorgJob):
|
|||
self.app.backup_progress_event.emit(self.tr('Restored files from archive.'))
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, profile, archive_name, selected_files, destination_folder):
|
||||
def prepare(cls, profile, archive_name, model: ExtractTree, destination_folder):
|
||||
ret = super().prepare(profile)
|
||||
if not ret['ok']:
|
||||
return ret
|
||||
|
@ -22,8 +29,31 @@ class BorgExtractJob(BorgJob):
|
|||
|
||||
cmd = ['borg', 'extract', '--list', '--info', '--log-json']
|
||||
cmd.append(f'{profile.repo.url}::{archive_name}')
|
||||
for s in selected_files:
|
||||
cmd.append(s)
|
||||
|
||||
# process selected items
|
||||
# all items will be excluded beside the one actively selected in the
|
||||
# dialog.
|
||||
# Unselected (and excluded) parent folders will be restored by borg
|
||||
# but without the metadata stored in the archive.
|
||||
pattern_file = tempfile.NamedTemporaryFile('w', delete=False)
|
||||
pattern_file.write("P fm\n")
|
||||
|
||||
indexes = [QModelIndex()]
|
||||
while indexes:
|
||||
index = indexes.pop()
|
||||
|
||||
for i in range(model.rowCount(index)):
|
||||
new_index = model.index(i, 0, index)
|
||||
indexes.append(new_index)
|
||||
|
||||
item: FileSystemItem[FileData] = new_index.internalPointer()
|
||||
if item.data.checkstate == Qt.CheckState.Checked:
|
||||
pattern_file.write("+ " + path_to_str(item.path) + "\n")
|
||||
|
||||
pattern_file.write("- *\n")
|
||||
pattern_file.flush()
|
||||
pattern_file.close() # wont delete temp file
|
||||
cmd.extend(['--patterns-from', pattern_file.name])
|
||||
|
||||
ret['ok'] = True
|
||||
ret['cmd'] = cmd
|
||||
|
@ -31,5 +61,5 @@ class BorgExtractJob(BorgJob):
|
|||
|
||||
return ret
|
||||
|
||||
def process_result(self, result):
|
||||
def process_result(self, result: dict):
|
||||
pass
|
||||
|
|
|
@ -21,7 +21,9 @@ class BorgListArchiveJob(BorgJob):
|
|||
ret['archive_name'] = archive_name
|
||||
ret['cmd'] = [
|
||||
'borg', 'list', '--info', '--log-json', '--json-lines',
|
||||
'--format', "{size:8d}{TAB}{mtime}{TAB}{path}{NL}",
|
||||
'--format',
|
||||
# fields to include in json output
|
||||
"{mode}{user}{group}{size}{mtime}{path}{source}{health}{NL}",
|
||||
f'{profile.repo.url}::{archive_name}']
|
||||
ret['ok'] = True
|
||||
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import os
|
||||
from PyQt5.QtWidgets import QMenu, QSystemTrayIcon
|
||||
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtWidgets import QApplication, QMenu, QSystemTrayIcon
|
||||
|
||||
from vorta.store.models import BackupProfileModel
|
||||
from vorta.utils import get_asset
|
||||
|
||||
|
||||
class TrayMenu(QSystemTrayIcon):
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: QApplication = None):
|
||||
QSystemTrayIcon.__init__(self, parent)
|
||||
self.app = parent
|
||||
self.set_tray_icon()
|
||||
|
@ -19,6 +20,7 @@ class TrayMenu(QSystemTrayIcon):
|
|||
self.setContextMenu(menu)
|
||||
|
||||
self.activated.connect(self.on_activation)
|
||||
self.app.paletteChanged.connect(lambda p: self.set_tray_icon())
|
||||
self.setVisible(True)
|
||||
self.show()
|
||||
|
||||
|
|
|
@ -26,8 +26,9 @@ from vorta.borg.umount import BorgUmountJob
|
|||
from vorta.store.models import ArchiveModel, BackupProfileMixin
|
||||
from vorta.utils import (choose_file_dialog, format_archive_name, get_asset,
|
||||
get_mount_points, pretty_bytes)
|
||||
from vorta.views.diff_result import DiffResult
|
||||
from vorta.views.extract_dialog import ExtractDialog
|
||||
from vorta.views import diff_result, extract_dialog
|
||||
from vorta.views.diff_result import DiffResultDialog, DiffTree
|
||||
from vorta.views.extract_dialog import ExtractDialog, ExtractTree
|
||||
from vorta.views.source_tab import SizeItem
|
||||
from vorta.views.utils import get_colored_icon
|
||||
|
||||
|
@ -118,6 +119,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|||
self.selected_archives = None
|
||||
self.set_icons()
|
||||
|
||||
# Connect to palette change
|
||||
self.app.paletteChanged.connect(lambda p: self.set_icons())
|
||||
|
||||
def set_icons(self):
|
||||
"Used when changing between light- and dark mode"
|
||||
self.bCheck.setIcon(get_colored_icon('check-circle'))
|
||||
|
@ -682,6 +686,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|||
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()
|
||||
|
@ -706,35 +713,54 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|||
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:
|
||||
def process_result():
|
||||
def receive():
|
||||
extraction_folder = dialog.selectedFiles()
|
||||
if extraction_folder:
|
||||
params = BorgExtractJob.prepare(
|
||||
self.profile(), archive.name, window.selected, 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)
|
||||
|
||||
archive = ArchiveModel.get(name=result['params']['archive_name'])
|
||||
window = ExtractDialog(result['data'], archive)
|
||||
self._toggle_all_buttons(True)
|
||||
window.setParent(self, QtCore.Qt.Sheet)
|
||||
self._window = window # for testing
|
||||
window.show()
|
||||
window.accepted.connect(process_result)
|
||||
model = ExtractTree()
|
||||
self._set_status(self.tr("Processing archive contents"))
|
||||
self._t = extract_dialog.ParseThread(result['data'],
|
||||
model)
|
||||
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.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, column):
|
||||
|
@ -862,12 +888,29 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
|||
name=result['params']['archive_name_newer'])
|
||||
archive_older = ArchiveModel.get(
|
||||
name=result['params']['archive_name_older'])
|
||||
window = DiffResult(result['data'], archive_newer, archive_older,
|
||||
result['params']['json_lines'])
|
||||
self._toggle_all_buttons(True)
|
||||
window.setParent(self, QtCore.Qt.Sheet)
|
||||
self._resultwindow = window # for testing
|
||||
window.show()
|
||||
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_action(self):
|
||||
profile = self.profile()
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,108 +1,688 @@
|
|||
import enum
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import PurePath
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QDialogButtonBox, QHeaderView, QPushButton
|
||||
from PyQt5.QtCore import (QDateTime, QLocale, QMimeData, QModelIndex, QPoint,
|
||||
Qt, QThread, QUrl)
|
||||
from PyQt5.QtGui import QColor, QKeySequence
|
||||
from PyQt5.QtWidgets import (QApplication, QDialogButtonBox, QHeaderView,
|
||||
QMenu, QPushButton, QShortcut)
|
||||
|
||||
from vorta.utils import get_asset, get_dict_from_list, nested_dict
|
||||
from vorta.views.partials.tree_view import TreeModel
|
||||
from vorta.utils import get_asset, pretty_bytes, uses_dark_mode
|
||||
from vorta.views.utils import get_colored_icon
|
||||
|
||||
from .partials.treemodel import (FileSystemItem, FileTreeModel,
|
||||
FileTreeSortProxyModel, path_to_str,
|
||||
relative_path)
|
||||
|
||||
uifile = get_asset("UI/extractdialog.ui")
|
||||
ExtractDialogUI, ExtractDialogBase = uic.loadUiType(uifile)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParseThread(QThread):
|
||||
"""A thread parsing diff results."""
|
||||
|
||||
def __init__(self, fs_data: str, model, parent=None):
|
||||
"""Init."""
|
||||
super().__init__(parent)
|
||||
self.model = model
|
||||
self.fs_data = fs_data
|
||||
|
||||
def run(self) -> None:
|
||||
"""Do the work"""
|
||||
# handle case of a single line of result, which will already be a dict
|
||||
if isinstance(self.fs_data, dict):
|
||||
lines = [self.fs_data]
|
||||
else:
|
||||
lines = [json.loads(line) for line in self.fs_data.split("\n") if line]
|
||||
|
||||
parse_json_lines(lines, self.model)
|
||||
|
||||
|
||||
class ExtractDialog(ExtractDialogBase, ExtractDialogUI):
|
||||
def __init__(self, fs_data, archive):
|
||||
"""
|
||||
Show the contents of an archive and allow choosing what to extract.
|
||||
"""
|
||||
|
||||
def __init__(self, archive, model):
|
||||
"""Init."""
|
||||
super().__init__()
|
||||
self.setupUi(self)
|
||||
|
||||
nested_file_list = nested_dict()
|
||||
self.selected = set()
|
||||
|
||||
def parse_json_line(data):
|
||||
size = data["size"]
|
||||
# python >= 3.7
|
||||
# modified = datetime.fromisoformat(data["mtime"]).ctime()
|
||||
# python < 3.7
|
||||
try:
|
||||
modified = datetime.strptime(data["mtime"], "%Y-%m-%dT%H:%M:%S.%f").ctime()
|
||||
except ValueError:
|
||||
modified = datetime.strptime(data["mtime"], "%Y-%m-%dT%H:%M:%S").ctime()
|
||||
dirpath, name = os.path.split(data["path"])
|
||||
# add to nested dict of folders to find nested dirs.
|
||||
d = get_dict_from_list(nested_file_list, dirpath.split("/"))
|
||||
if name not in d:
|
||||
d[name] = {}
|
||||
return size, modified, name, dirpath, data["type"]
|
||||
|
||||
# handle case of a single line of result, which will already be a dict
|
||||
lines = [fs_data] if isinstance(fs_data, dict) else \
|
||||
[json.loads(line) for line in fs_data.split('\n') if line]
|
||||
|
||||
files_with_attributes = [parse_json_line(line) for line in lines]
|
||||
|
||||
model = ExtractTree(files_with_attributes, nested_file_list, self.selected)
|
||||
self.model = model
|
||||
self.model.setParent(self)
|
||||
|
||||
view = self.treeView
|
||||
view.setAlternatingRowColors(True)
|
||||
view.setUniformRowHeights(True) # Allows for scrolling optimizations.
|
||||
view.setModel(model)
|
||||
|
||||
# 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)
|
||||
|
||||
# header
|
||||
header = view.header()
|
||||
header.setStretchLastSection(False)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(0, QHeaderView.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.buttonBox.addButton(
|
||||
self.extractButton, QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
|
||||
self.archiveNameLabel.setText(f"{archive.name}, {archive.time}")
|
||||
|
||||
# connect signals
|
||||
self.comboBoxDisplayMode.currentIndexChanged.connect(
|
||||
self.change_display_mode)
|
||||
self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop)
|
||||
self.bCollapseAll.clicked.connect(self.treeView.collapseAll)
|
||||
|
||||
self.buttonBox.rejected.connect(self.close)
|
||||
self.buttonBox.accepted.connect(self.accept)
|
||||
|
||||
self.set_icons()
|
||||
|
||||
# Connect to palette change
|
||||
QApplication.instance().paletteChanged.connect(
|
||||
lambda p: self.set_icons())
|
||||
|
||||
def retranslateUi(self, dialog):
|
||||
"""Retranslate strings in ui."""
|
||||
super().retranslateUi(dialog)
|
||||
|
||||
# setupUi calls retranslateUi
|
||||
if hasattr(self, 'extractButton'):
|
||||
if hasattr(self, "extractButton"):
|
||||
self.extractButton.setText(self.tr("Extract"))
|
||||
|
||||
def set_icons(self):
|
||||
"""Set or update the icons in the right color scheme."""
|
||||
self.bFoldersOnTop.setIcon(get_colored_icon('folder-on-top'))
|
||||
self.bCollapseAll.setIcon(get_colored_icon('angle-up-solid'))
|
||||
self.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree"))
|
||||
self.comboBoxDisplayMode.setItemIcon(1, get_colored_icon("view-list-tree"))
|
||||
|
||||
class ExtractTree(TreeModel):
|
||||
def __init__(
|
||||
self,
|
||||
files_with_attributes,
|
||||
nested_file_list,
|
||||
selected_files_folders,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(
|
||||
files_with_attributes, nested_file_list, selected_files_folders, parent
|
||||
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
|
||||
|
||||
The `selection` parameter specifies the index of the selected mode in
|
||||
`comboBoxDisplayMode`.
|
||||
|
||||
"""
|
||||
if selection == 0:
|
||||
mode = FileTreeModel.DisplayMode.TREE
|
||||
elif selection == 1:
|
||||
mode = FileTreeModel.DisplayMode.SIMPLIFIED_TREE
|
||||
else:
|
||||
raise Exception(
|
||||
"Unknown item in comboBoxDisplayMode with index {}".format(
|
||||
selection))
|
||||
|
||||
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`."""
|
||||
for item in lines:
|
||||
path = PurePath(item["path"])
|
||||
|
||||
size = item["size"]
|
||||
mode = item["mode"]
|
||||
file_type = FileType(mode[0])
|
||||
user = item["user"]
|
||||
group = item["group"]
|
||||
health = item["healthy"]
|
||||
source_path = item["source"] if "source" in item else None
|
||||
|
||||
# For python >= 3.7 this would work
|
||||
# modified = datetime.fromisoformat(item["mtime"]).ctime()
|
||||
# for python == 3.6 this must do the job
|
||||
# try:
|
||||
# modified = datetime.strptime(item["mtime"], "%Y-%m-%dT%H:%M:%S.%f")
|
||||
# except ValueError:
|
||||
# modified = datetime.strptime(item["mtime"], "%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
modified = QDateTime.fromString(item["mtime"], Qt.DateFormat.ISODateWithMs)
|
||||
|
||||
model.addItem(
|
||||
(
|
||||
path,
|
||||
FileData(
|
||||
file_type, size, mode, user, group, health, modified, source_path
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def data(self, index, role):
|
||||
|
||||
# ---- Sorting ---------------------------------------------------------------
|
||||
|
||||
|
||||
class ExtractSortProxyModel(FileTreeSortProxyModel):
|
||||
"""
|
||||
Sort a ExtractTree model.
|
||||
"""
|
||||
|
||||
def choose_data(self, index: QModelIndex):
|
||||
"""Choose the data of index used for comparison."""
|
||||
item: ExtractFileItem = index.internalPointer()
|
||||
column = index.column()
|
||||
|
||||
if column == 0:
|
||||
# file name
|
||||
return self.extract_path(index)
|
||||
elif column == 1:
|
||||
return item.data.last_modified
|
||||
elif column == 2:
|
||||
return item.data.size
|
||||
else:
|
||||
return item.data.health
|
||||
|
||||
|
||||
# ---- ExtractTree -----------------------------------------------------------
|
||||
|
||||
|
||||
class FileType(enum.Enum):
|
||||
"""File type of an item inside a borg archive."""
|
||||
|
||||
FILE = "-"
|
||||
DIRECTORY = "d"
|
||||
SYMBOLIC_LINK = "l"
|
||||
LINK = SYMBOLIC_LINK
|
||||
HARD_LINK = "h"
|
||||
FIFO = "p"
|
||||
SOCKET = "s"
|
||||
CHRDEV = "c"
|
||||
BLKDEV = "b"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileData:
|
||||
"""The data linked to a item inside a borg archive."""
|
||||
|
||||
file_type: FileType
|
||||
size: int
|
||||
mode: str
|
||||
user: str
|
||||
group: str
|
||||
health: bool
|
||||
last_modified: QDateTime
|
||||
source_path: Optional[str] = None # only relevant for links
|
||||
|
||||
checkstate: int = 0 # whether to extract the file (0, 1 or 2)
|
||||
checked_children: int = 0 # number of children checked
|
||||
|
||||
|
||||
ExtractFileItem = FileSystemItem[FileData]
|
||||
|
||||
|
||||
class ExtractTree(FileTreeModel[FileData]):
|
||||
"""The file tree model for diff results."""
|
||||
|
||||
def _make_filesystemitem(self, path, data):
|
||||
return super()._make_filesystemitem(path, data)
|
||||
|
||||
def _merge_data(self, item, data):
|
||||
if data:
|
||||
logger.debug("Overriding data for {}".format(path_to_str(item.path)))
|
||||
return super()._merge_data(item, data)
|
||||
|
||||
def _flat_filter(self, item):
|
||||
"""
|
||||
Return whether an item is part of the flat model representation.
|
||||
|
||||
The item's data might have not been set yet.
|
||||
"""
|
||||
return item.data and not item.children
|
||||
|
||||
def _simplify_filter(self, item: ExtractFileItem) -> bool:
|
||||
"""
|
||||
Return whether an item may be merged in simplified mode.
|
||||
|
||||
Allows simplification for every item.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _process_child(self, child):
|
||||
"""
|
||||
Process a new child.
|
||||
|
||||
This can make some changes to the child's data like
|
||||
setting a default value if the child's data is None.
|
||||
This can also update the data of the parent.
|
||||
This must emit `dataChanged` if data is changed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : FileSystemItem
|
||||
The child that was added.
|
||||
"""
|
||||
parent = child._parent
|
||||
|
||||
if not child.data:
|
||||
child.data = FileData(
|
||||
FileType.DIRECTORY, 0, "", "", "", True, datetime.now()
|
||||
)
|
||||
|
||||
if child.data.size != 0:
|
||||
# update size
|
||||
size = child.data.size
|
||||
|
||||
def add_size(parent):
|
||||
if parent is self.root:
|
||||
return
|
||||
|
||||
if parent.data is None:
|
||||
raise Exception(
|
||||
"Item {} without data".format(path_to_str(parent.path))
|
||||
)
|
||||
else:
|
||||
parent.data.size += size
|
||||
|
||||
# update parent
|
||||
parent = parent._parent
|
||||
if parent:
|
||||
add_size(parent)
|
||||
|
||||
add_size(parent)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
"""
|
||||
Returns the number of columns for the children of the given parent.
|
||||
|
||||
This corresponds to the number of data (column) entries shown
|
||||
for each item in the tree view.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QModelIndex, optional
|
||||
The index of the parent, by default QModelIndex()
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of rows.
|
||||
"""
|
||||
# name, last modified, size, health
|
||||
return 4
|
||||
|
||||
def headerData(
|
||||
self,
|
||||
section: int,
|
||||
orientation: Qt.Orientation,
|
||||
role: int = Qt.ItemDataRole.DisplayRole,
|
||||
):
|
||||
"""
|
||||
Get the data for the given role and section in the given header.
|
||||
|
||||
The header is identified by its orientation.
|
||||
For horizontal headers, the section number corresponds to
|
||||
the column number. Similarly, for vertical headers,
|
||||
the section number corresponds to the row number.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
section : int
|
||||
The row or column number.
|
||||
orientation : Qt.Orientation
|
||||
The orientation of the header.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------Improve
|
||||
Any
|
||||
The data for the specified header section.
|
||||
"""
|
||||
if (
|
||||
orientation == Qt.Orientation.Horizontal
|
||||
and role == Qt.ItemDataRole.DisplayRole
|
||||
):
|
||||
if section == 0:
|
||||
return self.tr("Name")
|
||||
elif section == 1:
|
||||
return self.tr("Last Modified")
|
||||
elif section == 2:
|
||||
return self.tr("Size")
|
||||
elif section == 3:
|
||||
return self.tr("Health")
|
||||
|
||||
return None
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
||||
"""
|
||||
Get the data for the given role and index.
|
||||
|
||||
The indexes internal pointer references the corresponding
|
||||
`FileSystemItem`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index of the item.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The data, return None if no data is available for the role.
|
||||
"""
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
item: ExtractFileItem = index.internalPointer()
|
||||
column = index.column()
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if column == 0:
|
||||
# name
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
return path_to_str(item.path)
|
||||
|
||||
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
|
||||
parent = index.parent()
|
||||
if parent == QModelIndex():
|
||||
return path_to_str(relative_path(self.root.path, item.path))
|
||||
|
||||
return path_to_str(
|
||||
relative_path(parent.internalPointer().path, item.path)
|
||||
)
|
||||
|
||||
# standard tree mode
|
||||
return item.subpath
|
||||
elif column == 1:
|
||||
# last modified
|
||||
return QLocale.system().toString(
|
||||
item.data.last_modified, QLocale.FormatType.ShortFormat
|
||||
)
|
||||
elif column == 2:
|
||||
# size
|
||||
return pretty_bytes(item.data.size)
|
||||
else:
|
||||
# health
|
||||
return
|
||||
|
||||
if role == Qt.ItemDataRole.BackgroundRole and column == 3:
|
||||
# health indicator
|
||||
if item.data.health:
|
||||
return QColor(Qt.green) if uses_dark_mode() else QColor(Qt.darkGreen)
|
||||
else:
|
||||
return QColor(Qt.green) if uses_dark_mode() else QColor(Qt.darkGreen)
|
||||
|
||||
if role == Qt.ItemDataRole.ToolTipRole:
|
||||
if column == 0:
|
||||
# name column -> display fullpath
|
||||
return path_to_str(item.path)
|
||||
|
||||
# info/data tooltip -> no real size limitation
|
||||
tooltip_template = (
|
||||
"{name}\n"
|
||||
+ "\n"
|
||||
+ "{filetype}\n"
|
||||
+ "{permissions}\n"
|
||||
+ "{user} {group}\n"
|
||||
+ "Modified: {last_modified}\n"
|
||||
+ "Health: {health}\n"
|
||||
)
|
||||
|
||||
# format
|
||||
if item.data.file_type == FileType.FILE:
|
||||
filetype = self.tr("File")
|
||||
elif item.data.file_type == FileType.DIRECTORY:
|
||||
filetype = self.tr("Directory")
|
||||
elif item.data.file_type == FileType.LINK:
|
||||
filetype = self.tr("Symbolic link")
|
||||
elif item.data.file_type == FileType.FIFO:
|
||||
filetype = self.tr("FIFO pipe")
|
||||
elif item.data.file_type == FileType.HARD_LINK:
|
||||
filetype = self.tr("Hard link")
|
||||
elif item.data.file_type == FileType.SOCKET:
|
||||
filetype = self.tr("Socket")
|
||||
elif item.data.file_type == FileType.BLKDEV:
|
||||
filetype = self.tr("Block special file")
|
||||
elif item.data.file_type == FileType.CHRDEV:
|
||||
filetype = self.tr("Character special file")
|
||||
else:
|
||||
raise Exception("Unknown filetype {}".format(item.data.file_type))
|
||||
|
||||
modified = QLocale.system().toString(item.data.last_modified)
|
||||
|
||||
if item.data.health:
|
||||
health = self.tr("healthy")
|
||||
else:
|
||||
health = self.tr("broken")
|
||||
|
||||
tooltip = tooltip_template.format(
|
||||
name=item.path[-1],
|
||||
filetype=filetype,
|
||||
permissions=item.data.mode,
|
||||
user=item.data.user,
|
||||
group=item.data.group,
|
||||
last_modified=modified,
|
||||
health=health,
|
||||
)
|
||||
|
||||
if item.data.source_path:
|
||||
tooltip += self.tr("Linked to: {}").format(item.data.source_path)
|
||||
|
||||
return tooltip
|
||||
|
||||
if role == Qt.ItemDataRole.CheckStateRole and column == 0:
|
||||
return item.data.checkstate
|
||||
|
||||
def setData(
|
||||
self, index: QModelIndex, value, role: int = Qt.ItemDataRole.DisplayRole
|
||||
) -> bool:
|
||||
"""
|
||||
Sets the role data for the item at index to value.
|
||||
|
||||
Returns true if successful; otherwise returns false.
|
||||
The dataChanged() signal should be emitted if the data was
|
||||
successfully set.
|
||||
"""
|
||||
if role != Qt.ItemDataRole.CheckStateRole:
|
||||
return False
|
||||
|
||||
item: ExtractFileItem = index.internalPointer()
|
||||
|
||||
if value == item.data.checkstate:
|
||||
return True
|
||||
|
||||
super_index = index.parent()
|
||||
if super_index == QModelIndex():
|
||||
super_item = self.root
|
||||
else:
|
||||
super_item: ExtractFileItem = super_index.internalPointer()
|
||||
|
||||
parent = item._parent
|
||||
while parent != super_item:
|
||||
if value == Qt.CheckState.Unchecked:
|
||||
# must have been one of the others previously
|
||||
parent.data.checked_children -= 1
|
||||
elif item.data.checkstate == Qt.CheckState.Unchecked: # old value
|
||||
# change from partially checked to checked
|
||||
# or the other way around does not change this count
|
||||
parent.data.checked_children += 1
|
||||
|
||||
if parent.data.checked_children:
|
||||
parent.data.checkstate = Qt.CheckState.PartiallyChecked
|
||||
else:
|
||||
parent.data.checkstate = Qt.CheckState.Unchecked
|
||||
|
||||
parent = parent._parent
|
||||
|
||||
if super_index != QModelIndex():
|
||||
if value == Qt.CheckState.Unchecked:
|
||||
# must have been one of the others previously
|
||||
super_item.data.checked_children -= 1
|
||||
elif item.data.checkstate == Qt.CheckState.Unchecked:
|
||||
# change from partially checked to checked
|
||||
# or the other way around does not change this count
|
||||
super_item.data.checked_children += 1
|
||||
|
||||
# update parent's state and possibly the parent's parent's state
|
||||
if super_item.data.checked_children:
|
||||
self.setData(super_index, Qt.CheckState.PartiallyChecked, role)
|
||||
else:
|
||||
self.setData(super_index, Qt.CheckState.Unchecked, role)
|
||||
|
||||
# update state of the children without changing their parents' states
|
||||
if value != Qt.CheckState.PartiallyChecked:
|
||||
self.set_checkstate_recursively(index, value)
|
||||
|
||||
# update this item's state
|
||||
item.data.checkstate = value
|
||||
self.dataChanged.emit(index, index, (role,))
|
||||
|
||||
return True
|
||||
|
||||
def set_checkstate_recursively(self, index: QModelIndex, value: Qt.CheckState):
|
||||
"""
|
||||
Set the checkstate of the children of an index recursively.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The parent index to start with.
|
||||
value : Qt.CheckState
|
||||
The state to set.
|
||||
"""
|
||||
|
||||
number_children = self.rowCount(index)
|
||||
if not number_children:
|
||||
return
|
||||
|
||||
index.internalPointer().data.checked_children = (
|
||||
0 if value == Qt.CheckState.Unchecked else number_children
|
||||
)
|
||||
|
||||
item = index.internalPointer()
|
||||
for i in range(number_children):
|
||||
child = index.child(i, 0)
|
||||
child_item: ExtractFileItem = child.internalPointer()
|
||||
child_item.data.checkstate = value
|
||||
|
||||
if role == Qt.DisplayRole:
|
||||
return item.data(index.column())
|
||||
elif role == Qt.CheckStateRole and index.column() == 0:
|
||||
return item.getCheckedState()
|
||||
else:
|
||||
return None
|
||||
# set state of hidden items
|
||||
parent = child_item._parent
|
||||
while parent != item:
|
||||
# hidden parent must have 1 child
|
||||
parent.data.checked_children = (
|
||||
0 if value == Qt.CheckState.Unchecked
|
||||
else self.rowCount(child)
|
||||
)
|
||||
parent.data.checkstate = value
|
||||
|
||||
def flags(self, index):
|
||||
if not index.isValid():
|
||||
return Qt.NoItemFlags
|
||||
parent = parent._parent
|
||||
|
||||
return Qt.ItemIsEnabled | Qt.ItemIsUserCheckable
|
||||
# set state of this child's children
|
||||
self.set_checkstate_recursively(child, value)
|
||||
|
||||
self.dataChanged.emit(
|
||||
index.child(0, 0),
|
||||
index.child(0, number_children - 1),
|
||||
(Qt.ItemDataRole.CheckStateRole,),
|
||||
)
|
||||
|
||||
def flags(self, index: QModelIndex):
|
||||
"""
|
||||
Returns the item flags for the given index.
|
||||
|
||||
The base class implementation returns a combination of flags
|
||||
that enables the item (ItemIsEnabled) and
|
||||
allows it to be selected (ItemIsSelectable).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Qt.ItemFlags
|
||||
The flags.
|
||||
"""
|
||||
flags = super().flags(index)
|
||||
if index.column() == 0:
|
||||
flags |= Qt.ItemFlag.ItemIsUserCheckable
|
||||
|
||||
return flags
|
||||
|
|
|
@ -0,0 +1,985 @@
|
|||
"""
|
||||
Implementation of a tree model for use with `QTreeView` based on (file) paths.
|
||||
|
||||
"""
|
||||
|
||||
import bisect
|
||||
import enum
|
||||
import os.path as osp
|
||||
from functools import reduce
|
||||
from pathlib import PurePath
|
||||
from typing import (Generic, List, Optional, Sequence, Tuple, TypeVar, Union,
|
||||
overload)
|
||||
|
||||
from PyQt5.QtCore import (QAbstractItemModel, QModelIndex, QObject,
|
||||
QSortFilterProxyModel, Qt, pyqtSignal)
|
||||
|
||||
#: A representation of a path
|
||||
Path = Tuple[str, ...]
|
||||
PathLike = Union[Path, Sequence[str]]
|
||||
|
||||
|
||||
def relative_path(p1: PathLike, p2: PathLike) -> Path:
|
||||
"""Get p2 relative to p1."""
|
||||
if len(p2) <= len(p1):
|
||||
return ()
|
||||
|
||||
return tuple(p2[len(p1):])
|
||||
|
||||
|
||||
def path_to_str(path: PathLike) -> str:
|
||||
"""Return a string representation of a path."""
|
||||
if not path:
|
||||
return ''
|
||||
|
||||
return osp.join(*path)
|
||||
|
||||
|
||||
#: Type of FileSystemItem's data
|
||||
T = TypeVar('T')
|
||||
FileSystemItemLike = Union[Tuple[Union[PurePath, Path], Optional[T]],
|
||||
'FileSystemItem']
|
||||
|
||||
#: Default return value
|
||||
A = TypeVar('A')
|
||||
|
||||
|
||||
class FileSystemItem(Generic[T]):
|
||||
"""
|
||||
An item in the virtual file system.
|
||||
|
||||
..warning::
|
||||
|
||||
Do not edit `children` manually. Always use `add` or `remove` or
|
||||
`sort`.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
path : Path
|
||||
The path of this item.
|
||||
data : Any
|
||||
The data belonging to this item.
|
||||
children : List[FileSystemItem]
|
||||
The children of this item.
|
||||
_subpath : str
|
||||
The subpath of this item relative to its parent.
|
||||
_parent : FileSystemItem or None
|
||||
The parent of the item.
|
||||
"""
|
||||
__slots__ = ['path', 'children', 'data', '_parent', 'subpath']
|
||||
|
||||
def __init__(self, path: PathLike, data: T):
|
||||
"""Init."""
|
||||
self.path: Path = tuple(path)
|
||||
self.data = data
|
||||
self.subpath: str = None
|
||||
self.children: List[FileSystemItem[T]] = []
|
||||
self._parent: Optional[FileSystemItem[T]] = None
|
||||
|
||||
# @property
|
||||
# def subpath(self) -> str:
|
||||
# """
|
||||
# Get the name of the item which is the subpath relative to its parent.
|
||||
# """
|
||||
# return self.path[-1]
|
||||
|
||||
# @property
|
||||
# def children(self):
|
||||
# """Get an iterable view of the item's children."""
|
||||
# return self.child_map.values()
|
||||
|
||||
def add(self,
|
||||
child: 'FileSystemItem[T]',
|
||||
_subpath: str = None,
|
||||
_check: bool = True):
|
||||
"""
|
||||
Add a child.
|
||||
|
||||
The parameters starting with an underscore exist for performance
|
||||
reasons only. They should only be used if the operations that these
|
||||
parameters toggle were performed already.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : FileSystemItem
|
||||
The child to add.
|
||||
_subpath : str, optional
|
||||
Precalculated subpath, default is None.
|
||||
_check : bool, optional
|
||||
Whether to check for children with the same subpath (using `get`).
|
||||
"""
|
||||
if _subpath is not None:
|
||||
child.subpath = _subpath
|
||||
else:
|
||||
child.subpath = path_to_str(relative_path(self.path, child.path))
|
||||
|
||||
i = bisect.bisect(self.children, child)
|
||||
|
||||
# check for a child with the same subpath
|
||||
if _check and len(self.children) > i - 1 >= 0 \
|
||||
and self.children[i - 1].subpath == child.subpath:
|
||||
raise RuntimeError(
|
||||
"The subpath must be unique to a parent's children.")
|
||||
|
||||
# add
|
||||
child._parent = self
|
||||
self.children.insert(i, child)
|
||||
|
||||
def addChildren(self, children: List['FileSystemItem[T]']):
|
||||
"""
|
||||
Add a list of children.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
children : List[FileSystemItem]
|
||||
The children to add.
|
||||
"""
|
||||
for child in children:
|
||||
self.add(child)
|
||||
|
||||
@overload
|
||||
def remove(self, subpath: str) -> None:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def remove(self, index: int) -> None:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def remove(self, child: 'FileSystemItem[T]') -> None:
|
||||
pass
|
||||
|
||||
def remove(self, child_subpath_index):
|
||||
"""
|
||||
Remove the given children.
|
||||
|
||||
The index or child to remove must be in the list
|
||||
else an error will be raised.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child_or_index : FileSystemItem or int
|
||||
The instance to remove or its index in `children`.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
The given item is not a child of this one.
|
||||
IndexError
|
||||
The given index is not a valid one.
|
||||
"""
|
||||
if isinstance(child_subpath_index, FileSystemItem):
|
||||
child = child_subpath_index
|
||||
i = bisect.bisect_left(self.children, child)
|
||||
if i < len(self.children) and self.children[i] == child:
|
||||
del self.children[i]
|
||||
|
||||
elif isinstance(child_subpath_index, str):
|
||||
subpath = child_subpath_index
|
||||
i = bisect.bisect_left(self.children, subpath)
|
||||
if i < len(self.children) and self.children[i].subpath == subpath:
|
||||
del self.children[i]
|
||||
|
||||
elif isinstance(child_subpath_index, int):
|
||||
i = child_subpath_index
|
||||
del self.children[i]
|
||||
|
||||
else:
|
||||
raise TypeError(
|
||||
"First argument passed to `{}.remove` has invalid type {}".
|
||||
format(
|
||||
type(self).__name__,
|
||||
type(child_subpath_index).__name__))
|
||||
|
||||
def __getitem__(self, index: int):
|
||||
"""
|
||||
Get a an item.
|
||||
|
||||
This allows accessing the attributes in the same manner for instances
|
||||
of this type and instances of `FileSystemItemLike`.
|
||||
"""
|
||||
if index == 0:
|
||||
return self.path
|
||||
elif index == 1:
|
||||
return self.data
|
||||
else:
|
||||
raise IndexError("Index {} out of range(0, 2)".format(index))
|
||||
|
||||
def get(
|
||||
self,
|
||||
subpath: str,
|
||||
default: Optional[A] = None
|
||||
) -> Union[Tuple[int, 'FileSystemItem[T]'], Optional[A]]:
|
||||
"""
|
||||
Find direct child with given subpath.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
subpath : str
|
||||
The items subpath relative to this.
|
||||
default : Any, optional
|
||||
The item to return if the child wasn't found, default None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tuple[int, FileSystemItem] or None
|
||||
The index and item if found else `default`.
|
||||
"""
|
||||
i = bisect.bisect_left(self.children, subpath)
|
||||
if i < len(self.children):
|
||||
child = self.children[i]
|
||||
if child.subpath == subpath:
|
||||
return i, child
|
||||
return default
|
||||
|
||||
def get_path(self, path: PathLike) -> Optional['FileSystemItem[T]']:
|
||||
"""
|
||||
Get the item with the given subpath relative to this item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : Path
|
||||
The subpath.
|
||||
"""
|
||||
|
||||
def walk(fsi, pp):
|
||||
if fsi is None:
|
||||
return None
|
||||
res = fsi.get(pp)
|
||||
if not res:
|
||||
return None
|
||||
i, item = res
|
||||
return item
|
||||
|
||||
fsi = reduce(walk, path, self)
|
||||
return fsi
|
||||
|
||||
def __repr__(self):
|
||||
"""Get a string representation."""
|
||||
return "FileSystemItem<'{}', '{}', {}, {}>".format(
|
||||
self.path,
|
||||
self.subpath,
|
||||
self.data,
|
||||
[c.subpath for c in self.children],
|
||||
)
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Lower than for bisect sorting."""
|
||||
if isinstance(other, FileSystemItem):
|
||||
return self.subpath < other.subpath
|
||||
if isinstance(other, (list, tuple)):
|
||||
for s, o in zip(self.path, other):
|
||||
if s != o:
|
||||
return s < o
|
||||
else:
|
||||
return len(self.path) < len(other)
|
||||
else:
|
||||
return self.subpath < other
|
||||
|
||||
def __gt__(self, other):
|
||||
"""Greater than for bisect sorting."""
|
||||
if isinstance(other, FileSystemItem):
|
||||
return self.subpath > other.subpath
|
||||
if isinstance(other, (list, tuple)):
|
||||
for s, o in zip(self.path, other):
|
||||
if s != o:
|
||||
return s > o
|
||||
else:
|
||||
return len(self.path) > len(other)
|
||||
else:
|
||||
return self.subpath > other
|
||||
|
||||
|
||||
class FileTreeModel(QAbstractItemModel, Generic[T]):
|
||||
"""
|
||||
FileTreeModel managing a virtual file system with variable file data.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
mode: DisplayMode
|
||||
The tree display mode of the model.
|
||||
|
||||
Methods
|
||||
-------
|
||||
_make_filesystemitem(path, data, children)
|
||||
Construct a `FileSystemItem`.
|
||||
_merge_data(item, data)
|
||||
Add the given data to the item.
|
||||
_flat_filter
|
||||
Return whether an item is part of the flat model representation.
|
||||
flags
|
||||
columnCount
|
||||
headerData
|
||||
|
||||
"""
|
||||
|
||||
class DisplayMode(enum.Enum):
|
||||
"""
|
||||
The tree display modes available for the model.
|
||||
|
||||
"""
|
||||
#: normal file tree
|
||||
TREE = enum.auto()
|
||||
|
||||
#: combine items in the tree having a single child with that child
|
||||
SIMPLIFIED_TREE = enum.auto()
|
||||
|
||||
#: simple list of items
|
||||
FLAT = enum.auto()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Init."""
|
||||
super().__init__(parent)
|
||||
self.root: FileSystemItem[T] = FileSystemItem([], None)
|
||||
|
||||
#: mode
|
||||
self.mode: 'FileTreeModel.DisplayMode' = self.DisplayMode.TREE
|
||||
|
||||
#: flat representation of the tree
|
||||
self._flattened: List[FileSystemItem] = []
|
||||
|
||||
def addItems(self, items: List[FileSystemItemLike[T]]):
|
||||
"""
|
||||
Add file system items to the model.
|
||||
|
||||
This method can be used for populating the model.
|
||||
Calls `addItem` for each item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
items : List[FileSystemItemLike]
|
||||
The items.
|
||||
"""
|
||||
for item in items:
|
||||
self.addItem(item)
|
||||
|
||||
def addItem(self, item: FileSystemItemLike[T]):
|
||||
"""
|
||||
Add a file system item to the model.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item : FileSystemItemLike
|
||||
The item.
|
||||
"""
|
||||
self.beginResetModel()
|
||||
|
||||
path = item[0]
|
||||
data = item[1]
|
||||
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
def child(tup, subpath):
|
||||
fsi, i = tup
|
||||
i += 1
|
||||
return self._addChild(fsi, path[:i], subpath, None), i
|
||||
|
||||
fsi, dummy = reduce(child, path[:-1], (self.root, 0))
|
||||
|
||||
self._addChild(fsi, path, path[-1], data)
|
||||
|
||||
self.endResetModel()
|
||||
|
||||
def _addChild(self, item: FileSystemItem[T], path: PathLike,
|
||||
path_part: str, data: Optional[T]) -> FileSystemItem[T]:
|
||||
"""
|
||||
Add a child to an item.
|
||||
|
||||
This is called by `addItem` in a reduce statement. It should
|
||||
add a new child with the given attributes to the given item.
|
||||
This implementation provides a reasonable default, most subclasses
|
||||
wont need to override this method. The implementation should make use
|
||||
of `_make_filesystemitem`, `_merge_data`, `_add_children`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item : FileSystemItem
|
||||
The item to add a new child to.
|
||||
path : PathLike
|
||||
The path of the new child.
|
||||
path_part : str
|
||||
The subpath of the new child relative to `item`.
|
||||
data : Any or None
|
||||
The data of the new child.
|
||||
children : Any or None
|
||||
The initial children of the item.
|
||||
|
||||
Returns
|
||||
-------
|
||||
FileSystemItem
|
||||
[description]
|
||||
"""
|
||||
res = item.get(path_part)
|
||||
if res:
|
||||
i, child = res
|
||||
if data is not None:
|
||||
self._merge_data(child, data)
|
||||
else:
|
||||
child = self._make_filesystemitem(path, data)
|
||||
|
||||
if self._flat_filter(child):
|
||||
i = bisect.bisect(self._flattened, child.path)
|
||||
self._flattened.insert(i, child)
|
||||
|
||||
item.add(child, _subpath=path_part, _check=False)
|
||||
|
||||
# update parent data
|
||||
self._process_child(child)
|
||||
|
||||
return child
|
||||
|
||||
def _make_filesystemitem(self, path: PathLike,
|
||||
data: Optional[T]) -> FileSystemItem[T]:
|
||||
"""
|
||||
Construct a `FileSystemItem`.
|
||||
|
||||
The attributes are the attributes of a `FileSystemItemLike`.
|
||||
This implementation already provides reasonable default that
|
||||
subclasses can be used.
|
||||
|
||||
..warning:: Do always call `_addChild` to add a child to an item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PathLike
|
||||
The path of the item.
|
||||
data : Any or None
|
||||
The data.
|
||||
children : Any or None
|
||||
The initial children.
|
||||
|
||||
Returns
|
||||
-------
|
||||
FileSystemItem
|
||||
The FileSystemItem for the internal tree structure.
|
||||
"""
|
||||
return FileSystemItem(path, data)
|
||||
|
||||
def _process_child(self, child: FileSystemItem[T]):
|
||||
"""
|
||||
Process a new child.
|
||||
|
||||
This can make some changes to the child's data like
|
||||
setting a default value if the child's data is None.
|
||||
This can also update the data of the parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : FileSystemItem
|
||||
The child that was added.
|
||||
"""
|
||||
pass # Does nothing
|
||||
|
||||
def _merge_data(self, item: FileSystemItem[T], data: Optional[T]):
|
||||
"""
|
||||
Add the given data to the item.
|
||||
|
||||
This method is called by `_addChild` which in turn is called by
|
||||
`addItem`. It gets an item in the virtual file system that was
|
||||
added again with the given data. The data may be None.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item : FileSystemItem
|
||||
The item to merge the data in.
|
||||
data : Any or None
|
||||
The data to add.
|
||||
"""
|
||||
if not item.data:
|
||||
item.data = data
|
||||
|
||||
def removeItem(self, path: Union[PurePath, PathLike]) -> None:
|
||||
"""
|
||||
Remove an item from the model.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PathLike or PurePath
|
||||
The path of the item to remove.
|
||||
"""
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
||||
self.beginResetModel()
|
||||
|
||||
parent = self.getItem(path[:-1])
|
||||
|
||||
if not parent:
|
||||
return
|
||||
|
||||
res = parent.get(path[-1])
|
||||
|
||||
if not res:
|
||||
return
|
||||
|
||||
i, item = res
|
||||
|
||||
# remove item and its children in flat representation
|
||||
items_to_remove: List[FileSystemItem] = [item]
|
||||
while items_to_remove:
|
||||
to_remove = items_to_remove.pop()
|
||||
|
||||
fi = bisect.bisect_left(self._flattened, to_remove.path)
|
||||
if fi < len(self._flattened) and self._flattened[fi] is to_remove:
|
||||
del self._flattened[fi]
|
||||
|
||||
items_to_remove.extend(to_remove.children)
|
||||
|
||||
# remove item from tree representation
|
||||
parent.remove(i)
|
||||
|
||||
self.endResetModel()
|
||||
|
||||
def setMode(self, value: 'DisplayMode'):
|
||||
"""
|
||||
Set the display mode of the tree model.
|
||||
|
||||
In TREE mode (default) the tree will be displayed as is.
|
||||
In SIMPLIFIED_TREE items will simplify the tree by combining
|
||||
items with their single child if they posess only one.
|
||||
In FLAT mode items will be displayed as a simple list. The items
|
||||
shown can be filtered by `_flat_filter`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : bool
|
||||
The new value for the attribute.
|
||||
|
||||
See also
|
||||
--------
|
||||
getMode: Get the current mode.
|
||||
_flat_filter
|
||||
"""
|
||||
if value == self.mode:
|
||||
return # nothing to do
|
||||
|
||||
self.beginResetModel()
|
||||
self.mode = value
|
||||
self.endResetModel()
|
||||
|
||||
def getMode(self) -> bool:
|
||||
"""
|
||||
Get the display mode set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
DisplayMode
|
||||
The current value.
|
||||
|
||||
See also
|
||||
--------
|
||||
setMode : Set the mode.
|
||||
"""
|
||||
return self.mode
|
||||
|
||||
def _flat_filter(self, item: FileSystemItem[T]) -> bool:
|
||||
"""
|
||||
Return whether an item is part of the flat model representation.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _simplify_filter(self, item: FileSystemItem[T]) -> bool:
|
||||
"""
|
||||
Return whether an item may be merged in simplified mode.
|
||||
"""
|
||||
return True
|
||||
|
||||
def getItem(
|
||||
self, path: Union[PurePath,
|
||||
PathLike]) -> Optional[FileSystemItem[T]]:
|
||||
"""
|
||||
Get the item with the given path.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PathLike or PurePath
|
||||
The path of the item.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Optional[FileSystemItem]
|
||||
[description]
|
||||
"""
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
return self.root.get_path(path)
|
||||
|
||||
def data(self,
|
||||
index: QModelIndex,
|
||||
role: int = Qt.ItemDataRole.DisplayRole):
|
||||
"""
|
||||
Get the data for the given role and index.
|
||||
|
||||
The indexes internal pointer references the corresponding
|
||||
`FileSystemItem`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index of the item.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The data, return None if no data is available for the role.
|
||||
"""
|
||||
return super().data(index, role)
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
"""
|
||||
Returns the number of rows under the given parent.
|
||||
|
||||
When the parent is valid it means that rowCount is returning
|
||||
the number of children of parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QModelIndex, optional
|
||||
The index of the parent item, by default QModelIndex()
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of children.
|
||||
"""
|
||||
if parent.column() > 0:
|
||||
return 0 # Only the first column has children
|
||||
|
||||
# flat mode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
if not parent.isValid():
|
||||
return len(self._flattened)
|
||||
return 0
|
||||
|
||||
# tree mode
|
||||
if not parent.isValid():
|
||||
parent_item: FileSystemItem = self.root
|
||||
else:
|
||||
parent_item = parent.internalPointer()
|
||||
|
||||
return len(parent_item.children)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
"""
|
||||
Returns the number of columns for the children of the given parent.
|
||||
|
||||
This corresponds to the number of data (column) entries shown
|
||||
for each item in the tree view.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QModelIndex, optional
|
||||
The index of the parent, by default QModelIndex()
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of rows.
|
||||
"""
|
||||
raise NotImplementedError("Method `columnCount` of FileTreeModel" +
|
||||
" must be implemented by subclasses.")
|
||||
|
||||
def indexPath(self, path: Union[PurePath, PathLike]) -> QModelIndex:
|
||||
"""
|
||||
Construct a `QModelIndex` for the given path.
|
||||
|
||||
If `combine` is enabled, the closest indexed parent path is returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PurePath or PathLike
|
||||
The path to the item the index should point to.
|
||||
|
||||
Returns
|
||||
-------
|
||||
QModelIndex
|
||||
The requested index.
|
||||
"""
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
# flat mode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
i = bisect.bisect_left(self._flattened, path)
|
||||
if i < len(self._flattened) and self._flattened[i].path == path:
|
||||
return self.index(i, 0)
|
||||
return QModelIndex()
|
||||
|
||||
# tree mode
|
||||
simplified = self.mode == self.DisplayMode.SIMPLIFIED_TREE
|
||||
|
||||
def step(tup, subpath):
|
||||
index, i, item = tup
|
||||
|
||||
if not item:
|
||||
return index, None
|
||||
|
||||
r, child = item.get(subpath)
|
||||
|
||||
if not child:
|
||||
return QModelIndex(), None
|
||||
|
||||
if i <= -1:
|
||||
i = r
|
||||
|
||||
if (simplified and len(child.children) == 1
|
||||
and self._simplify_filter(child)):
|
||||
return index, i, child
|
||||
|
||||
index = self.index(i if simplified else r, 0, index)
|
||||
|
||||
return index, -1, child
|
||||
|
||||
index, i, item = reduce(step, path, (QModelIndex(), -1, self.root))
|
||||
|
||||
return index
|
||||
|
||||
def index(self, row: int, column: int,
|
||||
parent: QModelIndex = QModelIndex()) -> QModelIndex:
|
||||
"""
|
||||
Construct a `QModelIndex`.
|
||||
|
||||
Returns the index of the item in the model specified by
|
||||
the given row, column and parent index.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
row : int
|
||||
column : int
|
||||
parent : QModelIndex, optional
|
||||
|
||||
Returns
|
||||
-------
|
||||
QModelIndex
|
||||
The requested index.
|
||||
"""
|
||||
# different behavior in flat and treemode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
if (0 <= row < len(self._flattened)
|
||||
and 0 <= column < self.columnCount(parent)):
|
||||
return self.createIndex(row, column, self._flattened[row])
|
||||
|
||||
return QModelIndex()
|
||||
|
||||
# valid index?
|
||||
if not parent.isValid():
|
||||
parent_item: FileSystemItem[T] = self.root
|
||||
else:
|
||||
parent_item = parent.internalPointer()
|
||||
|
||||
item = list(parent_item.children)[row]
|
||||
|
||||
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
|
||||
# combine items with a single child with that child
|
||||
while len(item.children) == 1 and self._simplify_filter(item):
|
||||
item = item.children[0]
|
||||
|
||||
if (0 <= row < len(parent_item.children)
|
||||
and 0 <= column < self.columnCount(parent)):
|
||||
return self.createIndex(row, column, item)
|
||||
|
||||
return QModelIndex()
|
||||
|
||||
@overload
|
||||
def parent(self, child: QModelIndex) -> QModelIndex:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def parent(self) -> QObject:
|
||||
pass
|
||||
|
||||
def parent(self, child=None):
|
||||
"""
|
||||
Returns the parent of the model item with the given index.
|
||||
|
||||
If the item has no parent, an invalid QModelIndex is returned.
|
||||
A common convention used in models that expose tree data structures
|
||||
is that only items in the first column have children.
|
||||
For that case, when reimplementing this function in a subclass
|
||||
the column of the returned QModelIndex would be 0.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : QModelIndex
|
||||
The index of the child item.
|
||||
|
||||
Returns
|
||||
-------
|
||||
QModelIndex
|
||||
The index of the parent item.
|
||||
"""
|
||||
# overloaded variant to retrieve parent of model
|
||||
if child is None:
|
||||
return super().parent()
|
||||
|
||||
# variant to retrieve parent for data item
|
||||
if not child.isValid():
|
||||
return QModelIndex()
|
||||
|
||||
# different behavior in tree and flat mode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
return QModelIndex() # in flat mode their are no parents
|
||||
|
||||
child_item: FileSystemItem[T] = child.internalPointer()
|
||||
parent_item = child_item._parent
|
||||
|
||||
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
|
||||
# combine items with a single child with the child
|
||||
while (parent_item is not self.root # do not call filter with root
|
||||
and len(parent_item.children) == 1
|
||||
and self._simplify_filter(parent_item)):
|
||||
parent_item = parent_item._parent
|
||||
|
||||
if parent_item is self.root:
|
||||
# Never return root item since it shouldn't be displayed
|
||||
return QModelIndex()
|
||||
|
||||
row, item = parent_item._parent.get(parent_item.subpath)
|
||||
return self.createIndex(row, 0, parent_item)
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
|
||||
"""
|
||||
Returns the item flags for the given index.
|
||||
|
||||
The base class implementation returns a combination of flags
|
||||
that enables the item (ItemIsEnabled) and
|
||||
allows it to be selected (ItemIsSelectable).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Qt.ItemFlags
|
||||
The flags.
|
||||
"""
|
||||
return super().flags(index)
|
||||
|
||||
def headerData(self,
|
||||
section: int,
|
||||
orientation: Qt.Orientation,
|
||||
role: int = Qt.ItemDataRole.DisplayRole):
|
||||
"""
|
||||
Get the data for the given role and section in the given header.
|
||||
|
||||
The header is identified by its orientation.
|
||||
For horizontal headers, the section number corresponds to
|
||||
the column number. Similarly, for vertical headers,
|
||||
the section number corresponds to the row number.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
section : int
|
||||
The row or column number.
|
||||
orientation : Qt.Orientation
|
||||
The orientation of the header.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The data for the specified header section.
|
||||
"""
|
||||
return super().headerData(section, orientation, role)
|
||||
|
||||
|
||||
class FileTreeSortProxyModel(QSortFilterProxyModel):
|
||||
"""
|
||||
Sort a FileTreeModel.
|
||||
"""
|
||||
sorted = pyqtSignal(int, Qt.SortOrder)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
"""Init."""
|
||||
super().__init__(parent)
|
||||
self.folders_on_top = False
|
||||
|
||||
def keepFoldersOnTop(self, value: bool = None) -> bool:
|
||||
"""
|
||||
Set or get whether folders are kept on top when sorting.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : bool, optional
|
||||
The new value, by default None
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
The value of the attribute.
|
||||
"""
|
||||
if value is not None and value != self.folders_on_top:
|
||||
self.folders_on_top = value
|
||||
# resort
|
||||
self.setDynamicSortFilter(False)
|
||||
self.sort(self.sortColumn(), self.sortOrder())
|
||||
self.setDynamicSortFilter(True)
|
||||
|
||||
return self.folders_on_top
|
||||
|
||||
def extract_path(self, index: QModelIndex):
|
||||
"""Get the path to compare for a given index."""
|
||||
item: FileSystemItem = index.internalPointer()
|
||||
model: FileTreeModel = self.sourceModel()
|
||||
|
||||
# name
|
||||
if model.mode == FileTreeModel.DisplayMode.FLAT:
|
||||
return path_to_str(item.path)
|
||||
|
||||
if model.mode == FileTreeModel.DisplayMode.SIMPLIFIED_TREE:
|
||||
parent = index.parent()
|
||||
if parent == QModelIndex():
|
||||
path = relative_path(model.root.path, item.path)
|
||||
else:
|
||||
path = relative_path(parent.internalPointer().path,
|
||||
item.path)
|
||||
|
||||
return path[0] if path else ''
|
||||
|
||||
# standard tree mode
|
||||
return item.subpath
|
||||
|
||||
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.")
|
||||
|
||||
def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool:
|
||||
"""
|
||||
Return whether the item of `left` is lower than the one of `right`.
|
||||
Parameters
|
||||
----------
|
||||
left : QModelIndex
|
||||
The index left of the `<`.
|
||||
right : QModelIndex
|
||||
The index right of the `<`.
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
Whether left is lower than right.
|
||||
"""
|
||||
if self.folders_on_top:
|
||||
item1 = left.internalPointer()
|
||||
item2 = right.internalPointer()
|
||||
ch1 = bool(len(item1.children))
|
||||
ch2 = bool(len(item2.children))
|
||||
|
||||
if ch1 ^ ch2:
|
||||
if self.sortOrder() == Qt.SortOrder.AscendingOrder:
|
||||
return ch1
|
||||
return ch2
|
||||
|
||||
data1 = self.choose_data(left)
|
||||
data2 = self.choose_data(right)
|
||||
return data1 < data2
|
|
@ -68,6 +68,9 @@ class RepoTab(RepoBase, RepoUI, BackupProfileMixin):
|
|||
self.populate_from_profile()
|
||||
self.set_icons()
|
||||
|
||||
# Connect to palette change
|
||||
QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
|
||||
|
||||
def set_icons(self):
|
||||
self.bAddSSHKey.setIcon(get_colored_icon("plus"))
|
||||
self.bAddRepo.setIcon(get_colored_icon("plus"))
|
||||
|
|
|
@ -96,6 +96,9 @@ class ScheduleTab(ScheduleBase, ScheduleUI, BackupProfileMixin):
|
|||
# Connect to schedule update
|
||||
self.app.scheduler.schedule_changed.connect(lambda pid: self.draw_next_scheduled_backup())
|
||||
|
||||
# Connect to palette change
|
||||
self.app.paletteChanged.connect(lambda p: self.set_icons())
|
||||
|
||||
def on_scheduler_change(self, _):
|
||||
profile = self.profile()
|
||||
# Save scheduler settings, apply new scheduler and display next task for profile.
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -152,8 +152,8 @@ def test_archive_extract(qapp, qtbot, mocker, borg_json_output):
|
|||
qtbot.waitUntil(lambda: hasattr(tab, '_window'), **pytest._wait_defaults)
|
||||
# qtbot.waitUntil(lambda: tab._window == qapp.activeWindow(), **pytest._wait_defaults)
|
||||
|
||||
assert tab._window.treeView.model().rootItem.childItems[0].data(0) == 'Users'
|
||||
tab._window.treeView.model().rootItem.childItems[0].load_children()
|
||||
model = tab._window.model
|
||||
assert model.root.children[0].subpath == 'home'
|
||||
assert tab._window.archiveNameLabel.text().startswith('test-archive, 2000')
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
from pathlib import PurePath
|
||||
|
||||
import pytest
|
||||
from PyQt5.QtCore import QItemSelectionModel
|
||||
|
||||
import vorta.borg
|
||||
import vorta.utils
|
||||
import vorta.views.archive_tab
|
||||
from vorta.views.diff_result import (ChangeType, DiffData, DiffTree, FileType,
|
||||
parse_diff_json, parse_diff_lines)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('json_mock_file,folder_root', [
|
||||
|
@ -20,6 +24,14 @@ def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, fol
|
|||
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
|
||||
mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)
|
||||
|
||||
compat = vorta.utils.borg_compat
|
||||
|
||||
def check(feature_name):
|
||||
if feature_name == 'DIFF_JSON_LINES':
|
||||
return False
|
||||
return vorta.utils.BorgCompatibility.check(compat, feature_name)
|
||||
mocker.patch.object(vorta.utils.borg_compat, 'check', check)
|
||||
|
||||
selection_model: QItemSelectionModel = tab.archiveTable.selectionModel()
|
||||
model = tab.archiveTable.model()
|
||||
|
||||
|
@ -33,78 +45,181 @@ def test_archive_diff(qapp, qtbot, mocker, borg_json_output, json_mock_file, fol
|
|||
|
||||
qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults)
|
||||
|
||||
assert tab._resultwindow.treeView.model().rootItem.childItems[0].data(0) == folder_root
|
||||
tab._resultwindow.treeView.model().rootItem.childItems[0].load_children()
|
||||
model = tab._resultwindow.treeView.model().sourceModel()
|
||||
assert model.root.children[0].subpath == folder_root
|
||||
|
||||
assert tab._resultwindow.archiveNameLabel_1.text() == 'test-archive'
|
||||
tab._resultwindow.accept()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('line, expected', [
|
||||
('changed link some/changed/link',
|
||||
(0, 'changed', 'link', 'some/changed', '-')),
|
||||
(' +77.8 kB -77.8 kB some/changed/file',
|
||||
(77800, 'modified', 'file', 'some/changed', '-')),
|
||||
(' +77.8 kB -77.8 kB [-rw-rw-rw- -> -rw-r--r--] some/changed/file',
|
||||
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
|
||||
('[-rw-rw-rw- -> -rw-r--r--] some/changed/file',
|
||||
(0, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
|
||||
@pytest.mark.parametrize(
|
||||
'line, expected',
|
||||
[
|
||||
('changed link some/changed/link',
|
||||
('some/changed/link', FileType.LINK, ChangeType.CHANGED_LINK, 0, 0,
|
||||
None, None, None)),
|
||||
(' +77.8 kB -77.8 kB some/changed/file',
|
||||
('some/changed/file', FileType.FILE, ChangeType.MODIFIED, 2 * 77800,
|
||||
0, None, None,
|
||||
(77800, 77800))),
|
||||
(' +77.8 kB -77.8 kB [-rw-rw-rw- -> -rw-r--r--] some/changed/file',
|
||||
('some/changed/file', FileType.FILE, ChangeType.MODIFIED, 2 * 77800, 0,
|
||||
('-rw-rw-rw-', '-rw-r--r--'), None, (77800, 77800))),
|
||||
('[-rw-rw-rw- -> -rw-r--r--] some/changed/file',
|
||||
('some/changed/file', FileType.FILE, ChangeType.MODE, 0, 0,
|
||||
('-rw-rw-rw-', '-rw-r--r--'), None, None)),
|
||||
('added directory some/changed/dir',
|
||||
('some/changed/dir', FileType.DIRECTORY, ChangeType.ADDED, 0, 0, None,
|
||||
None, None)),
|
||||
('removed directory some/changed/dir',
|
||||
('some/changed/dir', FileType.DIRECTORY, ChangeType.REMOVED_DIR, 0, 0,
|
||||
None, None, None)),
|
||||
|
||||
('added directory some/changed/dir',
|
||||
(0, 'added', 'dir', 'some/changed', 'd')),
|
||||
('removed directory some/changed/dir',
|
||||
(0, 'removed', 'dir', 'some/changed', 'd')),
|
||||
# Example from https://github.com/borgbase/vorta/issues/521
|
||||
('[user:user -> nfsnobody:nfsnobody] home/user/arrays/test.txt',
|
||||
('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
|
||||
None, ('user', 'user', 'nfsnobody', 'nfsnobody'), None)),
|
||||
|
||||
# Example from https://github.com/borgbase/vorta/issues/521
|
||||
('[user:user -> nfsnobody:nfsnobody] home/user/arrays/test.txt',
|
||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
||||
# Very short owner change, to check stripping whitespace from file path
|
||||
('[a:a -> b:b] home/user/arrays/test.txt',
|
||||
('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
|
||||
None, ('a', 'a', 'b', 'b'), None)),
|
||||
|
||||
# Very short owner change, to check stripping whitespace from file path
|
||||
('[a:a -> b:b] home/user/arrays/test.txt',
|
||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
||||
|
||||
# All file-related changes in one test
|
||||
(' +77.8 kB -77.8 kB [user:user -> nfsnobody:nfsnobody] [-rw-rw-rw- -> -rw-r--r--] home/user/arrays/test.txt',
|
||||
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'test.txt', 'home/user/arrays', '-')),
|
||||
])
|
||||
# All file-related changes in one test
|
||||
(' +77.8 kB -800 B [user:user -> nfsnobody:nfsnobody] [-rw-rw-rw- -> -rw-r--r--] home/user/arrays/test.txt',
|
||||
('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER,
|
||||
77800 + 800, 77000, ('-rw-rw-rw-', '-rw-r--r--'),
|
||||
('user', 'user', 'nfsnobody', 'nfsnobody'), (77800, 800))),
|
||||
])
|
||||
def test_archive_diff_parser(line, expected):
|
||||
files_with_attributes, nested_file_list = vorta.views.diff_result.parse_diff_lines([line])
|
||||
assert files_with_attributes == [expected]
|
||||
model = DiffTree()
|
||||
model.setMode(model.DisplayMode.FLAT)
|
||||
parse_diff_lines([line], model)
|
||||
|
||||
assert model.rowCount() == 1
|
||||
item = model.index(0, 0).internalPointer()
|
||||
|
||||
assert item.path == PurePath(expected[0]).parts
|
||||
assert item.data == DiffData(*expected[1:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('line, expected', [
|
||||
({'path': 'some/changed/link', 'changes': [{'type': 'changed link'}]},
|
||||
(0, 'changed', 'link', 'some/changed', '-')),
|
||||
({'path': 'some/changed/file', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800}]},
|
||||
(77800, 'modified', 'file', 'some/changed', '-')),
|
||||
({'path': 'some/changed/file', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800},
|
||||
{'type': 'mode', 'old_mode': '-rw-rw-rw-', 'new_mode': '-rw-r--r--'}]},
|
||||
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
|
||||
({'path': 'some/changed/file', 'changes': [{'type': 'mode', 'old_mode': '-rw-rw-rw-', 'new_mode': '-rw-r--r--'}]},
|
||||
(0, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
|
||||
({'path': 'some/changed/dir', 'changes': [{'type': 'added directory'}]},
|
||||
(0, 'added', 'dir', 'some/changed', 'd')),
|
||||
({'path': 'some/changed/dir', 'changes': [{'type': 'removed directory'}]},
|
||||
(0, 'removed', 'dir', 'some/changed', 'd')),
|
||||
@pytest.mark.parametrize(
|
||||
'line, expected',
|
||||
[
|
||||
({
|
||||
'path': 'some/changed/link',
|
||||
'changes': [{
|
||||
'type': 'changed link'
|
||||
}]
|
||||
}, ('some/changed/link', FileType.LINK, ChangeType.CHANGED_LINK, 0, 0,
|
||||
None, None, None)),
|
||||
({
|
||||
'path': 'some/changed/file',
|
||||
'changes': [{
|
||||
'type': 'modified',
|
||||
'added': 77800,
|
||||
'removed': 77800
|
||||
}]
|
||||
}, ('some/changed/file', FileType.FILE, ChangeType.MODIFIED, 2 * 77800,
|
||||
0, None, None, (77800, 77800))),
|
||||
({
|
||||
'path':
|
||||
'some/changed/file',
|
||||
'changes': [{
|
||||
'type': 'modified',
|
||||
'added': 77800,
|
||||
'removed': 800
|
||||
}, {
|
||||
'type': 'mode',
|
||||
'old_mode': '-rw-rw-rw-',
|
||||
'new_mode': '-rw-r--r--'
|
||||
}]
|
||||
}, ('some/changed/file', FileType.FILE, ChangeType.MODIFIED,
|
||||
77800 + 800, 77000, ('-rw-rw-rw-', '-rw-r--r--'), None,
|
||||
(77800, 800))),
|
||||
({
|
||||
'path':
|
||||
'some/changed/file',
|
||||
'changes': [{
|
||||
'type': 'mode',
|
||||
'old_mode': '-rw-rw-rw-',
|
||||
'new_mode': '-rw-r--r--'
|
||||
}]
|
||||
}, ('some/changed/file', FileType.FILE, ChangeType.MODE, 0, 0,
|
||||
('-rw-rw-rw-', '-rw-r--r--'), None, None)),
|
||||
({
|
||||
'path': 'some/changed/dir',
|
||||
'changes': [{
|
||||
'type': 'added directory'
|
||||
}]
|
||||
}, ('some/changed/dir', FileType.DIRECTORY, ChangeType.ADDED, 0, 0,
|
||||
None, None, None)),
|
||||
({
|
||||
'path': 'some/changed/dir',
|
||||
'changes': [{
|
||||
'type': 'removed directory'
|
||||
}]
|
||||
}, ('some/changed/dir', FileType.DIRECTORY, ChangeType.REMOVED_DIR, 0,
|
||||
0, None, None, None)),
|
||||
|
||||
# Example from https://github.com/borgbase/vorta/issues/521
|
||||
({'path': 'home/user/arrays/test.txt', 'changes': [{'type': 'owner', 'old_user': 'user', 'new_user': 'nfsnobody',
|
||||
'old_group': 'user', 'new_group': 'nfsnobody'}]},
|
||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
||||
# Example from https://github.com/borgbase/vorta/issues/521
|
||||
({
|
||||
'path':
|
||||
'home/user/arrays/test.txt',
|
||||
'changes': [{
|
||||
'type': 'owner',
|
||||
'old_user': 'user',
|
||||
'new_user': 'nfsnobody',
|
||||
'old_group': 'user',
|
||||
'new_group': 'nfsnobody'
|
||||
}]
|
||||
}, ('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
|
||||
None, ('user', 'user', 'nfsnobody', 'nfsnobody'), None)),
|
||||
|
||||
# Very short owner change, to check stripping whitespace from file path
|
||||
({'path': 'home/user/arrays/test.txt', 'changes': [{'type': 'owner', 'old_user': 'a', 'new_user': 'b',
|
||||
'old_group': 'a', 'new_group': 'b'}]},
|
||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
||||
# Very short owner change, to check stripping whitespace from file path
|
||||
({
|
||||
'path':
|
||||
'home/user/arrays/test.txt',
|
||||
'changes': [{
|
||||
'type': 'owner',
|
||||
'old_user': 'a',
|
||||
'new_user': 'b',
|
||||
'old_group': 'a',
|
||||
'new_group': 'b'
|
||||
}]
|
||||
}, ('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
|
||||
None, ('a', 'a', 'b', 'b'), None)),
|
||||
|
||||
# All file-related changes in one test
|
||||
({'path': 'home/user/arrays/test.txt', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800},
|
||||
{'type': 'mode', 'old_mode': '-rw-rw-rw-',
|
||||
'new_mode': '-rw-r--r--'},
|
||||
{'type': 'owner', 'old_user': 'user', 'new_user': 'nfsnobody',
|
||||
'old_group': 'user', 'new_group': 'nfsnobody'}]},
|
||||
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'test.txt', 'home/user/arrays', '-')),
|
||||
])
|
||||
# All file-related changes in one test
|
||||
({
|
||||
'path':
|
||||
'home/user/arrays/test.txt',
|
||||
'changes': [{
|
||||
'type': 'modified',
|
||||
'added': 77800,
|
||||
'removed': 77800
|
||||
}, {
|
||||
'type': 'mode',
|
||||
'old_mode': '-rw-rw-rw-',
|
||||
'new_mode': '-rw-r--r--'
|
||||
}, {
|
||||
'type': 'owner',
|
||||
'old_user': 'user',
|
||||
'new_user': 'nfsnobody',
|
||||
'old_group': 'user',
|
||||
'new_group': 'nfsnobody'
|
||||
}]
|
||||
}, ('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER,
|
||||
2 * 77800, 0, ('-rw-rw-rw-', '-rw-r--r--'),
|
||||
('user', 'user', 'nfsnobody', 'nfsnobody'), (77800, 77800))),
|
||||
])
|
||||
def test_archive_diff_json_parser(line, expected):
|
||||
files_with_attributes, _nested_file_list = vorta.views.diff_result.parse_diff_json_lines([line])
|
||||
assert files_with_attributes == [expected]
|
||||
model = DiffTree()
|
||||
model.setMode(model.DisplayMode.FLAT)
|
||||
parse_diff_json([line], model)
|
||||
|
||||
assert model.rowCount() == 1
|
||||
item = model.index(0, 0).internalPointer()
|
||||
|
||||
assert item.path == PurePath(expected[0]).parts
|
||||
assert item.data == DiffData(*expected[1:])
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
from PyQt5.QtCore import QModelIndex, Qt
|
||||
|
||||
import vorta.borg
|
||||
from vorta.views.extract_dialog import (ExtractTree, FileData, FileType,
|
||||
parse_json_lines)
|
||||
from vorta.views.partials.treemodel import FileSystemItem
|
||||
|
||||
|
||||
def prepare_borg(mocker, borg_json_output):
|
||||
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)
|
||||
|
||||
|
||||
default = {
|
||||
"mode": "drwxrwxr-x",
|
||||
"user": "theuser",
|
||||
"group": "theuser",
|
||||
"uid": 1000,
|
||||
"gid": 1000,
|
||||
"path": "",
|
||||
"healthy": True,
|
||||
"source": "",
|
||||
"linktarget": "",
|
||||
"flags": None,
|
||||
"mtime": "2022-05-13T14:33:57.305797",
|
||||
"size": 0,
|
||||
}
|
||||
|
||||
|
||||
def updated(path, values):
|
||||
d = default.copy()
|
||||
d.update(values)
|
||||
d["path"] = path
|
||||
return d
|
||||
|
||||
|
||||
def test_parser():
|
||||
"""Test creating a tree with correct data from json lines."""
|
||||
|
||||
lines = [
|
||||
updated("a", {}),
|
||||
updated("a/b", {"mode": "-rwxrwxr-x"}),
|
||||
updated("a/b/c", {}),
|
||||
updated("a/b/d", {}),
|
||||
updated("a/a", {}),
|
||||
updated("a/a/a", {}),
|
||||
updated("a/a/b", {}),
|
||||
updated("a/a/c", {"healthy": False}),
|
||||
updated("a/a/d", {}),
|
||||
updated("a/a/e", {}),
|
||||
]
|
||||
|
||||
model = ExtractTree()
|
||||
parse_json_lines(lines, model)
|
||||
|
||||
index = model.indexPath(("a", "b"))
|
||||
assert index != QModelIndex()
|
||||
assert index.internalPointer().data.file_type == FileType.FILE
|
||||
|
||||
item = model.getItem(("a", "a", "c"))
|
||||
assert item
|
||||
assert item.data.health is False
|
||||
|
||||
|
||||
def select(model, index):
|
||||
model.setData(index, Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||
|
||||
|
||||
def deselect(model, index):
|
||||
model.setData(index, Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
|
||||
|
||||
|
||||
def test_selection():
|
||||
"""Test selecting items for extraction."""
|
||||
lines = [
|
||||
updated("a", {}),
|
||||
updated("a/b", {}),
|
||||
updated("a/b/c", {}),
|
||||
updated("a/b/d", {}),
|
||||
updated("a/a", {}),
|
||||
updated("a/a/a", {}),
|
||||
updated("a/a/b", {}),
|
||||
updated("a/a/c", {}),
|
||||
updated("a/a/d", {}),
|
||||
updated("a/a/e", {}),
|
||||
updated("c", {}),
|
||||
]
|
||||
model = ExtractTree()
|
||||
parse_json_lines(lines, model)
|
||||
|
||||
# Test select
|
||||
ic = model.index(1, 0, QModelIndex())
|
||||
c: FileSystemItem[FileData] = ic.internalPointer()
|
||||
|
||||
select(model, ic)
|
||||
assert c.data.checkstate == 2
|
||||
assert c.data.checked_children == 0
|
||||
|
||||
# Test deselect
|
||||
deselect(model, ic)
|
||||
assert c.data.checkstate == 0
|
||||
assert c.data.checked_children == 0
|
||||
|
||||
# Test select parent as well as children
|
||||
ia = model.index(0, 0, QModelIndex())
|
||||
a: FileSystemItem[FileData] = ia.internalPointer()
|
||||
aa = model.getItem(("a", "a"))
|
||||
aab = model.getItem(("a", "a", "b"))
|
||||
ab = model.getItem(("a", "b"))
|
||||
abc = model.getItem(("a", "b", "c"))
|
||||
|
||||
select(model, ia)
|
||||
assert a.data.checkstate
|
||||
assert a.data.checked_children == 2
|
||||
assert a.data.checked_children == 2
|
||||
assert aab.data.checkstate
|
||||
assert ab.data.checkstate
|
||||
assert aa.data.checkstate
|
||||
assert aa.data.checked_children == 5
|
||||
|
||||
# Test deselect item as well as children with selected parent
|
||||
iab = model.indexPath(("a", "b"))
|
||||
deselect(model, iab)
|
||||
|
||||
assert a.data.checkstate == 1
|
||||
assert aa.data.checkstate == 2
|
||||
assert ab.data.checkstate == 0
|
||||
assert abc.data.checkstate == 0
|
||||
assert a.data.checked_children == 1
|
||||
assert ab.data.checked_children == 0
|
||||
|
||||
# Test deselect item and children
|
||||
deselect(model, ia)
|
||||
|
||||
assert a.data.checkstate == 0
|
||||
assert aa.data.checkstate == 0
|
||||
assert ab.data.checkstate == 0
|
||||
assert a.data.checked_children == 0
|
||||
assert aa.data.checked_children == 0
|
||||
|
||||
# Test select child with partially selected parent
|
||||
iaac = model.indexPath(("a", "a", "c"))
|
||||
|
||||
select(model, ia)
|
||||
deselect(model, iab)
|
||||
deselect(model, iaac)
|
||||
select(model, iab)
|
||||
select(model, iaac)
|
||||
|
||||
assert a.data.checkstate == 1
|
||||
assert aa.data.checkstate == 1
|
||||
assert ab.data.checkstate == 2
|
||||
|
||||
assert a.data.checked_children == 2
|
||||
assert ab.data.checked_children == 2
|
||||
assert aa.data.checked_children == 5
|
||||
|
||||
# Test deselect all children with selected parent
|
||||
iaa = model.indexPath(("a", "a"))
|
||||
deselect(model, iaa)
|
||||
deselect(model, iab)
|
||||
|
||||
assert a.data.checkstate == 0
|
||||
assert a.data.checked_children == 0
|
||||
|
||||
# Test select child with deselected parent
|
||||
select(model, iaac)
|
||||
|
||||
assert a.data.checkstate == 1
|
||||
assert ab.data.checkstate == 0
|
||||
assert aa.data.checkstate == 1
|
||||
assert a.data.checked_children == 1
|
||||
assert ab.data.checked_children == 0
|
||||
assert aa.data.checked_children == 1
|
||||
|
||||
select(model, iaa)
|
||||
assert a.data.checkstate == 1
|
||||
|
||||
select(model, iab)
|
||||
assert a.data.checkstate == 1
|
|
@ -0,0 +1,326 @@
|
|||
from pathlib import PurePath
|
||||
|
||||
import pytest
|
||||
from PyQt5.QtCore import QModelIndex
|
||||
|
||||
from vorta.views.partials.treemodel import FileSystemItem, FileTreeModel
|
||||
|
||||
|
||||
class TreeModelImp(FileTreeModel):
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
return 1
|
||||
|
||||
def _make_filesystemitem(self, path, data):
|
||||
return super()._make_filesystemitem(path, data)
|
||||
|
||||
def _merge_data(self, item, data):
|
||||
return super()._merge_data(item, data)
|
||||
|
||||
|
||||
class TestFileSystemItem:
|
||||
def test_tuple(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
|
||||
assert item[0] == item.path
|
||||
assert item[1] == item.data
|
||||
|
||||
def test_add(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
|
||||
assert len(item.children) == 0
|
||||
|
||||
child = FileSystemItem(PurePath('test/hello').parts, 4)
|
||||
|
||||
item.add(child)
|
||||
|
||||
assert len(item.children) == 1
|
||||
assert item.children[0] == child
|
||||
assert child.subpath == 'hello'
|
||||
assert child._parent == item
|
||||
|
||||
child = FileSystemItem(PurePath('test/hello').parts, 8)
|
||||
with pytest.raises(RuntimeError):
|
||||
# may not add a child with the same subpath
|
||||
item.add(child)
|
||||
|
||||
def test_remove(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
child1 = FileSystemItem(PurePath('test/a').parts, 4)
|
||||
child2 = FileSystemItem(PurePath('test/b').parts, 3)
|
||||
child3 = FileSystemItem(PurePath('test/c').parts, 2)
|
||||
|
||||
item.add(child1)
|
||||
item.add(child2)
|
||||
item.add(child3)
|
||||
|
||||
assert len(item.children) == 3
|
||||
|
||||
# test remove subpath
|
||||
item.remove('b')
|
||||
assert len(item.children) == 2
|
||||
assert child2 not in item.children
|
||||
|
||||
# test remove item
|
||||
item.remove(child3)
|
||||
assert len(item.children) == 1
|
||||
assert child3 not in item.children
|
||||
|
||||
def test_get(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
child1 = FileSystemItem(PurePath('test/a').parts, 4)
|
||||
child2 = FileSystemItem(PurePath('test/b').parts, 3)
|
||||
child3 = FileSystemItem(PurePath('test/c').parts, 2)
|
||||
|
||||
item.add(child1)
|
||||
item.add(child2)
|
||||
item.add(child3)
|
||||
|
||||
# test get inexistent subpath
|
||||
assert item.get('unknown') is None
|
||||
assert item.get('unknown', default='default') == 'default'
|
||||
|
||||
# get subpath
|
||||
res = item.get('a')
|
||||
assert res is not None
|
||||
assert res[1] == child1
|
||||
|
||||
res = item.get('b')
|
||||
assert res is not None
|
||||
assert res[1] == child2
|
||||
|
||||
# get subpath of empty list
|
||||
assert child1.get('a') is None
|
||||
|
||||
def test_get_subpath(self):
|
||||
item = FileSystemItem(('test',), 0)
|
||||
child1 = FileSystemItem(PurePath('test/a').parts, 4)
|
||||
child2 = FileSystemItem(PurePath('test/b').parts, 3)
|
||||
child3 = FileSystemItem(PurePath('test/c').parts, 2)
|
||||
|
||||
item.add(child1)
|
||||
item.add(child2)
|
||||
item.add(child3)
|
||||
|
||||
child11 = FileSystemItem(PurePath('test/a/aa').parts, 4)
|
||||
child1.add(child11)
|
||||
|
||||
assert item.get_path(PurePath('a/aa').parts) is child11
|
||||
assert item.get_path(('b',)) is child2
|
||||
|
||||
|
||||
class TestFileTreeModel:
|
||||
def test_basic_setup(self):
|
||||
model = TreeModelImp()
|
||||
|
||||
assert model.rowCount() == 0
|
||||
|
||||
# test FileTreeModel.addItem
|
||||
model.addItem((('test',), 0))
|
||||
assert model.rowCount() == 1
|
||||
|
||||
item = model.getItem(('test',))
|
||||
assert item is not None
|
||||
assert item.subpath == 'test'
|
||||
assert len(item.children) == 0
|
||||
assert item.data == 0 # test id
|
||||
|
||||
model.addItem((PurePath('/hello'), 1))
|
||||
model.addItem(FileSystemItem(PurePath('/hello/test').parts, 2))
|
||||
|
||||
assert model.rowCount() == 2
|
||||
|
||||
item = model.getItem(('/',))
|
||||
assert item is not None
|
||||
assert item.subpath == '/'
|
||||
assert len(item.children) == 1
|
||||
|
||||
item = model.getItem(PurePath('/hello/test').parts)
|
||||
assert item is not None and item.data == 2
|
||||
|
||||
# test adding Item to existing parent
|
||||
model.addItem((PurePath('test/subtest'), 3))
|
||||
assert model.rowCount() == 2
|
||||
item = model.getItem(PurePath('test/subtest').parts)
|
||||
assert item is not None and item.data == 3
|
||||
|
||||
# test parent
|
||||
assert (model.parent(model.indexPath(
|
||||
PurePath('test/subtest'))) == model.indexPath(PurePath('test')))
|
||||
|
||||
# test index
|
||||
item1 = model.getItem(('test',))
|
||||
item2 = model.getItem(PurePath('test/subtest'))
|
||||
|
||||
index1 = model.index(1, 0)
|
||||
assert index1.internalPointer() == item1
|
||||
assert index1 == model.indexPath(PurePath('test'))
|
||||
index2 = model.index(0, 0, index1)
|
||||
assert index2.internalPointer() == item2
|
||||
assert index2 == model.indexPath(PurePath('test/subtest'))
|
||||
|
||||
# test rowCount
|
||||
assert model.rowCount() == 2
|
||||
assert model.rowCount(index1) == 1
|
||||
|
||||
# test remove
|
||||
model.removeItem(('test',))
|
||||
assert model.rowCount() == 1
|
||||
assert item1 not in model.root.children
|
||||
|
||||
model.removeItem(PurePath('/hello/test').parts)
|
||||
assert model.rowCount() == 1
|
||||
assert model.getItem(PurePath('/hello/test')) is None
|
||||
item3 = model.getItem(PurePath('/hello'))
|
||||
assert len(item3.children) == 0
|
||||
|
||||
def test_root(self):
|
||||
model = TreeModelImp()
|
||||
assert model.getItem(PurePath()) == model.root
|
||||
|
||||
def test_flat(self):
|
||||
items = [
|
||||
(PurePath('a'), 0),
|
||||
(PurePath('a/a'), 1),
|
||||
(PurePath('a/c'), 3),
|
||||
(PurePath('a/b/a'), 4),
|
||||
(PurePath('a/b/b'), 5),
|
||||
(PurePath('a/b'), 2),
|
||||
(PurePath('b'), 6),
|
||||
(PurePath('b/a'), 7),
|
||||
(PurePath('b/b'), 8),
|
||||
]
|
||||
|
||||
model = TreeModelImp()
|
||||
model.addItems(items)
|
||||
|
||||
# test flat representation
|
||||
model.setMode(model.DisplayMode.FLAT)
|
||||
|
||||
assert model.rowCount() == len(items)
|
||||
assert model.parent(model.index(4, 0)) == QModelIndex()
|
||||
assert model.rowCount(model.index(3, 0)) == 0
|
||||
|
||||
item = model.getItem(PurePath('a/b/a'))
|
||||
assert item is not None and item.data == 4
|
||||
|
||||
# test flat add
|
||||
model.addItem((PurePath('a/a/a'), 10))
|
||||
|
||||
assert model.rowCount() == len(items) + 1
|
||||
item = model.getItem(PurePath('a/a/a'))
|
||||
assert item is not None and item.data == 10
|
||||
|
||||
# test flat remove
|
||||
model.removeItem(PurePath('a/a/a'))
|
||||
|
||||
assert model.rowCount() == len(items)
|
||||
assert item not in model._flattened
|
||||
|
||||
# test flat indexPath
|
||||
index = model.indexPath(PurePath('a/b'))
|
||||
assert index.internalPointer().data == 2
|
||||
assert model._flattened[index.row()].data == 2
|
||||
assert index.parent() == QModelIndex()
|
||||
|
||||
# test
|
||||
model.setMode(model.DisplayMode.TREE)
|
||||
|
||||
assert model.rowCount() == 2
|
||||
|
||||
def test_simplified_tree(self):
|
||||
items = [
|
||||
(PurePath('a'), 0),
|
||||
(PurePath('a/a'), 1),
|
||||
(PurePath('a/c'), 3),
|
||||
(PurePath('a/b/a'), 4),
|
||||
(PurePath('a/b/b'), 5),
|
||||
(PurePath('a/b'), 2),
|
||||
(PurePath('b'), 6),
|
||||
(PurePath('b/a'), 7),
|
||||
(PurePath('b/b'), 8),
|
||||
(PurePath('c'), 9),
|
||||
(PurePath('c/a'), 10),
|
||||
(PurePath('c/a/b'), 11),
|
||||
(PurePath('c/a/b/c'), 12),
|
||||
(PurePath('c/a/b/a'), 13),
|
||||
(PurePath('c/a/b/a/b/c'), 14),
|
||||
(PurePath('c/a/b/b/c/a'), 15),
|
||||
]
|
||||
|
||||
model = TreeModelImp()
|
||||
model.addItems(items)
|
||||
|
||||
# test tree representation
|
||||
model.setMode(model.DisplayMode.SIMPLIFIED_TREE)
|
||||
|
||||
assert model.rowCount() == 3
|
||||
|
||||
a = model.index(0, 0)
|
||||
assert model.rowCount(a) == 3
|
||||
ab = model.index(1, 0, a)
|
||||
assert model.rowCount(ab) == 2
|
||||
assert model.parent(ab) == a
|
||||
|
||||
# test combined representation
|
||||
|
||||
cab = model.index(2, 0)
|
||||
assert model.rowCount(cab) == 3
|
||||
assert cab.internalPointer().data == 11
|
||||
assert model.rowCount(cab) == 3
|
||||
assert model.parent(cab) == QModelIndex()
|
||||
cabc = model.index(2, 0, cab)
|
||||
cababc = model.index(0, 0, cab)
|
||||
cabbca = model.index(1, 0, cab)
|
||||
assert cababc.internalPointer().data == 14
|
||||
assert model.parent(cababc).internalId() == cab.internalId()
|
||||
assert cabbca.internalPointer().data == 15
|
||||
assert model.parent(cabbca).internalId() == cab.internalId()
|
||||
assert cabc.internalPointer().data == 12
|
||||
|
||||
# test combined add
|
||||
model.addItem((PurePath('a/a/a'), 30))
|
||||
|
||||
aaa = model.index(0, 0, a)
|
||||
assert aaa.internalPointer().data == 30
|
||||
assert model.rowCount(aaa) == 0
|
||||
|
||||
model.addItem((PurePath('c/a/a'), 31))
|
||||
|
||||
ca = model.index(2, 0)
|
||||
assert ca.internalPointer().data == 10
|
||||
assert ca.parent() == QModelIndex()
|
||||
assert model.rowCount(ca) == 2
|
||||
caa = model.index(0, 0, ca)
|
||||
assert caa.internalPointer().data == 31
|
||||
assert caa.parent().internalId() == ca.internalId()
|
||||
|
||||
# test combined remove
|
||||
model.removeItem(PurePath('a/a/a').parts)
|
||||
|
||||
aa = model.index(0, 0, a)
|
||||
assert aa.internalPointer().data == 1
|
||||
assert model.rowCount(aa) == 0
|
||||
|
||||
model.removeItem(PurePath('c/a/a'))
|
||||
|
||||
cab = model.index(2, 0)
|
||||
assert cab.internalPointer().data == 11
|
||||
assert model.rowCount(cab) == 3
|
||||
|
||||
# test combined indexPath
|
||||
index = model.indexPath(PurePath('a/b'))
|
||||
assert index.internalPointer().data == 2
|
||||
assert index.parent() == a
|
||||
|
||||
index = model.indexPath(PurePath('c/a/b'))
|
||||
assert model.parent(index) == QModelIndex()
|
||||
assert index.internalPointer().data == 11
|
||||
assert model.rowCount(index) == 3
|
||||
|
||||
index = model.indexPath(PurePath('c/a'))
|
||||
assert index == QModelIndex()
|
||||
|
||||
# test mode change
|
||||
model.setMode(model.DisplayMode.TREE)
|
||||
|
||||
assert model.rowCount() == 3
|
Loading…
Reference in New Issue