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
|
import sys
|
||||||
|
|
||||||
from peewee import SqliteDatabase
|
from peewee import SqliteDatabase
|
||||||
|
|
||||||
from vorta._version import __version__
|
from vorta._version import __version__
|
||||||
from vorta.i18n import trans_late, translate
|
|
||||||
from vorta.config import SETTINGS_DIR
|
from vorta.config import SETTINGS_DIR
|
||||||
|
from vorta.i18n import trans_late, translate
|
||||||
from vorta.log import init_logger, logger
|
from vorta.log import init_logger, logger
|
||||||
from vorta.store.connection import init_db
|
from vorta.store.connection import init_db
|
||||||
from vorta.updater import get_updater
|
from vorta.updater import get_updater
|
||||||
|
@ -15,6 +16,7 @@ from vorta.utils import parse_args
|
||||||
def main():
|
def main():
|
||||||
def exception_handler(type, value, tb):
|
def exception_handler(type, value, tb):
|
||||||
from traceback import format_exception
|
from traceback import format_exception
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QMessageBox
|
from PyQt5.QtWidgets import QMessageBox
|
||||||
logger.critical("Uncaught exception, file a report at https://github.com/borgbase/vorta/issues/new",
|
logger.critical("Uncaught exception, file a report at https://github.com/borgbase/vorta/issues/new",
|
||||||
exc_info=(type, value, tb))
|
exc_info=(type, value, tb))
|
||||||
|
|
|
@ -88,8 +88,6 @@ class VortaApp(QtSingleApplication):
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
self.check_darwin_permissions()
|
self.check_darwin_permissions()
|
||||||
|
|
||||||
self.installEventFilter(self)
|
|
||||||
|
|
||||||
def create_backups_cmdline(self, profile_name):
|
def create_backups_cmdline(self, profile_name):
|
||||||
profile = BackupProfileModel.get_or_none(name=profile_name)
|
profile = BackupProfileModel.get_or_none(name=profile_name)
|
||||||
if profile is not None:
|
if profile is not None:
|
||||||
|
@ -99,17 +97,6 @@ class VortaApp(QtSingleApplication):
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Invalid profile name {profile_name}")
|
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):
|
def quit_app_action(self):
|
||||||
self.backup_cancelled_event.emit()
|
self.backup_cancelled_event.emit()
|
||||||
del self.main_window
|
del self.main_window
|
||||||
|
|
|
@ -16,24 +16,14 @@
|
||||||
<layout class="QVBoxLayout" name="verticalLayout">
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
<item>
|
<item>
|
||||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>4</number>
|
||||||
|
</property>
|
||||||
<property name="topMargin">
|
<property name="topMargin">
|
||||||
<number>10</number>
|
<number>0</number>
|
||||||
</property>
|
</property>
|
||||||
<item>
|
|
||||||
<widget class="QLabel" name="label_2">
|
|
||||||
<property name="text">
|
|
||||||
<string>Difference between</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="archiveNameLabel_1">
|
<widget class="QLabel" name="archiveNameLabel_1">
|
||||||
<property name="font">
|
|
||||||
<font>
|
|
||||||
<weight>75</weight>
|
|
||||||
<bold>true</bold>
|
|
||||||
</font>
|
|
||||||
</property>
|
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>nyx2.local-2018-11-16T09:49:58 </string>
|
<string>nyx2.local-2018-11-16T09:49:58 </string>
|
||||||
</property>
|
</property>
|
||||||
|
@ -42,18 +32,12 @@
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="label_3">
|
<widget class="QLabel" name="label_3">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>and</string>
|
<string>↔</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="archiveNameLabel_2">
|
<widget class="QLabel" name="archiveNameLabel_2">
|
||||||
<property name="font">
|
|
||||||
<font>
|
|
||||||
<weight>75</weight>
|
|
||||||
<bold>true</bold>
|
|
||||||
</font>
|
|
||||||
</property>
|
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>nyx2.local-2018-10-16T09:49:58 </string>
|
<string>nyx2.local-2018-10-16T09:49:58 </string>
|
||||||
</property>
|
</property>
|
||||||
|
@ -72,6 +56,63 @@
|
||||||
</property>
|
</property>
|
||||||
</spacer>
|
</spacer>
|
||||||
</item>
|
</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>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
|
@ -83,22 +124,9 @@
|
||||||
<number>10</number>
|
<number>10</number>
|
||||||
</property>
|
</property>
|
||||||
<item>
|
<item>
|
||||||
<spacer name="horizontalSpacer">
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
<property name="orientation">
|
<property name="standardButtons">
|
||||||
<enum>Qt::Horizontal</enum>
|
<set>QDialogButtonBox::Close</set>
|
||||||
</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>
|
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
@ -106,6 +134,10 @@
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
|
<tabstops>
|
||||||
|
<tabstop>treeView</tabstop>
|
||||||
|
<tabstop>comboBoxDisplayMode</tabstop>
|
||||||
|
</tabstops>
|
||||||
<resources/>
|
<resources/>
|
||||||
<connections/>
|
<connections/>
|
||||||
</ui>
|
</ui>
|
||||||
|
|
|
@ -52,16 +52,65 @@
|
||||||
</property>
|
</property>
|
||||||
</spacer>
|
</spacer>
|
||||||
</item>
|
</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>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QTreeView" name="treeView"/>
|
<widget class="QTreeView" name="treeView">
|
||||||
</item>
|
<attribute name="headerMinimumSectionSize">
|
||||||
<item>
|
<number>10</number>
|
||||||
<widget class="QLabel" name="label">
|
</attribute>
|
||||||
<property name="text">
|
|
||||||
<string>Note: If you select a top-level folder and deselect its children, they will still be restored.</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<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
|
from .borg_job import BorgJob
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,7 +20,7 @@ class BorgExtractJob(BorgJob):
|
||||||
self.app.backup_progress_event.emit(self.tr('Restored files from archive.'))
|
self.app.backup_progress_event.emit(self.tr('Restored files from archive.'))
|
||||||
|
|
||||||
@classmethod
|
@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)
|
ret = super().prepare(profile)
|
||||||
if not ret['ok']:
|
if not ret['ok']:
|
||||||
return ret
|
return ret
|
||||||
|
@ -22,8 +29,31 @@ class BorgExtractJob(BorgJob):
|
||||||
|
|
||||||
cmd = ['borg', 'extract', '--list', '--info', '--log-json']
|
cmd = ['borg', 'extract', '--list', '--info', '--log-json']
|
||||||
cmd.append(f'{profile.repo.url}::{archive_name}')
|
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['ok'] = True
|
||||||
ret['cmd'] = cmd
|
ret['cmd'] = cmd
|
||||||
|
@ -31,5 +61,5 @@ class BorgExtractJob(BorgJob):
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def process_result(self, result):
|
def process_result(self, result: dict):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -21,7 +21,9 @@ class BorgListArchiveJob(BorgJob):
|
||||||
ret['archive_name'] = archive_name
|
ret['archive_name'] = archive_name
|
||||||
ret['cmd'] = [
|
ret['cmd'] = [
|
||||||
'borg', 'list', '--info', '--log-json', '--json-lines',
|
'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}']
|
f'{profile.repo.url}::{archive_name}']
|
||||||
ret['ok'] = True
|
ret['ok'] = True
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import os
|
import os
|
||||||
from PyQt5.QtWidgets import QMenu, QSystemTrayIcon
|
|
||||||
from PyQt5.QtGui import QIcon
|
from PyQt5.QtGui import QIcon
|
||||||
|
from PyQt5.QtWidgets import QApplication, QMenu, QSystemTrayIcon
|
||||||
|
|
||||||
from vorta.store.models import BackupProfileModel
|
from vorta.store.models import BackupProfileModel
|
||||||
from vorta.utils import get_asset
|
from vorta.utils import get_asset
|
||||||
|
|
||||||
|
|
||||||
class TrayMenu(QSystemTrayIcon):
|
class TrayMenu(QSystemTrayIcon):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent: QApplication = None):
|
||||||
QSystemTrayIcon.__init__(self, parent)
|
QSystemTrayIcon.__init__(self, parent)
|
||||||
self.app = parent
|
self.app = parent
|
||||||
self.set_tray_icon()
|
self.set_tray_icon()
|
||||||
|
@ -19,6 +20,7 @@ class TrayMenu(QSystemTrayIcon):
|
||||||
self.setContextMenu(menu)
|
self.setContextMenu(menu)
|
||||||
|
|
||||||
self.activated.connect(self.on_activation)
|
self.activated.connect(self.on_activation)
|
||||||
|
self.app.paletteChanged.connect(lambda p: self.set_tray_icon())
|
||||||
self.setVisible(True)
|
self.setVisible(True)
|
||||||
self.show()
|
self.show()
|
||||||
|
|
||||||
|
|
|
@ -26,8 +26,9 @@ from vorta.borg.umount import BorgUmountJob
|
||||||
from vorta.store.models import ArchiveModel, BackupProfileMixin
|
from vorta.store.models import ArchiveModel, BackupProfileMixin
|
||||||
from vorta.utils import (choose_file_dialog, format_archive_name, get_asset,
|
from vorta.utils import (choose_file_dialog, format_archive_name, get_asset,
|
||||||
get_mount_points, pretty_bytes)
|
get_mount_points, pretty_bytes)
|
||||||
from vorta.views.diff_result import DiffResult
|
from vorta.views import diff_result, extract_dialog
|
||||||
from vorta.views.extract_dialog import ExtractDialog
|
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.source_tab import SizeItem
|
||||||
from vorta.views.utils import get_colored_icon
|
from vorta.views.utils import get_colored_icon
|
||||||
|
|
||||||
|
@ -118,6 +119,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
||||||
self.selected_archives = None
|
self.selected_archives = None
|
||||||
self.set_icons()
|
self.set_icons()
|
||||||
|
|
||||||
|
# Connect to palette change
|
||||||
|
self.app.paletteChanged.connect(lambda p: self.set_icons())
|
||||||
|
|
||||||
def set_icons(self):
|
def set_icons(self):
|
||||||
"Used when changing between light- and dark mode"
|
"Used when changing between light- and dark mode"
|
||||||
self.bCheck.setIcon(get_colored_icon('check-circle'))
|
self.bCheck.setIcon(get_colored_icon('check-circle'))
|
||||||
|
@ -682,6 +686,9 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
def extract_action(self):
|
def extract_action(self):
|
||||||
|
"""
|
||||||
|
Open a dialog for choosing what to extract from the selected archive.
|
||||||
|
"""
|
||||||
profile = self.profile()
|
profile = self.profile()
|
||||||
|
|
||||||
row_selected = self.archiveTable.selectionModel().selectedRows()
|
row_selected = self.archiveTable.selectionModel().selectedRows()
|
||||||
|
@ -706,28 +713,46 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
||||||
self._set_status(self.tr('Select an archive to restore first.'))
|
self._set_status(self.tr('Select an archive to restore first.'))
|
||||||
|
|
||||||
def extract_list_result(self, result):
|
def extract_list_result(self, result):
|
||||||
|
"""Process the contents of the archive to extract."""
|
||||||
self._set_status('')
|
self._set_status('')
|
||||||
if result['returncode'] == 0:
|
if result['returncode'] == 0:
|
||||||
|
archive = ArchiveModel.get(name=result['params']['archive_name'])
|
||||||
|
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 process_result():
|
||||||
def receive():
|
def receive():
|
||||||
extraction_folder = dialog.selectedFiles()
|
extraction_folder = dialog.selectedFiles()
|
||||||
if extraction_folder:
|
if extraction_folder:
|
||||||
params = BorgExtractJob.prepare(
|
params = BorgExtractJob.prepare(self.profile(),
|
||||||
self.profile(), archive.name, window.selected, extraction_folder[0])
|
archive.name,
|
||||||
|
model,
|
||||||
|
extraction_folder[0])
|
||||||
if params['ok']:
|
if params['ok']:
|
||||||
self._toggle_all_buttons(False)
|
self._toggle_all_buttons(False)
|
||||||
job = BorgExtractJob(params['cmd'], params, self.profile().repo.id)
|
job = BorgExtractJob(params['cmd'], params,
|
||||||
|
self.profile().repo.id)
|
||||||
job.updated.connect(self.mountErrors.setText)
|
job.updated.connect(self.mountErrors.setText)
|
||||||
job.result.connect(self.extract_archive_result)
|
job.result.connect(self.extract_archive_result)
|
||||||
self.app.jobs_manager.add_job(job)
|
self.app.jobs_manager.add_job(job)
|
||||||
else:
|
else:
|
||||||
self._set_status(params['message'])
|
self._set_status(params['message'])
|
||||||
|
|
||||||
dialog = choose_file_dialog(self, self.tr("Choose Extraction Point"), want_folder=True)
|
dialog = choose_file_dialog(self,
|
||||||
|
self.tr("Choose Extraction Point"),
|
||||||
|
want_folder=True)
|
||||||
dialog.open(receive)
|
dialog.open(receive)
|
||||||
|
|
||||||
archive = ArchiveModel.get(name=result['params']['archive_name'])
|
window = ExtractDialog(archive, model)
|
||||||
window = ExtractDialog(result['data'], archive)
|
|
||||||
self._toggle_all_buttons(True)
|
self._toggle_all_buttons(True)
|
||||||
window.setParent(self, QtCore.Qt.Sheet)
|
window.setParent(self, QtCore.Qt.Sheet)
|
||||||
self._window = window # for testing
|
self._window = window # for testing
|
||||||
|
@ -735,6 +760,7 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
||||||
window.accepted.connect(process_result)
|
window.accepted.connect(process_result)
|
||||||
|
|
||||||
def extract_archive_result(self, result):
|
def extract_archive_result(self, result):
|
||||||
|
"""Finished extraction."""
|
||||||
self._toggle_all_buttons(True)
|
self._toggle_all_buttons(True)
|
||||||
|
|
||||||
def cell_double_clicked(self, row, column):
|
def cell_double_clicked(self, row, column):
|
||||||
|
@ -862,10 +888,27 @@ class ArchiveTab(ArchiveTabBase, ArchiveTabUI, BackupProfileMixin):
|
||||||
name=result['params']['archive_name_newer'])
|
name=result['params']['archive_name_newer'])
|
||||||
archive_older = ArchiveModel.get(
|
archive_older = ArchiveModel.get(
|
||||||
name=result['params']['archive_name_older'])
|
name=result['params']['archive_name_older'])
|
||||||
window = DiffResult(result['data'], archive_newer, archive_older,
|
self._set_status(self.tr("Processing diff results."))
|
||||||
result['params']['json_lines'])
|
|
||||||
|
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._toggle_all_buttons(True)
|
||||||
window.setParent(self, QtCore.Qt.Sheet)
|
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
|
self._resultwindow = window # for testing
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,108 +1,688 @@
|
||||||
|
import enum
|
||||||
import json
|
import json
|
||||||
import os
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import PurePath
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5 import uic
|
from PyQt5 import uic
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import (QDateTime, QLocale, QMimeData, QModelIndex, QPoint,
|
||||||
from PyQt5.QtWidgets import QDialogButtonBox, QHeaderView, QPushButton
|
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.utils import get_asset, pretty_bytes, uses_dark_mode
|
||||||
from vorta.views.partials.tree_view import TreeModel
|
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")
|
uifile = get_asset("UI/extractdialog.ui")
|
||||||
ExtractDialogUI, ExtractDialogBase = uic.loadUiType(uifile)
|
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):
|
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__()
|
super().__init__()
|
||||||
self.setupUi(self)
|
self.setupUi(self)
|
||||||
|
|
||||||
nested_file_list = nested_dict()
|
self.model = model
|
||||||
self.selected = set()
|
self.model.setParent(self)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
view = self.treeView
|
view = self.treeView
|
||||||
view.setAlternatingRowColors(True)
|
view.setAlternatingRowColors(True)
|
||||||
view.setUniformRowHeights(True) # Allows for scrolling optimizations.
|
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 = view.header()
|
||||||
header.setStretchLastSection(False)
|
header.setStretchLastSection(False)
|
||||||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||||
|
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
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
|
# add extract button to button box
|
||||||
self.extractButton = QPushButton(self)
|
self.extractButton = QPushButton(self)
|
||||||
self.extractButton.setObjectName("extractButton")
|
self.extractButton.setObjectName("extractButton")
|
||||||
self.extractButton.setText(self.tr("Extract"))
|
self.extractButton.setText(self.tr("Extract"))
|
||||||
|
|
||||||
self.buttonBox.addButton(self.extractButton,
|
self.buttonBox.addButton(
|
||||||
QDialogButtonBox.ButtonRole.AcceptRole)
|
self.extractButton, QDialogButtonBox.ButtonRole.AcceptRole
|
||||||
|
)
|
||||||
|
|
||||||
self.archiveNameLabel.setText(f"{archive.name}, {archive.time}")
|
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.rejected.connect(self.close)
|
||||||
self.buttonBox.accepted.connect(self.accept)
|
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):
|
def retranslateUi(self, dialog):
|
||||||
"""Retranslate strings in ui."""
|
"""Retranslate strings in ui."""
|
||||||
super().retranslateUi(dialog)
|
super().retranslateUi(dialog)
|
||||||
|
|
||||||
# setupUi calls retranslateUi
|
# setupUi calls retranslateUi
|
||||||
if hasattr(self, 'extractButton'):
|
if hasattr(self, "extractButton"):
|
||||||
self.extractButton.setText(self.tr("Extract"))
|
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 slot_sorted(self, column, order):
|
||||||
def __init__(
|
"""React to the tree view being sorted."""
|
||||||
self,
|
# reveal selection
|
||||||
files_with_attributes,
|
selectedRows = self.treeView.selectionModel().selectedRows()
|
||||||
nested_file_list,
|
if selectedRows:
|
||||||
selected_files_folders,
|
self.treeView.scrollTo(selectedRows[0])
|
||||||
parent=None,
|
|
||||||
):
|
def copy_item(self, index: QModelIndex = None):
|
||||||
super().__init__(
|
"""
|
||||||
files_with_attributes, nested_file_list, selected_files_folders, parent
|
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():
|
if not index.isValid():
|
||||||
return None
|
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()
|
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:
|
# set state of hidden items
|
||||||
return item.data(index.column())
|
parent = child_item._parent
|
||||||
elif role == Qt.CheckStateRole and index.column() == 0:
|
while parent != item:
|
||||||
return item.getCheckedState()
|
# hidden parent must have 1 child
|
||||||
else:
|
parent.data.checked_children = (
|
||||||
return None
|
0 if value == Qt.CheckState.Unchecked
|
||||||
|
else self.rowCount(child)
|
||||||
|
)
|
||||||
|
parent.data.checkstate = value
|
||||||
|
|
||||||
def flags(self, index):
|
parent = parent._parent
|
||||||
if not index.isValid():
|
|
||||||
return Qt.NoItemFlags
|
|
||||||
|
|
||||||
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.populate_from_profile()
|
||||||
self.set_icons()
|
self.set_icons()
|
||||||
|
|
||||||
|
# Connect to palette change
|
||||||
|
QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
|
||||||
|
|
||||||
def set_icons(self):
|
def set_icons(self):
|
||||||
self.bAddSSHKey.setIcon(get_colored_icon("plus"))
|
self.bAddSSHKey.setIcon(get_colored_icon("plus"))
|
||||||
self.bAddRepo.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
|
# Connect to schedule update
|
||||||
self.app.scheduler.schedule_changed.connect(lambda pid: self.draw_next_scheduled_backup())
|
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, _):
|
def on_scheduler_change(self, _):
|
||||||
profile = self.profile()
|
profile = self.profile()
|
||||||
# Save scheduler settings, apply new scheduler and display next task for 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: hasattr(tab, '_window'), **pytest._wait_defaults)
|
||||||
# qtbot.waitUntil(lambda: tab._window == qapp.activeWindow(), **pytest._wait_defaults)
|
# qtbot.waitUntil(lambda: tab._window == qapp.activeWindow(), **pytest._wait_defaults)
|
||||||
|
|
||||||
assert tab._window.treeView.model().rootItem.childItems[0].data(0) == 'Users'
|
model = tab._window.model
|
||||||
tab._window.treeView.model().rootItem.childItems[0].load_children()
|
assert model.root.children[0].subpath == 'home'
|
||||||
assert tab._window.archiveNameLabel.text().startswith('test-archive, 2000')
|
assert tab._window.archiveNameLabel.text().startswith('test-archive, 2000')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
|
from pathlib import PurePath
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PyQt5.QtCore import QItemSelectionModel
|
from PyQt5.QtCore import QItemSelectionModel
|
||||||
|
|
||||||
import vorta.borg
|
import vorta.borg
|
||||||
import vorta.utils
|
import vorta.utils
|
||||||
import vorta.views.archive_tab
|
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', [
|
@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)
|
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0)
|
||||||
mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)
|
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()
|
selection_model: QItemSelectionModel = tab.archiveTable.selectionModel()
|
||||||
model = tab.archiveTable.model()
|
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)
|
qtbot.waitUntil(lambda: hasattr(tab, '_resultwindow'), **pytest._wait_defaults)
|
||||||
|
|
||||||
assert tab._resultwindow.treeView.model().rootItem.childItems[0].data(0) == folder_root
|
model = tab._resultwindow.treeView.model().sourceModel()
|
||||||
tab._resultwindow.treeView.model().rootItem.childItems[0].load_children()
|
assert model.root.children[0].subpath == folder_root
|
||||||
|
|
||||||
assert tab._resultwindow.archiveNameLabel_1.text() == 'test-archive'
|
assert tab._resultwindow.archiveNameLabel_1.text() == 'test-archive'
|
||||||
tab._resultwindow.accept()
|
tab._resultwindow.accept()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('line, expected', [
|
@pytest.mark.parametrize(
|
||||||
|
'line, expected',
|
||||||
|
[
|
||||||
('changed link some/changed/link',
|
('changed link some/changed/link',
|
||||||
(0, 'changed', 'link', 'some/changed', '-')),
|
('some/changed/link', FileType.LINK, ChangeType.CHANGED_LINK, 0, 0,
|
||||||
|
None, None, None)),
|
||||||
(' +77.8 kB -77.8 kB some/changed/file',
|
(' +77.8 kB -77.8 kB some/changed/file',
|
||||||
(77800, 'modified', 'file', 'some/changed', '-')),
|
('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',
|
(' +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', '-')),
|
('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',
|
('[-rw-rw-rw- -> -rw-r--r--] some/changed/file',
|
||||||
(0, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
|
('some/changed/file', FileType.FILE, ChangeType.MODE, 0, 0,
|
||||||
|
('-rw-rw-rw-', '-rw-r--r--'), None, None)),
|
||||||
('added directory some/changed/dir',
|
('added directory some/changed/dir',
|
||||||
(0, 'added', 'dir', 'some/changed', 'd')),
|
('some/changed/dir', FileType.DIRECTORY, ChangeType.ADDED, 0, 0, None,
|
||||||
|
None, None)),
|
||||||
('removed directory some/changed/dir',
|
('removed directory some/changed/dir',
|
||||||
(0, 'removed', 'dir', 'some/changed', 'd')),
|
('some/changed/dir', FileType.DIRECTORY, ChangeType.REMOVED_DIR, 0, 0,
|
||||||
|
None, None, None)),
|
||||||
|
|
||||||
# Example from https://github.com/borgbase/vorta/issues/521
|
# Example from https://github.com/borgbase/vorta/issues/521
|
||||||
('[user:user -> nfsnobody:nfsnobody] home/user/arrays/test.txt',
|
('[user:user -> nfsnobody:nfsnobody] home/user/arrays/test.txt',
|
||||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
('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
|
# Very short owner change, to check stripping whitespace from file path
|
||||||
('[a:a -> b:b] home/user/arrays/test.txt',
|
('[a:a -> b:b] home/user/arrays/test.txt',
|
||||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
('home/user/arrays/test.txt', FileType.FILE, ChangeType.OWNER, 0, 0,
|
||||||
|
None, ('a', 'a', 'b', 'b'), None)),
|
||||||
|
|
||||||
# All file-related changes in one test
|
# 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',
|
(' +77.8 kB -800 B [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', '-')),
|
('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):
|
def test_archive_diff_parser(line, expected):
|
||||||
files_with_attributes, nested_file_list = vorta.views.diff_result.parse_diff_lines([line])
|
model = DiffTree()
|
||||||
assert files_with_attributes == [expected]
|
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', [
|
@pytest.mark.parametrize(
|
||||||
({'path': 'some/changed/link', 'changes': [{'type': 'changed link'}]},
|
'line, expected',
|
||||||
(0, 'changed', 'link', 'some/changed', '-')),
|
[
|
||||||
({'path': 'some/changed/file', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800}]},
|
({
|
||||||
(77800, 'modified', 'file', 'some/changed', '-')),
|
'path': 'some/changed/link',
|
||||||
({'path': 'some/changed/file', 'changes': [{'type': 'modified', 'added': 77800, 'removed': 77800},
|
'changes': [{
|
||||||
{'type': 'mode', 'old_mode': '-rw-rw-rw-', 'new_mode': '-rw-r--r--'}]},
|
'type': 'changed link'
|
||||||
(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--'}]},
|
}, ('some/changed/link', FileType.LINK, ChangeType.CHANGED_LINK, 0, 0,
|
||||||
(0, '[-rw-rw-rw- -> -rw-r--r--]', 'file', 'some/changed', '-')),
|
None, None, None)),
|
||||||
({'path': 'some/changed/dir', 'changes': [{'type': 'added directory'}]},
|
({
|
||||||
(0, 'added', 'dir', 'some/changed', 'd')),
|
'path': 'some/changed/file',
|
||||||
({'path': 'some/changed/dir', 'changes': [{'type': 'removed directory'}]},
|
'changes': [{
|
||||||
(0, 'removed', 'dir', 'some/changed', 'd')),
|
'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
|
# 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'}]},
|
'path':
|
||||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
'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
|
# 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'}]},
|
'path':
|
||||||
(0, 'modified', 'test.txt', 'home/user/arrays', '-')),
|
'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
|
# 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-',
|
'path':
|
||||||
'new_mode': '-rw-r--r--'},
|
'home/user/arrays/test.txt',
|
||||||
{'type': 'owner', 'old_user': 'user', 'new_user': 'nfsnobody',
|
'changes': [{
|
||||||
'old_group': 'user', 'new_group': 'nfsnobody'}]},
|
'type': 'modified',
|
||||||
(77800, '[-rw-rw-rw- -> -rw-r--r--]', 'test.txt', 'home/user/arrays', '-')),
|
'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):
|
def test_archive_diff_json_parser(line, expected):
|
||||||
files_with_attributes, _nested_file_list = vorta.views.diff_result.parse_diff_json_lines([line])
|
model = DiffTree()
|
||||||
assert files_with_attributes == [expected]
|
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