mirror of https://github.com/borgbase/vorta
919 lines
32 KiB
Python
919 lines
32 KiB
Python
import enum
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import PurePath
|
|
from typing import List, Optional, Tuple
|
|
|
|
from PyQt6 import uic
|
|
from PyQt6.QtCore import (
|
|
QDateTime,
|
|
QLocale,
|
|
QMimeData,
|
|
QModelIndex,
|
|
QPoint,
|
|
Qt,
|
|
QThread,
|
|
QUrl,
|
|
)
|
|
from PyQt6.QtGui import QColor, QKeySequence, QShortcut
|
|
from PyQt6.QtWidgets import QApplication, QHeaderView, QMenu, QTreeView
|
|
|
|
from vorta.store.models import SettingsModel
|
|
from vorta.utils import get_asset, pretty_bytes, uses_dark_mode
|
|
from vorta.views.partials.treemodel import (
|
|
FileSystemItem,
|
|
FileTreeModel,
|
|
FileTreeSortProxyModel,
|
|
path_to_str,
|
|
relative_path,
|
|
)
|
|
from vorta.views.utils import get_colored_icon
|
|
|
|
uifile = get_asset('UI/diffresult.ui')
|
|
DiffResultUI, DiffResultBase = uic.loadUiType(uifile)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ParseThread(QThread):
|
|
"""A thread parsing diff results."""
|
|
|
|
def __init__(self, fs_data: str, json_lines: bool, model: 'DiffTree', parent=None):
|
|
"""Init."""
|
|
super().__init__(parent)
|
|
self.model = model
|
|
self.fs_data = fs_data
|
|
self.json_lines = json_lines
|
|
|
|
def run(self) -> None:
|
|
"""Do the work"""
|
|
# Older version do not support json output
|
|
if self.json_lines:
|
|
# If fs_data is already a dict, then there was just a single json-line
|
|
# and the default handler already parsed into json dict, otherwise
|
|
# fs_data is a str, and needs to be split and parsed into json dicts
|
|
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_diff_json(lines, self.model)
|
|
else:
|
|
lines = [line for line in self.fs_data.split('\n') if line]
|
|
parse_diff_lines(lines, self.model)
|
|
|
|
|
|
class DiffResultDialog(DiffResultBase, DiffResultUI):
|
|
"""Display the results of `borg diff`."""
|
|
|
|
def __init__(self, archive_newer, archive_older, model: 'DiffTree'):
|
|
"""Init."""
|
|
super().__init__()
|
|
self.setupUi(self)
|
|
|
|
self.model = model
|
|
self.model.setParent(self)
|
|
|
|
self.treeView: QTreeView
|
|
self.treeView.setUniformRowHeights(True) # Allows for scrolling optimizations.
|
|
self.treeView.setAlternatingRowColors(True)
|
|
self.treeView.setTextElideMode(Qt.TextElideMode.ElideMiddle) # to better see name of paths
|
|
|
|
# custom context menu
|
|
self.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.treeView.customContextMenuRequested.connect(self.treeview_context_menu)
|
|
|
|
# shortcuts
|
|
shortcut_copy = QShortcut(QKeySequence.StandardKey.Copy, self.treeView)
|
|
shortcut_copy.activated.connect(self.diff_item_copy)
|
|
|
|
# add sort proxy model
|
|
self.sortproxy = DiffSortProxyModel(self)
|
|
self.sortproxy.setSourceModel(self.model)
|
|
self.treeView.setModel(self.sortproxy)
|
|
self.sortproxy.sorted.connect(self.slot_sorted)
|
|
|
|
self.treeView.setSortingEnabled(True)
|
|
|
|
# header
|
|
header = self.treeView.header()
|
|
header.setStretchLastSection(False) # stretch only first section
|
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
|
|
|
# signals
|
|
|
|
self.archiveNameLabel_1.setText(f'{archive_newer.name}')
|
|
self.archiveNameLabel_2.setText(f'{archive_older.name}')
|
|
|
|
self.comboBoxDisplayMode.currentIndexChanged.connect(self.change_display_mode)
|
|
diff_result_display_mode = SettingsModel.get(key='diff_files_display_mode').str_value
|
|
self.comboBoxDisplayMode.setCurrentIndex(int(diff_result_display_mode))
|
|
self.bFoldersOnTop.toggled.connect(self.sortproxy.keepFoldersOnTop)
|
|
self.bCollapseAll.clicked.connect(self.treeView.collapseAll)
|
|
|
|
self.buttonBox.accepted.connect(self.accept)
|
|
self.buttonBox.rejected.connect(self.reject)
|
|
|
|
self.set_icons()
|
|
|
|
# Connect to palette change
|
|
QApplication.instance().paletteChanged.connect(lambda p: self.set_icons())
|
|
|
|
def set_icons(self):
|
|
"""Set or update the icons in the right color scheme."""
|
|
self.bCollapseAll.setIcon(get_colored_icon('angle-up-solid'))
|
|
self.bFoldersOnTop.setIcon(get_colored_icon('folder-on-top'))
|
|
self.comboBoxDisplayMode.setItemIcon(0, get_colored_icon("view-list-tree"))
|
|
self.comboBoxDisplayMode.setItemIcon(1, get_colored_icon("view-list-tree"))
|
|
self.comboBoxDisplayMode.setItemIcon(2, get_colored_icon("view-list-details"))
|
|
|
|
def treeview_context_menu(self, pos: QPoint):
|
|
"""Display a context menu for `treeView`."""
|
|
index = self.treeView.indexAt(pos)
|
|
if not index.isValid():
|
|
# popup only for items
|
|
return
|
|
|
|
menu = QMenu(self.treeView)
|
|
|
|
menu.addAction(
|
|
get_colored_icon('copy'),
|
|
self.tr("Copy"),
|
|
lambda: self.diff_item_copy(index),
|
|
)
|
|
|
|
if self.model.getMode() != self.model.DisplayMode.FLAT:
|
|
menu.addSeparator()
|
|
menu.addAction(
|
|
get_colored_icon('angle-down-solid'),
|
|
self.tr("Expand recursively"),
|
|
lambda: self.treeView.expandRecursively(index),
|
|
)
|
|
|
|
menu.popup(self.treeView.viewport().mapToGlobal(pos))
|
|
|
|
def diff_item_copy(self, index: QModelIndex = None):
|
|
"""
|
|
Copy a diff item path to the clipboard.
|
|
|
|
Copies the first selected item if no index is specified.
|
|
"""
|
|
if index is None or (not index.isValid()):
|
|
indexes = self.treeView.selectionModel().selectedRows()
|
|
|
|
if not indexes:
|
|
return
|
|
|
|
index = indexes[0]
|
|
|
|
index = self.sortproxy.mapToSource(index)
|
|
item: DiffItem = index.internalPointer()
|
|
path = PurePath('/', *item.path)
|
|
|
|
data = QMimeData()
|
|
data.setUrls([QUrl(path.as_uri())])
|
|
data.setText(str(path))
|
|
|
|
QApplication.clipboard().setMimeData(data)
|
|
|
|
def change_display_mode(self, selection: int):
|
|
"""
|
|
Change the display mode of the tree view
|
|
|
|
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
|
|
elif selection == 2:
|
|
mode = FileTreeModel.DisplayMode.FLAT
|
|
else:
|
|
raise Exception("Unknown item in comboBoxDisplayMode with index {}".format(selection))
|
|
|
|
SettingsModel.update({SettingsModel.str_value: str(selection)}).where(
|
|
SettingsModel.key == 'diff_files_display_mode'
|
|
).execute()
|
|
|
|
self.model.setMode(mode)
|
|
|
|
def slot_sorted(self, column, order):
|
|
"""React the tree view being sorted."""
|
|
# reveal selection
|
|
selectedRows = self.treeView.selectionModel().selectedRows()
|
|
if selectedRows:
|
|
self.treeView.scrollTo(selectedRows[0])
|
|
|
|
|
|
# ---- Output parsing --------------------------------------------------------
|
|
|
|
|
|
def parse_diff_json(diffs: List[dict], model: 'DiffTree'):
|
|
"""Parse the json output from `borg diff`."""
|
|
for item in diffs:
|
|
path = PurePath(item['path'])
|
|
file_type = FileType.FILE
|
|
changed_size = 0
|
|
size = 0
|
|
change_type: ChangeType = None
|
|
mode_change: Optional[Tuple[str, str]] = None
|
|
owner_change: Optional[Tuple[str, str, str, str]] = None
|
|
ctime_change: Optional[Tuple[QDateTime, QDateTime]] = None
|
|
mtime_change: Optional[Tuple[QDateTime, QDateTime]] = None
|
|
modified: Optional[Tuple[int, int]] = None
|
|
|
|
# added link, removed link, changed link
|
|
# modified (added, removed), added (size), removed (size)
|
|
# added directory, removed directory
|
|
# owner (old_user, new_user, old_group, new_group))
|
|
# mode (old_mode, new_mode)
|
|
# ctime (old_ctime, new_ctime)
|
|
# mtime (old_mtime, new_mtime)
|
|
for change in item['changes']:
|
|
# if more than one type of change has happened for this file/dir/link, then report the most important
|
|
# (higher priority)
|
|
if {'type': 'modified'} == change:
|
|
# modified, but can't compare ids -> no added, removed
|
|
change_type = ChangeType.MODIFIED
|
|
elif change['type'] == 'modified':
|
|
# modified with added, removed
|
|
change_type = ChangeType.MODIFIED
|
|
size = change['added'] - change['removed']
|
|
modified = (change['added'], change['removed'])
|
|
changed_size = sum(modified)
|
|
|
|
elif change['type'] == 'changed link':
|
|
change_type = ChangeType.CHANGED_LINK
|
|
file_type = FileType.LINK
|
|
|
|
elif change['type'].startswith(('added', 'removed')):
|
|
if 'directory' in change['type']:
|
|
file_type = FileType.DIRECTORY
|
|
elif 'link' in change['type']:
|
|
file_type = FileType.LINK
|
|
elif 'chrdev' in change['type']:
|
|
file_type = FileType.CHRDEV
|
|
elif 'blkdev' in change['type']:
|
|
file_type = FileType.BLKDEV
|
|
elif 'fifo' in change['type']:
|
|
file_type = FileType.FIFO
|
|
# else default FileType.FILE
|
|
|
|
size = change.get('size', 0)
|
|
|
|
a_r = change['type'].split()[0] # 'added' or 'removed'
|
|
if a_r == 'added':
|
|
change_type = ChangeType.ADDED
|
|
else:
|
|
change_type = ChangeType.REMOVED
|
|
size = -size
|
|
|
|
changed_size = size
|
|
|
|
elif change['type'] == 'mode':
|
|
# mode change can occur along with previous changes
|
|
change_type = ChangeType.MODIFIED
|
|
mode_change = (change['old_mode'], change['new_mode'])
|
|
|
|
elif change['type'] == 'owner':
|
|
# owner change can occur along with previous changes
|
|
change_type = ChangeType.MODIFIED
|
|
|
|
owner_change = (
|
|
change['old_user'],
|
|
change['old_group'],
|
|
change['new_user'],
|
|
change['new_group'],
|
|
)
|
|
|
|
elif change['type'] == 'ctime':
|
|
# ctime change can occur along with previous changes
|
|
change_type = ChangeType.MODIFIED
|
|
ctime_change = (
|
|
QDateTime.fromString(change['old_ctime'], Qt.DateFormat.ISODateWithMs),
|
|
QDateTime.fromString(change['new_ctime'], Qt.DateFormat.ISODateWithMs),
|
|
)
|
|
elif change['type'] == 'mtime':
|
|
# mtime change can occur along with previous changes
|
|
change_type = ChangeType.MODIFIED
|
|
mtime_change = (
|
|
QDateTime.fromString(change['old_mtime'], Qt.DateFormat.ISODateWithMs),
|
|
QDateTime.fromString(change['new_mtime'], Qt.DateFormat.ISODateWithMs),
|
|
)
|
|
|
|
else:
|
|
raise Exception('Unknown change type: {}'.format(change['type']))
|
|
|
|
model.addItem(
|
|
(
|
|
path,
|
|
DiffData(
|
|
file_type=file_type,
|
|
change_type=change_type,
|
|
changed_size=changed_size,
|
|
size=size,
|
|
mode_change=mode_change,
|
|
owner_change=owner_change,
|
|
ctime_change=ctime_change,
|
|
mtime_change=mtime_change,
|
|
modified=modified,
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
# re pattern
|
|
pattern_otypes = r'directory|link|fifo|chrdev|blkdev'
|
|
pattern_ar = r'(?P<a_r>added|removed) ((?P<ar_type>' + pattern_otypes + r')|\s+(?P<size>[\d.]+) (?P<size_unit>\w+))\s*'
|
|
pattern_cl = r'changed link\s*'
|
|
pattern_modified = r'\s*\+?(?P<added>[\d.]+) (?P<added_unit>\w+)\s*-?(?P<removed>[\d.]+) (?P<removed_unit>\w+)'
|
|
pattern_mode = r'\[(?P<old_mode>[\w-]{10}) -> (?P<new_mode>[\w-]{10})\]'
|
|
pattern_owner = r'\[(?P<old_user>[\w ]+):(?P<old_group>[\w ]+) -> (?P<new_user>[\w ]+):(?P<new_group>[\w ]+)\]'
|
|
pattern_path = r'(?P<path>.*)'
|
|
pattern_changed_file = (
|
|
r'(({ar} )|((?P<cl>{cl} )|'
|
|
+ r'((?P<modified>{modified}\s+)?)(?P<owner>{owner}\s+)?(?P<mode>{mode}\s+)?))'
|
|
+ r'{path}'
|
|
).format(
|
|
ar=pattern_ar,
|
|
cl=pattern_cl,
|
|
modified=pattern_modified,
|
|
mode=pattern_mode,
|
|
owner=pattern_owner,
|
|
path=pattern_path,
|
|
)
|
|
re_changed_file = re.compile(pattern_changed_file)
|
|
|
|
|
|
def parse_diff_lines(lines: List[str], model: 'DiffTree'):
|
|
"""
|
|
Parse non-json diff output from borg.
|
|
|
|
::
|
|
|
|
[-rw-rw-r-- -> lrwxrwxrwx] home/theuser/Documents/testdir/file2
|
|
[-rw-rw-r-- -> drwxr-xr-x] home/theuser/Documents/testdir/file3
|
|
+32 B -36 B [-r--rw---- -> -rwxrwx--x] home/theuser/Documents/testfile.txt
|
|
[drwxrwxr-x -> lrwxrwxrwx] home/theuser/Documents/testlink
|
|
added directory home/theuser/Documents/newfolder
|
|
removed 0 B home/theuser/Documents/testdir/file1
|
|
added 20 B home/theuser/Documents/testdir/file4
|
|
changed link home/theuser/Documents/testlink
|
|
changed link [theuser:dip -> theuser:theuser] home/theuser/Documents/testlink
|
|
|
|
Notes
|
|
-----
|
|
This method can't handle changes of type `modified` that do not provide
|
|
the amount of `added` and `removed` bytes.
|
|
|
|
"""
|
|
for line in lines:
|
|
if not line:
|
|
continue
|
|
|
|
parsed_line = re_changed_file.fullmatch(line)
|
|
|
|
if not parsed_line:
|
|
raise Exception("Couldn't parse diff output `{}`".format(line))
|
|
continue
|
|
|
|
path = PurePath(parsed_line['path'])
|
|
file_type = FileType.FILE
|
|
size = 0
|
|
changed_size = 0
|
|
change_type: ChangeType = None
|
|
mode_change: Optional[Tuple[str, str]] = None
|
|
owner_change: Optional[Tuple[str, str, str, str]] = None
|
|
modified: Optional[Tuple[int, int]] = None
|
|
|
|
if parsed_line['a_r']:
|
|
# added or removed
|
|
if parsed_line['ar_type']:
|
|
if parsed_line['ar_type'] == 'directory':
|
|
file_type = FileType.DIRECTORY
|
|
elif parsed_line['ar_type'] == 'link':
|
|
file_type = FileType.LINK
|
|
elif parsed_line['ar_type'] == 'chrdev':
|
|
file_type = FileType.CHRDEV
|
|
elif parsed_line['ar_type'] == 'blkdev':
|
|
file_type = FileType.BLKDEV
|
|
elif parsed_line['ar_type'] == 'fifo':
|
|
file_type = FileType.FIFO
|
|
else:
|
|
raise ValueError(f"Unknown file type `{parsed_line['ar_type']}`")
|
|
else:
|
|
# normal file
|
|
size = size_to_byte(parsed_line['size'], parsed_line['size_unit'])
|
|
|
|
if parsed_line['a_r'] == 'added':
|
|
change_type = ChangeType.ADDED
|
|
elif parsed_line['a_r'] == 'removed':
|
|
change_type = ChangeType.REMOVED
|
|
size = -size
|
|
|
|
changed_size = size
|
|
else:
|
|
change_type = ChangeType.MODIFIED
|
|
|
|
if parsed_line['owner']:
|
|
# owner changed
|
|
owner_change = (
|
|
parsed_line['old_user'],
|
|
parsed_line['old_group'],
|
|
parsed_line['new_user'],
|
|
parsed_line['new_group'],
|
|
)
|
|
|
|
if parsed_line['cl']:
|
|
# link changed
|
|
# links can't have changed permissions
|
|
change_type = ChangeType.CHANGED_LINK
|
|
file_type = FileType.LINK
|
|
else:
|
|
# modified contents or mode
|
|
if parsed_line['modified']:
|
|
modified = (
|
|
size_to_byte(parsed_line['added'], parsed_line['added_unit']),
|
|
size_to_byte(parsed_line['removed'], parsed_line['removed_unit']),
|
|
)
|
|
|
|
size = modified[0] - modified[1]
|
|
changed_size = sum(modified)
|
|
|
|
if parsed_line['mode']:
|
|
mode_change = (parsed_line['old_mode'], parsed_line['new_mode'])
|
|
|
|
# add change to model
|
|
model.addItem(
|
|
(
|
|
path,
|
|
DiffData(
|
|
file_type=file_type,
|
|
change_type=change_type,
|
|
changed_size=changed_size,
|
|
size=size,
|
|
mode_change=mode_change,
|
|
owner_change=owner_change,
|
|
modified=modified,
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
def size_to_byte(significand: str, unit: str) -> int:
|
|
"""Convert a size with a unit identifier from borg into a number of bytes."""
|
|
if unit == 'B':
|
|
return int(significand)
|
|
elif unit == 'kB' or unit == 'KB':
|
|
return int(float(significand) * 10**3)
|
|
elif unit == 'MB':
|
|
return int(float(significand) * 10**6)
|
|
elif unit == 'GB':
|
|
return int(float(significand) * 10**9)
|
|
elif unit == 'TB':
|
|
return int(float(significand) * 10**12)
|
|
else:
|
|
# unknown identifier
|
|
raise Exception("Unknown unit `{}`".format(unit))
|
|
|
|
|
|
# ---- Sorting ---------------------------------------------------------------
|
|
|
|
|
|
class DiffSortProxyModel(FileTreeSortProxyModel):
|
|
"""
|
|
Sort a DiffTree model.
|
|
"""
|
|
|
|
def choose_data(self, index: QModelIndex):
|
|
"""Choose the data of index used for comparison."""
|
|
item: DiffItem = index.internalPointer()
|
|
column = index.column()
|
|
|
|
if column == 0:
|
|
return self.extract_path(index)
|
|
elif column == 1:
|
|
# change type
|
|
ct = item.data.change_type
|
|
if ct == ChangeType.NONE:
|
|
return ChangeType.MODIFIED
|
|
return ct
|
|
elif column == 2:
|
|
return item.data.changed_size
|
|
else:
|
|
# size
|
|
return item.data.size
|
|
|
|
|
|
# ---- DiffTree --------------------------------------------------------------
|
|
|
|
|
|
class ChangeType(enum.Enum):
|
|
"""
|
|
The possible types of changes from `borg diff`.
|
|
|
|
modified - file contents changed.
|
|
added - the file was added.
|
|
removed - the file was removed.
|
|
added directory - the directory was added.
|
|
removed directory - the directory was removed.
|
|
added link - the symlink was added.
|
|
removed link - the symlink was removed.
|
|
changed link - the symlink target was changed.
|
|
mode - the file/directory/link mode was changed.
|
|
Note: this could indicate a change from a file/directory/link
|
|
type to a different type (file/directory/link),
|
|
such as - a file is deleted and replaced with
|
|
a directory of the same name.
|
|
owner - user and/or group ownership changed.
|
|
ctime - creation time changed.
|
|
mtime - modification time changed.
|
|
|
|
size:
|
|
If type == `added` or `removed`,
|
|
then size provides the size of the added or removed file.
|
|
added:
|
|
If type == `modified` and chunk ids can be compared,
|
|
then added and removed indicate the amount of
|
|
data `added` and `removed`. If chunk ids can not be compared,
|
|
then added and removed properties are not provided and
|
|
the only information available is that the file contents were modified.
|
|
removed:
|
|
See added property.
|
|
old_mode:
|
|
If type == `mode`, then old_mode and new_mode provide the mode
|
|
and permissions changes.
|
|
new_mode:
|
|
See old_mode property.
|
|
old_user:
|
|
If type == `owner`, then old_user, new_user, old_group
|
|
and new_group provide the user and group ownership changes.
|
|
old_group:
|
|
See old_user property.
|
|
new_user:
|
|
See old_user property.
|
|
new_group:
|
|
See old_user property.
|
|
old_ctime:
|
|
If type == `ctime`, then old_ctime and new_ctime provide creation time changes.
|
|
new_ctime:
|
|
See old_ctime property.
|
|
old_mtime:
|
|
If type == `mtime`, then old_mtime and new_mtime provide modification time changes.
|
|
new_mtime:
|
|
See old_mtime property.
|
|
"""
|
|
|
|
NONE = 0 # no change
|
|
MODIFIED = 2 # int for sorting
|
|
ADDED = 1
|
|
REMOVED = 3
|
|
ADDED_DIR = ADDED
|
|
REMOVED_DIR = REMOVED
|
|
ADDED_LINK = ADDED
|
|
REMOVED_LINK = REMOVED
|
|
CHANGED_LINK = MODIFIED
|
|
MODE = MODIFIED # changed permissions
|
|
OWNER = MODIFIED
|
|
CTIME = MODIFIED
|
|
MTIME = MODIFIED
|
|
|
|
def short(self):
|
|
"""Get a short identifier for the change type."""
|
|
if self == self.ADDED:
|
|
return 'A'
|
|
if self == self.REMOVED:
|
|
return 'D'
|
|
if self == self.MODIFIED:
|
|
return 'M'
|
|
return ''
|
|
|
|
def __ge__(self, other):
|
|
"""Greater than or equal for enums."""
|
|
if self.__class__ is other.__class__:
|
|
return other >= self
|
|
return NotImplemented
|
|
|
|
def __gt__(self, other):
|
|
"""Greater than for enums."""
|
|
if self.__class__ is other.__class__:
|
|
return other < self
|
|
return NotImplemented
|
|
|
|
def __le__(self, other):
|
|
"""Lower than or equal for enums."""
|
|
if self.__class__ is other.__class__:
|
|
return self.value == other.value or self < other
|
|
return NotImplemented
|
|
|
|
def __lt__(self, other):
|
|
"""Lower than for enums."""
|
|
if self.__class__ is other.__class__:
|
|
return self.value < other.value
|
|
return NotImplemented
|
|
|
|
|
|
class FileType(enum.Enum):
|
|
"""The possible file types of changed file."""
|
|
|
|
FILE = enum.auto()
|
|
DIRECTORY = enum.auto()
|
|
LINK = enum.auto()
|
|
CHRDEV = enum.auto()
|
|
BLKDEV = enum.auto()
|
|
FIFO = enum.auto()
|
|
|
|
|
|
@dataclass
|
|
class DiffData:
|
|
"""The data linked to a diff item."""
|
|
|
|
file_type: FileType
|
|
change_type: ChangeType
|
|
changed_size: int # total modified bits
|
|
size: int # size change (disk usage)
|
|
mode_change: Optional[Tuple[str, str]] = None
|
|
owner_change: Optional[Tuple[str, str, str, str]] = None
|
|
ctime_change: Optional[Tuple[QDateTime, QDateTime]] = None
|
|
mtime_change: Optional[Tuple[QDateTime, QDateTime]] = None
|
|
modified: Optional[Tuple[int, int]] = None
|
|
|
|
|
|
DiffItem = FileSystemItem[DiffData]
|
|
|
|
|
|
class DiffTree(FileTreeModel[DiffData]):
|
|
"""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 item.data.change_type != ChangeType.NONE
|
|
|
|
def _simplify_filter(self, item: DiffItem) -> bool:
|
|
"""
|
|
Return whether an item may be merged in simplified mode.
|
|
|
|
Allows simplification only for unchanged items.
|
|
"""
|
|
for child in item.children:
|
|
if child.data.change_type != ChangeType.NONE:
|
|
return False
|
|
|
|
if item.data.change_type == ChangeType.NONE:
|
|
return True
|
|
|
|
return False # otherwise the change is not displayed
|
|
|
|
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 = DiffData(FileType.DIRECTORY, ChangeType.NONE, 0, 0)
|
|
|
|
if child.data.size != 0 or child.data.changed_size != 0:
|
|
# update size
|
|
size = child.data.size
|
|
changed_size = child.data.changed_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
|
|
parent.data.changed_size += changed_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, change_type, changed bytes, size balance
|
|
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("Change")
|
|
elif section == 2:
|
|
return self.tr("Size")
|
|
elif section == 3:
|
|
return self.tr("Balance")
|
|
|
|
return None
|
|
|
|
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
|
"""
|
|
Get the data for the given role and index.
|
|
|
|
The indexes internal pointer references the corresponding
|
|
`FileSystemItem`.
|
|
|
|
Parameters
|
|
----------
|
|
index : QModelIndex
|
|
The index of the item.
|
|
role : int, optional
|
|
The data role, by default Qt.ItemDataRole.DisplayRole
|
|
|
|
Returns
|
|
-------
|
|
Any
|
|
The data, return None if no data is available for the role.
|
|
"""
|
|
if not index.isValid():
|
|
return None
|
|
|
|
item: DiffItem = 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:
|
|
# change type
|
|
return item.data.change_type.short()
|
|
elif column == 2:
|
|
return pretty_bytes(item.data.changed_size)
|
|
else:
|
|
# size
|
|
return pretty_bytes(item.data.size)
|
|
|
|
if role == Qt.ItemDataRole.ForegroundRole:
|
|
# colour
|
|
if item.data.change_type == ChangeType.ADDED:
|
|
return QColor(Qt.GlobalColor.green) if uses_dark_mode() else QColor(Qt.GlobalColor.darkGreen)
|
|
if item.data.change_type == ChangeType.MODIFIED:
|
|
return QColor(Qt.GlobalColor.yellow) if uses_dark_mode() else QColor(Qt.GlobalColor.darkYellow)
|
|
if item.data.change_type == ChangeType.REMOVED:
|
|
return QColor(Qt.GlobalColor.red) if uses_dark_mode() else QColor(Qt.GlobalColor.darkRed)
|
|
return None # no change
|
|
|
|
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} {changetype}"
|
|
|
|
modified_template = self.tr("Added {}, deleted {}")
|
|
owner_template = "{: <10} -> {: >10}"
|
|
time_template = "{}: {} -> {}"
|
|
permission_template = "{} -> {}"
|
|
|
|
# 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("Link")
|
|
elif item.data.file_type == FileType.BLKDEV:
|
|
filetype = self.tr("Block device file")
|
|
elif item.data.file_type == FileType.CHRDEV:
|
|
filetype = self.tr("Character device file")
|
|
else:
|
|
raise Exception("Unknown filetype {}".format(item.data.file_type))
|
|
|
|
if item.data.change_type == ChangeType.NONE:
|
|
changetype = self.tr("unchanged")
|
|
elif item.data.change_type == ChangeType.MODIFIED:
|
|
changetype = self.tr("modified")
|
|
elif item.data.change_type == ChangeType.REMOVED:
|
|
changetype = self.tr("removed")
|
|
elif item.data.change_type == ChangeType.ADDED:
|
|
changetype = self.tr("added")
|
|
else:
|
|
raise Exception("Unknown changetype {}".format(item.data.change_type))
|
|
|
|
tooltip = tooltip_template.format(name=item.path[-1], filetype=filetype, changetype=changetype)
|
|
if item.data.modified:
|
|
tooltip += '\n'
|
|
tooltip += modified_template.format(
|
|
pretty_bytes(item.data.modified[0]),
|
|
pretty_bytes(item.data.modified[1]),
|
|
)
|
|
|
|
if item.data.mode_change:
|
|
tooltip += '\n'
|
|
tooltip += permission_template.format(*item.data.mode_change)
|
|
|
|
if item.data.owner_change:
|
|
tooltip += '\n'
|
|
tooltip += owner_template.format(
|
|
'{}:{}'.format(item.data.owner_change[0], item.data.owner_change[1]),
|
|
"{}:{}".format(item.data.owner_change[2], item.data.owner_change[3]),
|
|
)
|
|
|
|
if item.data.ctime_change:
|
|
tooltip += '\n'
|
|
tooltip += time_template.format(
|
|
"Creation Time",
|
|
QLocale.system().toString(item.data.ctime_change[0], QLocale.FormatType.ShortFormat),
|
|
QLocale.system().toString(item.data.ctime_change[1], QLocale.FormatType.ShortFormat),
|
|
)
|
|
|
|
if item.data.mtime_change:
|
|
tooltip += '\n'
|
|
tooltip += time_template.format(
|
|
"Modification Time",
|
|
QLocale.system().toString(item.data.mtime_change[0], QLocale.FormatType.ShortFormat),
|
|
QLocale.system().toString(item.data.mtime_change[1], QLocale.FormatType.ShortFormat),
|
|
)
|
|
|
|
return tooltip
|