diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 736888e..c4b7236 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -7,7 +7,7 @@ __intname__ = "npbackup.gui.core.runner" __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024061101" +__build__ = "2024091501" from typing import Optional, Callable, Union, List @@ -220,6 +220,9 @@ class NPBackupRunner: self._verbose = False self._live_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._no_cache = False self.restic_runner = None @@ -300,6 +303,17 @@ class NPBackupRunner: self.write_logs(msg, level="critical", raise_error="ValueError") 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 def stdout(self): return self._stdout @@ -409,7 +423,7 @@ class NPBackupRunner: f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info" ) try: - os.environ['NPBACKUP_EXEC_TIME'] = str(self.exec_time) + os.environ["NPBACKUP_EXEC_TIME"] = str(self.exec_time) except OSError: pass return result @@ -826,6 +840,7 @@ class NPBackupRunner: self.restic_runner.no_cache = self.no_cache self.restic_runner.live_output = self.live_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.stderr = self.stderr if self.binary: diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 211347c..603dccf 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -13,6 +13,7 @@ from typing import List, Optional, Tuple import sys import os import re +import gc from argparse import ArgumentParser from pathlib import Path from logging import getLogger @@ -41,6 +42,8 @@ from resources.customization import ( LOADER_ANIMATION, FOLDER_ICON, FILE_ICON, + SYMLINK_ICON, + IRREGULAR_FILE_ICON, LICENSE_TEXT, SIMPLEGUI_THEME, OEM_ICON, @@ -55,6 +58,7 @@ from npbackup.path_helper import CURRENT_DIR from npbackup.__version__ import version_string from npbackup.__debug__ import _DEBUG from npbackup.restic_wrapper import ResticRunner +from npbackup.restic_wrapper import schema 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': '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() count = 0 for entry in ls_result: # 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": # On windows, we need to make sure tree keys don't get duplicate because of lower/uppercase # Shown filenames aren't affected by this - entry["path"] = entry["path"].lower() - parent = os.path.dirname(entry["path"]) + entry.path = entry.path.lower() + parent = os.path.dirname(entry.path) # Make sure we normalize mtime, and remove microseconds # 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 = str(entry["mtime"])[0:19] - if entry["type"] == "dir" and entry["path"] not in treedata.tree_dict: + mtime = entry.mtime.strftime("%Y-%m-%d %H:%M:%S") + name = os.path.basename(entry.path) + if entry.type == schema.LsNodeType.DIR and entry.path not in treedata.tree_dict: treedata.Insert( parent=parent, - key=entry["path"], - text=entry["name"], + key=entry.path, + text=name, values=["", mtime], icon=FOLDER_ICON, ) - elif entry["type"] == "file": - size = BytesConverter(entry["size"]).human + elif entry.type == schema.LsNodeType.FILE: + size = BytesConverter(entry.size).human treedata.Insert( parent=parent, - key=entry["path"], - text=entry["name"], + key=entry.path, + text=name, values=[size, mtime], 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 # 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 @@ -237,12 +262,13 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: __backend_binary=backend_binary, ) 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 + # result is {"result": True, "output": [{snapshot_description}, {entry}, {entry}]} - content = result["output"] + # content = result["output"] # First entry of snapshot list is the snapshot description - snapshot = content.pop(0) + snapshot = result["output"].pop(0) try: snap_date = dateutil.parser.parse(snapshot["time"]) except (KeyError, IndexError, TypeError): @@ -261,7 +287,6 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: 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}" - if not backup_id or not snapshot or not short_id: sg.PopupError(_t("main_gui.cannot_get_content"), keep_on_top=True) 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 # 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(): sg.PopupAnimated( LOADER_ANIMATION, @@ -283,6 +309,8 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: ) sg.PopupAnimated(None) + logger.info("Finished creating data tree") + left_col = [ [sg.Text(backup_id)], [ @@ -316,8 +344,11 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: enable_close_attempted_event=True, ) - # Reclaim memory fro thread result - thread = None + # Reclaim memory from thread result + # Note from v3 dev: This doesn't actually improve memory usage + del thread + del result + gc.collect() while True: event, values = window.read() @@ -334,6 +365,7 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: # before closing the window window["-TREE-"].update(values=sg.TreeData()) window.close() + del window return True @@ -966,6 +998,7 @@ def _main_gui(viewer_mode: bool): continue snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0] ls_window(repo_config, snapshot_to_see) + gc.collect() if event == "--FORGET--": if not full_config: sg.PopupError(_t("main_gui.no_config"), keep_on_top=True) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index fc48a33..873cb6b 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -127,6 +127,10 @@ def gui_thread_runner( # We'll always use json output in GUI mode 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 if __repo_config: runner.repo_config = __repo_config diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 008c6bf..9c8e48a 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -7,8 +7,8 @@ __intname__ = "npbackup.restic_wrapper" __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024090602" -__version__ = "2.2.4" +__build__ = "2024091501" +__version__ = "2.3.0" from typing import Tuple, List, Optional, Callable, Union @@ -16,7 +16,7 @@ import os import sys from logging import getLogger import re -import json +import msgspec from datetime import datetime, timezone import dateutil.parser import queue @@ -26,7 +26,7 @@ from ofunctions.misc import BytesConverter, fn_name from npbackup.__debug__ import _DEBUG from npbackup.__env__ import FAST_COMMANDS_TIMEOUT, CHECK_INTERVAL from npbackup.path_helper import CURRENT_DIR - +from npbackup.restic_wrapper import schema logger = getLogger() @@ -48,6 +48,7 @@ class ResticRunner: self._dry_run = False self._no_cache = False self._json_output = False + self._struct_output = False self.backup_result_content = None @@ -222,6 +223,17 @@ class ResticRunner: else: 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 def ignore_cloud_files(self) -> bool: return self._ignore_cloud_files @@ -636,6 +648,10 @@ class ResticRunner: return self.convert_to_json_output(False, output=msg) else: # 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) return self.convert_to_json_output(False, msg=msg) # pylint: disable=E1102 (not-callable) @@ -658,31 +674,40 @@ class ResticRunner: js = { "result": result, "operation": operation, + "extended_info": None, "args": kwargs, - "output": None, + "output": [], } if result: if output: - if isinstance(output, str): - output = list(filter(None, output.split("\n"))) - else: - output = [str(output)] - if len(output) > 1: - output_is_list = True - js["output"] = [] - else: - output_is_list = False - for line in output: - if output_is_list: - try: - js["output"].append(json.loads(line)) - except json.decoder.JSONDecodeError: - js["output"].append(line) - else: - try: - js["output"] = json.loads(line) - except json.decoder.JSONDecodeError: - js["output"] = {"data": line} + decoder = msgspec.json.Decoder() + ls_decoder = msgspec.json.Decoder(schema.LsNode) + is_first_line = True + + for line in output.split("\n"): + if not line: + continue + try: + if ( + not is_first_line + and operation == "ls" + and self.struct_output + ): + + js["output"].append(ls_decoder.decode(line)) + else: + js["output"].append(decoder.decode(line)) + is_first_line = False + except msgspec.DecodeError as exc: + 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: self.write_logs(msg, level="info") else: @@ -691,8 +716,11 @@ class ResticRunner: self.write_logs(msg, level="error") if output: try: - js["output"] = json.loads(output) - except json.decoder.JSONDecodeError: + js["output"] = msgspec.json.decode(output) + 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} return js diff --git a/npbackup/restic_wrapper/schema.py b/npbackup/restic_wrapper/schema.py new file mode 100644 index 0000000..a8e66ee --- /dev/null +++ b/npbackup/restic_wrapper/schema.py @@ -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 diff --git a/resources/customization.py b/resources/customization.py index 6a1d330..0f4602c 100644 --- a/resources/customization.py +++ b/resources/customization.py @@ -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=" 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" +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==" 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=" diff --git a/resources/irregular_file_icon.png b/resources/irregular_file_icon.png new file mode 100644 index 0000000..220b09f Binary files /dev/null and b/resources/irregular_file_icon.png differ diff --git a/resources/symlink_icon.png b/resources/symlink_icon.png new file mode 100644 index 0000000..f16ecd1 Binary files /dev/null and b/resources/symlink_icon.png differ