GUI: Highly improve restore window memory usage

This commit is contained in:
deajan 2024-09-15 10:06:35 +02:00
parent 65d4559bbd
commit 410121e2cf
8 changed files with 166 additions and 47 deletions

View file

@ -7,7 +7,7 @@ __intname__ = "npbackup.gui.core.runner"
__author__ = "Orsiris de Jong" __author__ = "Orsiris de Jong"
__copyright__ = "Copyright (C) 2022-2024 NetInvent" __copyright__ = "Copyright (C) 2022-2024 NetInvent"
__license__ = "GPL-3.0-only" __license__ = "GPL-3.0-only"
__build__ = "2024061101" __build__ = "2024091501"
from typing import Optional, Callable, Union, List from typing import Optional, Callable, Union, List
@ -220,6 +220,9 @@ class NPBackupRunner:
self._verbose = False self._verbose = False
self._live_output = False self._live_output = False
self._json_output = False self._json_output = False
# struct_output is msgspec.Struct instead of json, which is less memory consuming
# struct_output neeeds json_output to be True
self._struct_output = False
self._binary = None self._binary = None
self._no_cache = False self._no_cache = False
self.restic_runner = None self.restic_runner = None
@ -300,6 +303,17 @@ class NPBackupRunner:
self.write_logs(msg, level="critical", raise_error="ValueError") self.write_logs(msg, level="critical", raise_error="ValueError")
self._json_output = value self._json_output = value
@property
def struct_output(self):
return self._struct_output
@struct_output.setter
def struct_output(self, value):
if not isinstance(value, bool):
msg = f"Bogus struct_output parameter given: {value}"
self.write_logs(msg, level="critical", raise_error="ValueError")
self._struct_output = value
@property @property
def stdout(self): def stdout(self):
return self._stdout return self._stdout
@ -409,7 +423,7 @@ class NPBackupRunner:
f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info" f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info"
) )
try: try:
os.environ['NPBACKUP_EXEC_TIME'] = str(self.exec_time) os.environ["NPBACKUP_EXEC_TIME"] = str(self.exec_time)
except OSError: except OSError:
pass pass
return result return result
@ -826,6 +840,7 @@ class NPBackupRunner:
self.restic_runner.no_cache = self.no_cache self.restic_runner.no_cache = self.no_cache
self.restic_runner.live_output = self.live_output self.restic_runner.live_output = self.live_output
self.restic_runner.json_output = self.json_output self.restic_runner.json_output = self.json_output
self.restic_runner.struct_output = self.struct_output
self.restic_runner.stdout = self.stdout self.restic_runner.stdout = self.stdout
self.restic_runner.stderr = self.stderr self.restic_runner.stderr = self.stderr
if self.binary: if self.binary:

View file

@ -13,6 +13,7 @@ from typing import List, Optional, Tuple
import sys import sys
import os import os
import re import re
import gc
from argparse import ArgumentParser from argparse import ArgumentParser
from pathlib import Path from pathlib import Path
from logging import getLogger from logging import getLogger
@ -41,6 +42,8 @@ from resources.customization import (
LOADER_ANIMATION, LOADER_ANIMATION,
FOLDER_ICON, FOLDER_ICON,
FILE_ICON, FILE_ICON,
SYMLINK_ICON,
IRREGULAR_FILE_ICON,
LICENSE_TEXT, LICENSE_TEXT,
SIMPLEGUI_THEME, SIMPLEGUI_THEME,
OEM_ICON, OEM_ICON,
@ -55,6 +58,7 @@ from npbackup.path_helper import CURRENT_DIR
from npbackup.__version__ import version_string from npbackup.__version__ import version_string
from npbackup.__debug__ import _DEBUG from npbackup.__debug__ import _DEBUG
from npbackup.restic_wrapper import ResticRunner from npbackup.restic_wrapper import ResticRunner
from npbackup.restic_wrapper import schema
logger = getLogger() logger = getLogger()
@ -183,39 +187,60 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData:
{'name': 'unsupported.tcl', 'type': 'file', 'path': '/C/GIT/npbackup/npbackup.dist/tk/unsupported.tcl', 'uid': 0, 'gid': 0, 'size': 10521, 'mode': 438, 'permissions': '-rw-rw-rw-', 'mtime': '2022-09-05T14:18:52+02:00', 'atime': '2022-09-05T14:18:52+02:00', 'ctime': '2022-09-05T14:18:52+02:00', 'struct_type': 'node'} {'name': 'unsupported.tcl', 'type': 'file', 'path': '/C/GIT/npbackup/npbackup.dist/tk/unsupported.tcl', 'uid': 0, 'gid': 0, 'size': 10521, 'mode': 438, 'permissions': '-rw-rw-rw-', 'mtime': '2022-09-05T14:18:52+02:00', 'atime': '2022-09-05T14:18:52+02:00', 'ctime': '2022-09-05T14:18:52+02:00', 'struct_type': 'node'}
{'name': 'xmfbox.tcl', 'type': 'file', 'path': '/C/GIT/npbackup/npbackup.dist/tk/xmfbox.tcl', 'uid': 0, 'gid': 0, 'size': 27064, 'mode': 438, 'permissions': '-rw-rw-rw-', 'mtime': '2022-09-05T14:18:52+02:00', 'atime': '2022-09-05T14:18:52+02:00', 'ctime': '2022-09-05T14:18:52+02:00', 'struct_type': 'node'} {'name': 'xmfbox.tcl', 'type': 'file', 'path': '/C/GIT/npbackup/npbackup.dist/tk/xmfbox.tcl', 'uid': 0, 'gid': 0, 'size': 27064, 'mode': 438, 'permissions': '-rw-rw-rw-', 'mtime': '2022-09-05T14:18:52+02:00', 'atime': '2022-09-05T14:18:52+02:00', 'ctime': '2022-09-05T14:18:52+02:00', 'struct_type': 'node'}
] ]
Since v3-rc6, we're actually using a msgspec.Struct represenation which uses dot notation
""" """
treedata = sg.TreeData() treedata = sg.TreeData()
count = 0 count = 0
for entry in ls_result: for entry in ls_result:
# Make sure we drop the prefix '/' so sg.TreeData does not get an empty root # Make sure we drop the prefix '/' so sg.TreeData does not get an empty root
entry["path"] = entry["path"].lstrip("/")
entry.path = entry.path.lstrip("/")
if os.name == "nt": if os.name == "nt":
# On windows, we need to make sure tree keys don't get duplicate because of lower/uppercase # On windows, we need to make sure tree keys don't get duplicate because of lower/uppercase
# Shown filenames aren't affected by this # Shown filenames aren't affected by this
entry["path"] = entry["path"].lower() entry.path = entry.path.lower()
parent = os.path.dirname(entry["path"]) parent = os.path.dirname(entry.path)
# Make sure we normalize mtime, and remove microseconds # Make sure we normalize mtime, and remove microseconds
# dateutil.parser.parse is *really* cpu hungry, let's replace it with a dumb alternative # dateutil.parser.parse is *really* cpu hungry, let's replace it with a dumb alternative
# mtime = dateutil.parser.parse(entry["mtime"]).strftime("%Y-%m-%d %H:%M:%S") # mtime = dateutil.parser.parse(entry["mtime"]).strftime("%Y-%m-%d %H:%M:%S")
mtime = str(entry["mtime"])[0:19] mtime = entry.mtime.strftime("%Y-%m-%d %H:%M:%S")
if entry["type"] == "dir" and entry["path"] not in treedata.tree_dict: name = os.path.basename(entry.path)
if entry.type == schema.LsNodeType.DIR and entry.path not in treedata.tree_dict:
treedata.Insert( treedata.Insert(
parent=parent, parent=parent,
key=entry["path"], key=entry.path,
text=entry["name"], text=name,
values=["", mtime], values=["", mtime],
icon=FOLDER_ICON, icon=FOLDER_ICON,
) )
elif entry["type"] == "file": elif entry.type == schema.LsNodeType.FILE:
size = BytesConverter(entry["size"]).human size = BytesConverter(entry.size).human
treedata.Insert( treedata.Insert(
parent=parent, parent=parent,
key=entry["path"], key=entry.path,
text=entry["name"], text=name,
values=[size, mtime], values=[size, mtime],
icon=FILE_ICON, icon=FILE_ICON,
) )
elif entry.type == schema.LsNodeType.SYMLINK:
treedata.Insert(
parent=parent,
key=entry.path,
text=name,
values=["", mtime],
icon=SYMLINK_ICON,
)
elif entry.type == schema.LsNodeType.IRREGULAR:
treedata.Insert(
parent=parent,
key=entry.path,
text=name,
values=["", mtime],
icon=IRREGULAR_FILE_ICON,
)
# Since the thread is heavily CPU bound, let's add a minimal # Since the thread is heavily CPU bound, let's add a minimal
# arbitrary sleep time to let GUI update # arbitrary sleep time to let GUI update
# In a 130k entry scenario, using count % 1000 added less than a second on a 25 second run # In a 130k entry scenario, using count % 1000 added less than a second on a 25 second run
@ -237,12 +262,13 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
__backend_binary=backend_binary, __backend_binary=backend_binary,
) )
if not result or not result["result"]: if not result or not result["result"]:
sg.Popup("main_gui.snapshot_is_empty") sg.Popup(_t("main_gui.snapshot_is_empty"))
return None, None return None, None
# result is {"result": True, "output": [{snapshot_description}, {entry}, {entry}]} # result is {"result": True, "output": [{snapshot_description}, {entry}, {entry}]}
content = result["output"] # content = result["output"]
# First entry of snapshot list is the snapshot description # First entry of snapshot list is the snapshot description
snapshot = content.pop(0) snapshot = result["output"].pop(0)
try: try:
snap_date = dateutil.parser.parse(snapshot["time"]) snap_date = dateutil.parser.parse(snapshot["time"])
except (KeyError, IndexError, TypeError): except (KeyError, IndexError, TypeError):
@ -261,7 +287,6 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
hostname = "[inconnu]" hostname = "[inconnu]"
backup_id = f"{_t('main_gui.backup_content_from')} {snap_date} {_t('main_gui.run_as')} {username}@{hostname} {_t('main_gui.identified_by')} {short_id}" backup_id = f"{_t('main_gui.backup_content_from')} {snap_date} {_t('main_gui.run_as')} {username}@{hostname} {_t('main_gui.identified_by')} {short_id}"
if not backup_id or not snapshot or not short_id: if not backup_id or not snapshot or not short_id:
sg.PopupError(_t("main_gui.cannot_get_content"), keep_on_top=True) sg.PopupError(_t("main_gui.cannot_get_content"), keep_on_top=True)
return False return False
@ -272,7 +297,8 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
# We get a thread result, hence pylint will complain the thread isn't a tuple # We get a thread result, hence pylint will complain the thread isn't a tuple
# pylint: disable=E1101 (no-member) # pylint: disable=E1101 (no-member)
thread = _make_treedata_from_json(content)
thread = _make_treedata_from_json(result["output"])
while not thread.done() and not thread.cancelled(): while not thread.done() and not thread.cancelled():
sg.PopupAnimated( sg.PopupAnimated(
LOADER_ANIMATION, LOADER_ANIMATION,
@ -283,6 +309,8 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
) )
sg.PopupAnimated(None) sg.PopupAnimated(None)
logger.info("Finished creating data tree")
left_col = [ left_col = [
[sg.Text(backup_id)], [sg.Text(backup_id)],
[ [
@ -316,8 +344,11 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
enable_close_attempted_event=True, enable_close_attempted_event=True,
) )
# Reclaim memory fro thread result # Reclaim memory from thread result
thread = None # Note from v3 dev: This doesn't actually improve memory usage
del thread
del result
gc.collect()
while True: while True:
event, values = window.read() event, values = window.read()
@ -334,6 +365,7 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool:
# before closing the window # before closing the window
window["-TREE-"].update(values=sg.TreeData()) window["-TREE-"].update(values=sg.TreeData())
window.close() window.close()
del window
return True return True
@ -966,6 +998,7 @@ def _main_gui(viewer_mode: bool):
continue continue
snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0] snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0]
ls_window(repo_config, snapshot_to_see) ls_window(repo_config, snapshot_to_see)
gc.collect()
if event == "--FORGET--": if event == "--FORGET--":
if not full_config: if not full_config:
sg.PopupError(_t("main_gui.no_config"), keep_on_top=True) sg.PopupError(_t("main_gui.no_config"), keep_on_top=True)

View file

@ -127,6 +127,10 @@ def gui_thread_runner(
# We'll always use json output in GUI mode # We'll always use json output in GUI mode
runner.json_output = True runner.json_output = True
# in GUI mode, we'll use struct output instead of json whenever it's possible
# as of v3, this only is needed for ls operations
runner.struct_output = True
# So we don't always init repo_config, since runner.group_runner would do that itself # So we don't always init repo_config, since runner.group_runner would do that itself
if __repo_config: if __repo_config:
runner.repo_config = __repo_config runner.repo_config = __repo_config

View file

@ -7,8 +7,8 @@ __intname__ = "npbackup.restic_wrapper"
__author__ = "Orsiris de Jong" __author__ = "Orsiris de Jong"
__copyright__ = "Copyright (C) 2022-2024 NetInvent" __copyright__ = "Copyright (C) 2022-2024 NetInvent"
__license__ = "GPL-3.0-only" __license__ = "GPL-3.0-only"
__build__ = "2024090602" __build__ = "2024091501"
__version__ = "2.2.4" __version__ = "2.3.0"
from typing import Tuple, List, Optional, Callable, Union from typing import Tuple, List, Optional, Callable, Union
@ -16,7 +16,7 @@ import os
import sys import sys
from logging import getLogger from logging import getLogger
import re import re
import json import msgspec
from datetime import datetime, timezone from datetime import datetime, timezone
import dateutil.parser import dateutil.parser
import queue import queue
@ -26,7 +26,7 @@ from ofunctions.misc import BytesConverter, fn_name
from npbackup.__debug__ import _DEBUG from npbackup.__debug__ import _DEBUG
from npbackup.__env__ import FAST_COMMANDS_TIMEOUT, CHECK_INTERVAL from npbackup.__env__ import FAST_COMMANDS_TIMEOUT, CHECK_INTERVAL
from npbackup.path_helper import CURRENT_DIR from npbackup.path_helper import CURRENT_DIR
from npbackup.restic_wrapper import schema
logger = getLogger() logger = getLogger()
@ -48,6 +48,7 @@ class ResticRunner:
self._dry_run = False self._dry_run = False
self._no_cache = False self._no_cache = False
self._json_output = False self._json_output = False
self._struct_output = False
self.backup_result_content = None self.backup_result_content = None
@ -222,6 +223,17 @@ class ResticRunner:
else: else:
raise ValueError("Bogus json_output value given") raise ValueError("Bogus json_output value given")
@property
def struct_output(self) -> bool:
return self._struct_output
@struct_output.setter
def struct_output(self, value: bool):
if isinstance(value, bool):
self._struct_output = value
else:
raise ValueError("Bogus struct_output value given")
@property @property
def ignore_cloud_files(self) -> bool: def ignore_cloud_files(self) -> bool:
return self._ignore_cloud_files return self._ignore_cloud_files
@ -636,6 +648,10 @@ class ResticRunner:
return self.convert_to_json_output(False, output=msg) return self.convert_to_json_output(False, output=msg)
else: else:
# pylint: disable=E1101 (no-member) # pylint: disable=E1101 (no-member)
output = output.replace(
self.repository,
self.repository.split(":")[0] + ":_(o_O)_hidden_by_npbackup",
)
msg = f"Backend is not ready to perform operation {fn.__name__}. Repo maybe inaccessible or not initialized. You can try to run a backup to initialize the repository:\n{output}." # pylint: disable=E1101 (no-member) msg = f"Backend is not ready to perform operation {fn.__name__}. Repo maybe inaccessible or not initialized. You can try to run a backup to initialize the repository:\n{output}." # pylint: disable=E1101 (no-member)
return self.convert_to_json_output(False, msg=msg) return self.convert_to_json_output(False, msg=msg)
# pylint: disable=E1102 (not-callable) # pylint: disable=E1102 (not-callable)
@ -658,31 +674,40 @@ class ResticRunner:
js = { js = {
"result": result, "result": result,
"operation": operation, "operation": operation,
"extended_info": None,
"args": kwargs, "args": kwargs,
"output": None, "output": [],
} }
if result: if result:
if output: if output:
if isinstance(output, str): decoder = msgspec.json.Decoder()
output = list(filter(None, output.split("\n"))) ls_decoder = msgspec.json.Decoder(schema.LsNode)
else: is_first_line = True
output = [str(output)]
if len(output) > 1: for line in output.split("\n"):
output_is_list = True if not line:
js["output"] = [] continue
else: try:
output_is_list = False if (
for line in output: not is_first_line
if output_is_list: and operation == "ls"
try: and self.struct_output
js["output"].append(json.loads(line)) ):
except json.decoder.JSONDecodeError:
js["output"].append(line) js["output"].append(ls_decoder.decode(line))
else: else:
try: js["output"].append(decoder.decode(line))
js["output"] = json.loads(line) is_first_line = False
except json.decoder.JSONDecodeError: except msgspec.DecodeError as exc:
js["output"] = {"data": line} msg = f"JSON decode error: {exc} on content '{line}'"
self.write_logs(msg, level="error")
js["extended_info"] = msg
js["output"].append({"data": line})
js["result"] = False
# If we only have one output, we don't need a list
if len(js["output"]) == 1:
js["output"] = js["output"][0]
if msg: if msg:
self.write_logs(msg, level="info") self.write_logs(msg, level="info")
else: else:
@ -691,8 +716,11 @@ class ResticRunner:
self.write_logs(msg, level="error") self.write_logs(msg, level="error")
if output: if output:
try: try:
js["output"] = json.loads(output) js["output"] = msgspec.json.decode(output)
except json.decoder.JSONDecodeError: except msgspec.DecodeError:
msg = f"JSON decode error: {exc} on output '{output}'"
self.write_logs(msg, level="error")
js["extended_info"] = msg
js["output"] = {"data": output} js["output"] = {"data": output}
return js return js

View file

@ -0,0 +1,37 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# This file is part of npbackup
__intname__ = "npbackup.restic_wrapper.schema"
__author__ = "Orsiris de Jong"
__copyright__ = "Copyright (C) 2024 NetInvent"
__license__ = "GPL-3.0-only"
__description__ = "Restic json output schemas"
from typing import Optional
from enum import StrEnum
from datetime import datetime
from msgspec import Struct
class LsNodeType(StrEnum):
FILE = "file"
DIR = "dir"
SYMLINK = "symlink"
IRREGULAR = "irregular"
class LsNode(Struct, omit_defaults=True):
"""
restic ls outputs lines of
{"name": "b458b848.2024-04-28-13h07.gz", "type": "file", "path": "/path/b458b848.2024-04-28-13h07.gz", "uid": 0, "gid": 0, "size": 82638431, "mode": 438, "permissions": "-rw-rw-rw-", "mtime": "2024-04-29T10:32:18+02:00", "atime": "2024-04-29T10:32:18+02:00", "ctime": "2024-04-29T10:32:18+02:00", "message_type": "node", "struct_type": "node"}
# In order to save some memory in GUI, let's drop unused data
"""
# name: str # We don't need name, we have path from which we extract name, which is more memory efficient
type: LsNodeType
path: str
mtime: datetime
size: Optional[int] = None

View file

@ -50,6 +50,8 @@ OEM_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAAXNSR0IArs4c6QAAAAR
FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII=" FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII="
INHERITED_FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJCSURBVDhPxVJNaBNhEH3bJPSHtCWtpRZbU4shJQoigi2IOfTQ2F5EUUEP2oNa0N5E8eBJUEHUg0IxGPAg6EGKeigo9CBSbEuVLNFkIWot1KYxzV/TNt1sdvdzvk3MehARPPhgdub7dt7bN8PiXyGUM0aO2HosVRjgtaywRf9z9REv+flPqAgk3vqUlu7TNmgZFDMi7o8GxqR5/TV16AJvq3QCs5IWmpX0KSq1ynV6xsccLR6wVBRyZg5yYRUqK0DQFDBdIb5WEqkCxmcQGbqh+Oj0rSIQG+9g9c17YW31orqxC5PTYbyceI9Pc0twuzpw6ngftjvrgeIC4pEXelvf5DGijZEecP6Qdahu6wnYPedQ0+TG9duP4R28DHdXAy6c2Y12x3fs6B0GS9wDlgNgcoTTavnDEBAYOhvbewE1CzEYxJVrTzA/PYKTgwX0uD5j+LAGWdxD46yAsSLAOKsEQ8CAmqRIIfwxhH7vFjg3LdGKVhCOZihyECOrxKM9QKcwFazlTLMt07scErEI2ppJV5fx6k0MNx98RTqnwLnZimd3W4mrUjMXKcF0QMth+Q/o3maBKGWpcQP9++yYeOjG2aMO6EynUbl97sJ0YArIUbKcw8B+O6ptDLcCX4iwYThZW1eoQaf5KRsiv3OgpomQpw+s4+mdTkwFsxA877DzoISro0nscllIg5MpfhmhsoPkYrxcCagji/5LtfBfrKH5i2hqoGuynoqTIyLn10yBnz+SneJAOf8N+BKCFCHj9B8B/ACq5vDyyzK0kwAAAABJRU5ErkJggg==" INHERITED_FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJCSURBVDhPxVJNaBNhEH3bJPSHtCWtpRZbU4shJQoigi2IOfTQ2F5EUUEP2oNa0N5E8eBJUEHUg0IxGPAg6EGKeigo9CBSbEuVLNFkIWot1KYxzV/TNt1sdvdzvk3MehARPPhgdub7dt7bN8PiXyGUM0aO2HosVRjgtaywRf9z9REv+flPqAgk3vqUlu7TNmgZFDMi7o8GxqR5/TV16AJvq3QCs5IWmpX0KSq1ynV6xsccLR6wVBRyZg5yYRUqK0DQFDBdIb5WEqkCxmcQGbqh+Oj0rSIQG+9g9c17YW31orqxC5PTYbyceI9Pc0twuzpw6ngftjvrgeIC4pEXelvf5DGijZEecP6Qdahu6wnYPedQ0+TG9duP4R28DHdXAy6c2Y12x3fs6B0GS9wDlgNgcoTTavnDEBAYOhvbewE1CzEYxJVrTzA/PYKTgwX0uD5j+LAGWdxD46yAsSLAOKsEQ8CAmqRIIfwxhH7vFjg3LdGKVhCOZihyECOrxKM9QKcwFazlTLMt07scErEI2ppJV5fx6k0MNx98RTqnwLnZimd3W4mrUjMXKcF0QMth+Q/o3maBKGWpcQP9++yYeOjG2aMO6EynUbl97sJ0YArIUbKcw8B+O6ptDLcCX4iwYThZW1eoQaf5KRsiv3OgpomQpw+s4+mdTkwFsxA877DzoISro0nscllIg5MpfhmhsoPkYrxcCagji/5LtfBfrKH5i2hqoGuynoqTIyLn10yBnz+SneJAOf8N+BKCFCHj9B8B/ACq5vDyyzK0kwAAAABJRU5ErkJggg=="
FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC" FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC"
SYMLINK_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAwUExURf///0lJSSgoKNDQ0AAAAMfHxxoaGsTExNfX1/b29hcXFy8vL+zs7MHBwSsrK+jo6JAyYacAAAAJcEhZcwAACxEAAAsRAX9kX5EAAABXSURBVBjTY8AChJSUlAxADBUXF5cAMMOtomMCmLFkDwNnMIhxz/sAhxOI0f9kD4TR8M/7BITB/2Q7iGE6geGfG4jBwNnRuwTMYAWaCGYwA+1QxLCZgQEAN90WKol5c8kAAAAASUVORK5CYII="
IRREGULAR_FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAwUExURQAAABISEjg4OEBAQF5eXmdnZ2lpaXl5eYODg46OjpWVlaWlpbGxsdPT0/X19f///y10hqsAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAA1SURBVBjTY/gPBdgZv/KgjI/cUMbPOAjjR3kthPGZgQcq9YUw49citrVgxgcGBi5MS///BwC5AXGMCId5ZAAAAABJRU5ErkJggg=="
INHERITED_FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJFSURBVDhPjVNNaBNREP6y2cRo29QfGmJNayBBWxWr+I/gP9VYxFLUnITVFgqCF8GLFyuKeND6g/UkSA8qBC9KDxYVUQQx1otNQ2q1VAWtUcMWQlvT7K4zb7NpdAv6wbe8mfdm5pt5bx0g7Gpp6yCchsHWX+ATBX/i3cjAqWOHDmS+fxm0vBJ/DN1AwFeByVweanYcP8ey01Sz+JYZQ7lHwtHmTcvPXO7umVe1sJ7CRHGZP7qhwyXLmOstI4v5JyZ/5eCg4y5ZQnt0R2gqf+vBCWXPdtr6XFQg0QGn0zEjZWKalPR/GEXqYxqrVywNUdhmjhUKNF2HRBlkp8gn8Cb+Ei+ePcankWHUBkPYu/8gKfQin9eEYoKHPyJCpwROSuCiBMybXZ1Qok0Ih5egrf04AoEaRPdtRcUcDyrLZ0PTRAIBU4GmCQXcYzLxFlcvnsPzeAKLArXi0Jp163FYaRVrrskFLUwroClx9eH3KWzZthPBxUHTHkoJDib7iwp1XRPBDPMWSAG34HZJUDM/4Pf7xfrpk0foutYJVVVJTQ26b8dEUGkLQoE1RM5eV1eP5IBZrbFxN+739EI50kqXbpQomKEFawaRSASz3G7cuH5F2MyJiXG6ZnOfyTOzUFBgtsDXyLxzN4a++Ct4y9zYsHYVLpw/i5UNDcX9UgUCyzY2dVBfRm5Ks/HraNrmiz18zf+BwrGFFgx6qvzqJBt9viqbzz4Deln81qmL/2JpAjKBBdWhyPzq8ElaVhJz7PsXhvp6LwG49xvFxfylO2UHaAAAAABJRU5ErkJggg==" INHERITED_FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJFSURBVDhPjVNNaBNREP6y2cRo29QfGmJNayBBWxWr+I/gP9VYxFLUnITVFgqCF8GLFyuKeND6g/UkSA8qBC9KDxYVUQQx1otNQ2q1VAWtUcMWQlvT7K4zb7NpdAv6wbe8mfdm5pt5bx0g7Gpp6yCchsHWX+ATBX/i3cjAqWOHDmS+fxm0vBJ/DN1AwFeByVweanYcP8ey01Sz+JYZQ7lHwtHmTcvPXO7umVe1sJ7CRHGZP7qhwyXLmOstI4v5JyZ/5eCg4y5ZQnt0R2gqf+vBCWXPdtr6XFQg0QGn0zEjZWKalPR/GEXqYxqrVywNUdhmjhUKNF2HRBlkp8gn8Cb+Ei+ePcankWHUBkPYu/8gKfQin9eEYoKHPyJCpwROSuCiBMybXZ1Qok0Ih5egrf04AoEaRPdtRcUcDyrLZ0PTRAIBU4GmCQXcYzLxFlcvnsPzeAKLArXi0Jp163FYaRVrrskFLUwroClx9eH3KWzZthPBxUHTHkoJDib7iwp1XRPBDPMWSAG34HZJUDM/4Pf7xfrpk0foutYJVVVJTQ26b8dEUGkLQoE1RM5eV1eP5IBZrbFxN+739EI50kqXbpQomKEFawaRSASz3G7cuH5F2MyJiXG6ZnOfyTOzUFBgtsDXyLxzN4a++Ct4y9zYsHYVLpw/i5UNDcX9UgUCyzY2dVBfRm5Ks/HraNrmiz18zf+BwrGFFgx6qvzqJBt9viqbzz4Deln81qmL/2JpAjKBBdWhyPzq8ElaVhJz7PsXhvp6LwG49xvFxfylO2UHaAAAAABJRU5ErkJggg=="
TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAEtSURBVDhPjZNdTsJAFIUPbalQUSEQNMQ16AJcga++uBJe9AE2wE5civvwzUDQhIClnjMz5Wc6gd7km85M7px7ettpgPH4Ukxfp5ig0MoLZZT7JtvG20PDrCINxRboD4HNGvj9AZaLA+bA/Hs/L2HsBbaskCTA5TXQG1Ds9phuHxjeAVddi4t7DUYAdCBDURwmJgtWzTKLiycN1oEEOAsdFs3Uutv8AasV0GrrFFoadgIRZwmTQ6QXQJuVRdZhfq5TNqwANyQQsw/nkBs1vQwjoI2IPTAVmeQ78KkKOAdJk0hAzxPk/ivkronqdh3CryAHql6DahOdQOgThgj2QD9SyG4IFSxj5+DUn+hTdVDwMlBAInU4FOCSF2T0/twZTcac3hDeyfPx9ZnOAHz8A2r8W8iBwGS0AAAAAElFTkSuQmCC" TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAEtSURBVDhPjZNdTsJAFIUPbalQUSEQNMQ16AJcga++uBJe9AE2wE5civvwzUDQhIClnjMz5Wc6gd7km85M7px7ettpgPH4Ukxfp5ig0MoLZZT7JtvG20PDrCINxRboD4HNGvj9AZaLA+bA/Hs/L2HsBbaskCTA5TXQG1Ds9phuHxjeAVddi4t7DUYAdCBDURwmJgtWzTKLiycN1oEEOAsdFs3Uutv8AasV0GrrFFoadgIRZwmTQ6QXQJuVRdZhfq5TNqwANyQQsw/nkBs1vQwjoI2IPTAVmeQ78KkKOAdJk0hAzxPk/ivkronqdh3CryAHql6DahOdQOgThgj2QD9SyG4IFSxj5+DUn+hTdVDwMlBAInU4FOCSF2T0/twZTcac3hDeyfPx9ZnOAHz8A2r8W8iBwGS0AAAAAElFTkSuQmCC"
INHERITED_TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAIKSURBVDhPjVPPS1RRFP7mzTj425GXM5MNjpHgQNKEP3AhWEE0zCKCNoKbapO7IETBH6BBtYiZTeqfIC5UcCGooItoEQxBRU0tNBgYUBLHRnQQtDfPc857b3w1E/XBdznnvnO/+91z33WAEL6vT/ZNYgI6Z3+AK6x5qTYwes0hmcKDngdUL3B6AuSOgMMDG7NAdv88tkg4F8jTDi4XUFUL1F8gMd/v9KiA1w/UeAyaCPAgAiAHbEhxlqaTeEC7VlYaNNHDA+1L61mApLjYwvfEW3x7s4q91CYamlvRfu8BOWyB9gsor5CSch6MI5CAQpGLBJjrMy8w1deLiy2tuD0wCDUQQPxuGyrYQTXVa7zKgOGAJljASVn6y0csx8bxPJGihUEputLVjRsPByRmk9x0C+KAJxTqAe/+YyuJqzcj8AWDRr6ZFG5//VBwaBcQB7rpwFVG17i/C4/fL/HnjTWsvH6FXPYnuWnCk9klWaTZjiAONLOJ3O1LoRDSyU8SX78TwcjyBm49ekxVuswxSx+BHZCfjmgUZW43VqdjkjNPj4/kmq3c3kQRsG7Buvenc/PYSrxDf5UDw51tWHz5DM3hcOF7yR7wDmyP4bvchKGFRYkPMxnUqPQr2sAbWig4+NufWOdVi+aKe6DTYyABFvkf2gUopQfSOBatbpwYorCOSG/y39h5744DWDgDprSgrrE6IGUAAAAASUVORK5CYII=" INHERITED_TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAIKSURBVDhPjVPPS1RRFP7mzTj425GXM5MNjpHgQNKEP3AhWEE0zCKCNoKbapO7IETBH6BBtYiZTeqfIC5UcCGooItoEQxBRU0tNBgYUBLHRnQQtDfPc857b3w1E/XBdznnvnO/+91z33WAEL6vT/ZNYgI6Z3+AK6x5qTYwes0hmcKDngdUL3B6AuSOgMMDG7NAdv88tkg4F8jTDi4XUFUL1F8gMd/v9KiA1w/UeAyaCPAgAiAHbEhxlqaTeEC7VlYaNNHDA+1L61mApLjYwvfEW3x7s4q91CYamlvRfu8BOWyB9gsor5CSch6MI5CAQpGLBJjrMy8w1deLiy2tuD0wCDUQQPxuGyrYQTXVa7zKgOGAJljASVn6y0csx8bxPJGihUEputLVjRsPByRmk9x0C+KAJxTqAe/+YyuJqzcj8AWDRr6ZFG5//VBwaBcQB7rpwFVG17i/C4/fL/HnjTWsvH6FXPYnuWnCk9klWaTZjiAONLOJ3O1LoRDSyU8SX78TwcjyBm49ekxVuswxSx+BHZCfjmgUZW43VqdjkjNPj4/kmq3c3kQRsG7Buvenc/PYSrxDf5UDw51tWHz5DM3hcOF7yR7wDmyP4bvchKGFRYkPMxnUqPQr2sAbWig4+NufWOdVi+aKe6DTYyABFvkf2gUopQfSOBatbpwYorCOSG/y39h5744DWDgDprSgrrE6IGUAAAAASUVORK5CYII="

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

BIN
resources/symlink_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B