From b459b95950c8557a8f3d3f2d67f0a4420759f503 Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Sat, 2 May 2026 03:28:01 +0200 Subject: [PATCH 1/8] - The comma-separated return creates a 7-element tuple instead of comparing two tuples, every equality check returns True - master -> main branch fix - prepare_trash now calls self.move_to_trash instead of self.delete, so plugin subclasses won't permanently delete files when the user expects trashing - NotImplementedError - unrecognized platforms fail fast with a clear message instead of a cryptic NameError - removed shadowing basename import - get_column_widths uses range so plugin-added columns get their widths saved/restored --- build.py | 8 ++++---- src/main/python/fman/__init__.py | 2 ++ src/main/python/fman/fs.py | 2 +- src/main/python/fman/impl/model/worker.py | 6 +++--- src/main/python/fman/impl/widgets.py | 2 +- .../resources/base/Plugins/Core/core/commands/__init__.py | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/build.py b/build.py index 60255c1a..f9988e72 100644 --- a/build.py +++ b/build.py @@ -96,7 +96,7 @@ def release(): settings_path, next_version + snapshot_suffix, 'Bump version for next development iteration' ) - git('push', '-u', 'origin', 'master') + git('push', '-u', 'origin', 'main') try: git('push', 'origin', release_tag) try: @@ -106,7 +106,7 @@ def release(): ' git pull\n' ' git checkout %s\n' ' python build.py release\n' - ' git checkout master\n\n' + ' git checkout main\n\n' 'on the other OSs now, then come back here and do:' '\n\n' ' python build.py post_release\n' @@ -117,7 +117,7 @@ def release(): raise except: git('revert', '--no-edit', revision_before + '..HEAD' ) - git('push', '-u', 'origin', 'master') + git('push', '-u', 'origin', 'main') revision_before = git('rev-parse', 'HEAD').rstrip() raise except: @@ -149,7 +149,7 @@ def post_release(): create_cloudfront_invalidation(cloudfront_items_to_invalidate) record_release_on_server() upload_core_to_github() - git('checkout', 'master') + git('checkout', 'main') def _prompt_for_next_version(release_version): next_version = _get_suggested_next_version(release_version) diff --git a/src/main/python/fman/__init__.py b/src/main/python/fman/__init__.py index ab1f58d1..eefe7c12 100644 --- a/src/main/python/fman/__init__.py +++ b/src/main/python/fman/__init__.py @@ -41,6 +41,8 @@ DATA_DIRECTORY = expanduser('~/Library/Application Support/fman') elif PLATFORM == 'Linux': DATA_DIRECTORY = expanduser('~/.config/fman') +else: + raise NotImplementedError('Unsupported platform: %s' % PLATFORM) class ApplicationCommand: def __init__(self, window): diff --git a/src/main/python/fman/fs.py b/src/main/python/fman/fs.py index 2cf8f00f..d020a0a7 100644 --- a/src/main/python/fman/fs.py +++ b/src/main/python/fman/fs.py @@ -146,7 +146,7 @@ def prepare_trash(self, path): raise self._operation_not_implemented() return [Task( 'Deleting ' + path.rsplit('/', 1)[-1], - fn=self.delete, args=(path,), size=1 + fn=self.move_to_trash, args=(path,), size=1 )] def touch(self, path): raise self._operation_not_implemented() diff --git a/src/main/python/fman/impl/model/worker.py b/src/main/python/fman/impl/model/worker.py index 51066233..df74de27 100644 --- a/src/main/python/fman/impl/model/worker.py +++ b/src/main/python/fman/impl/model/worker.py @@ -21,7 +21,7 @@ def submit(self, priority, fn, *args, **kwargs): with self._shutdown_lock: if self._shutdown: return - self._queue.put(WorkItem(priority, fn, *args, *kwargs)) + self._queue.put(WorkItem(priority, fn, *args, **kwargs)) def shutdown(self): with self._shutdown_lock: self._shutdown = True @@ -56,7 +56,7 @@ def __lt__(self, other): return NotImplemented def __eq__(self, other): try: - return self._fn, self._args, self._kwargs, self._priority == \ - other._fn, other._args, other._kwargs, other._priority + return (self._fn, self._args, self._kwargs, self._priority) == \ + (other._fn, other._args, other._kwargs, other._priority) except AttributeError: return NotImplemented \ No newline at end of file diff --git a/src/main/python/fman/impl/widgets.py b/src/main/python/fman/impl/widgets.py index c92c08fc..12a8fe24 100644 --- a/src/main/python/fman/impl/widgets.py +++ b/src/main/python/fman/impl/widgets.py @@ -155,7 +155,7 @@ def get_sort_column(self): return column, ascending @run_in_main_thread def get_column_widths(self): - return [self._file_view.columnWidth(i) for i in (0, 1)] + return [self._file_view.columnWidth(i) for i in range(self._model.columnCount())] @run_in_main_thread def set_column_widths(self, column_widths): num_columns = self._model.columnCount() diff --git a/src/main/resources/base/Plugins/Core/core/commands/__init__.py b/src/main/resources/base/Plugins/Core/core/commands/__init__.py index 319553d4..644397e8 100644 --- a/src/main/resources/base/Plugins/Core/core/commands/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/commands/__init__.py @@ -17,7 +17,7 @@ from io import UnsupportedOperation from itertools import chain from os import strerror -from os.path import basename, pardir +from os.path import pardir from pathlib import PurePath from PyQt5.QtCore import QUrl from PyQt5.QtGui import QDesktopServices From 98a8c0eb433e69f52321d8639016feeb0c3ac76f Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Sat, 2 May 2026 03:34:28 +0200 Subject: [PATCH 2/8] fix failing tests --- src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py b/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py index 837f2bc2..a68a38d6 100644 --- a/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py +++ b/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py @@ -351,7 +351,7 @@ def _read_directory(self, dir_path): child_contents = self._read_directory(child) else: child_contents = child.read_text() - result[child.name] = child_contents + result[normalize('NFC', child.name)] = child_contents return result def _expect_zip_contents(self, contents, zip_file_path): with TemporaryDirectory() as tmp_dir: From 8cf09613cc230d23046428fc31f161b238ac9b3c Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Sat, 2 May 2026 03:43:47 +0200 Subject: [PATCH 3/8] all pending plugin errors are now shown at startup instead of just the first one --- src/main/python/fman/impl/plugins/error.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/python/fman/impl/plugins/error.py b/src/main/python/fman/impl/plugins/error.py index 8b19d965..c6797555 100644 --- a/src/main/python/fman/impl/plugins/error.py +++ b/src/main/python/fman/impl/plugins/error.py @@ -46,8 +46,8 @@ def handle_system_exit(self, code=0): self._app.exit(code) def on_main_window_shown(self, main_window): self._main_window = main_window - if self._pending_error_messages: - self._main_window.show_alert(self._pending_error_messages[0]) + for message in self._pending_error_messages: + self._main_window.show_alert(message) def _get_plugin_traceback(self, exc): if isinstance(exc, ThemeError): return exc.description From 064b5f0eb887c97202e3f80b7973715a33d7aa7d Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Sat, 2 May 2026 03:49:46 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=20=20-=20command=5Fregistry.py=20=E2=80=94?= =?UTF-8?q?=20=5Fset=5Fcontext=20now=20uses=20try/finally=20so=20cm.=5F=5F?= =?UTF-8?q?exit=5F=5F=20always=20runs,=20even=20on=20exception.=20=20=20-?= =?UTF-8?q?=20util/qt/=5F=5Finit=5F=5F.py=20=E2=80=94=20Added=20missing=20?= =?UTF-8?q?c=5Fvoid=5Fp=20import=20from=20ctypes,=20fixing=20a=20macOS=20r?= =?UTF-8?q?untime=20crash.=20=20=20-=20table.py=20=E2=80=94=20Fixed=20off-?= =?UTF-8?q?by-one:=20bounds=20check=20now=20rejects=20len=20+=201=20correc?= =?UTF-8?q?tly.=20=20=20-=20widgets.py=20=E2=80=94=20Added=20null=20guard?= =?UTF-8?q?=20on=20=5Fmain=5Fwindow=20before=20accessing=20it=20in=20state?= =?UTF-8?q?=20change=20handler.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/python/fman/impl/model/table.py | 2 +- src/main/python/fman/impl/plugins/command_registry.py | 8 +++++--- src/main/python/fman/impl/util/qt/__init__.py | 3 ++- src/main/python/fman/impl/widgets.py | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/python/fman/impl/model/table.py b/src/main/python/fman/impl/model/table.py index 33fe1c80..c1d17ff6 100644 --- a/src/main/python/fman/impl/model/table.py +++ b/src/main/python/fman/impl/model/table.py @@ -151,7 +151,7 @@ def insert(self, rows, first_rownum): new_keys = {row.key: first_rownum + i for i, row in enumerate(rows)} with self._lock: # Perform this check here, once we have the lock: - if first_rownum < 0 or first_rownum > len(self._rows) + 1: + if first_rownum < 0 or first_rownum > len(self._rows): raise ValueError('Invalid first_rownum: %d' % first_rownum) num_rows = len(rows) for row in self._rows[first_rownum:]: diff --git a/src/main/python/fman/impl/plugins/command_registry.py b/src/main/python/fman/impl/plugins/command_registry.py index e7204763..7bb1a0e6 100644 --- a/src/main/python/fman/impl/plugins/command_registry.py +++ b/src/main/python/fman/impl/plugins/command_registry.py @@ -153,9 +153,11 @@ def _set_context(self, pane, file_under_cursor=_DEFAULT): if file_under_cursor is not self._DEFAULT: cm = pane._override_file_under_cursor(file_under_cursor) cm.__enter__() - yield - if file_under_cursor is not self._DEFAULT: - cm.__exit__(None, None, None) + try: + yield + finally: + if file_under_cursor is not self._DEFAULT: + cm.__exit__(None, None, None) def _get_default_aliases(cmd_class): return re.sub(r'([a-z])([A-Z])', r'\1 \2', cmd_class.__name__)\ diff --git a/src/main/python/fman/impl/util/qt/__init__.py b/src/main/python/fman/impl/util/qt/__init__.py index 9d77ee96..8baddbad 100644 --- a/src/main/python/fman/impl/util/qt/__init__.py +++ b/src/main/python/fman/impl/util/qt/__init__.py @@ -16,8 +16,9 @@ def disable_window_animations_mac(window): # penalties and leads to subtle changes in behaviour. We therefore wait for # the Show event: def eventFilter(target, event): + from ctypes import c_void_p from objc import objc_object - view = objc_object(c_void_p=int(target.winId())) + view = objc_object(c_void_p=c_void_p(int(target.winId()))) NSWindowAnimationBehaviorNone = 2 view.window().setAnimationBehavior_(NSWindowAnimationBehaviorNone) FilterEventOnce(window, QEvent.Show, eventFilter) diff --git a/src/main/python/fman/impl/widgets.py b/src/main/python/fman/impl/widgets.py index 12a8fe24..5a591d98 100644 --- a/src/main/python/fman/impl/widgets.py +++ b/src/main/python/fman/impl/widgets.py @@ -37,7 +37,7 @@ def exit(self, returnCode=0): def set_style_sheet(self, stylesheet): self.setStyleSheet(stylesheet) def _on_state_changed(self, new_state): - if new_state == Qt.ApplicationActive: + if new_state == Qt.ApplicationActive and self._main_window is not None: for pane in self._main_window.get_panes(): pane.reload() From 93f580e40940ccf1b539a398466bd1b05b1eae11 Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Sat, 2 May 2026 03:50:56 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=20=20-=20util/path.py=20=E2=80=94=20normal?= =?UTF-8?q?ize=20now=20loops=20until=20all=20..=20segments=20are=20resolve?= =?UTF-8?q?d,=20so=20a/b/c/../../d=20correctly=20becomes=20a/d.=20=20=20-?= =?UTF-8?q?=20session.py=20=E2=80=94=20Removed=20dead=20=5Fget=5Fstartup?= =?UTF-8?q?=5Fmessage=20method=20(duplicated=20by=20=5Fshow=5Fstartup=5Fme?= =?UTF-8?q?ssages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/python/fman/impl/session.py | 7 ------- src/main/python/fman/impl/util/path.py | 5 ++++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/python/fman/impl/session.py b/src/main/python/fman/impl/session.py index 58278245..18144e70 100644 --- a/src/main/python/fman/impl/session.py +++ b/src/main/python/fman/impl/session.py @@ -68,13 +68,6 @@ def _show_startup_messages(self, main_window): 'Updated to v%s. ' \ 'Changelog' % self._fman_version main_window.show_status_message(status_message, timeout_secs=5) - def _get_startup_message(self): - previous_version = self._settings.get('fman_version', None) - if not previous_version or previous_version == self._fman_version: - return 'v%s ready.' % self._fman_version - return 'Updated to v%s. ' \ - 'Changelog' \ - % self._fman_version def _init_panes(self, panes, pane_infos, paths_on_cmdline): with ThreadPoolExecutor(max_workers=len(panes)) as executor: futures = [ diff --git a/src/main/python/fman/impl/util/path.py b/src/main/python/fman/impl/util/path.py index b5ffff8a..7105975f 100644 --- a/src/main/python/fman/impl/util/path.py +++ b/src/main/python/fman/impl/util/path.py @@ -34,5 +34,8 @@ def normalize(path_): if path_ == '.': path_ = '' # Resolve a/../b - path_ = re.subn(r'(^|/)([^/]+)/\.\.(?:$|/)', r'\1', path_)[0] + while True: + path_, count = re.subn(r'(^|/)([^/]+)/\.\.(?:$|/)', r'\1', path_) + if not count: + break return path_.rstrip('/') \ No newline at end of file From 74f1ba7cee0e48c29eaab9b999811bc2df97124e Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Sun, 3 May 2026 06:30:22 +0200 Subject: [PATCH 6/8] bugfix --- src/main/resources/base/Plugins/Core/core/commands/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/base/Plugins/Core/core/commands/__init__.py b/src/main/resources/base/Plugins/Core/core/commands/__init__.py index 644397e8..b6c88f53 100644 --- a/src/main/resources/base/Plugins/Core/core/commands/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/commands/__init__.py @@ -1730,7 +1730,7 @@ def on_command(self, command_name, args): except (KeyError, ValueError): return None if scheme == 'file://': - new_scheme = _get_handler_for_archive(basename(path)) + new_scheme = _get_handler_for_archive(basename(url)) if new_scheme: try: if is_dir(url): From 97eb4b1cb776e38af2ebec45719c029fa7c3607c Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Fri, 8 May 2026 19:47:21 +0200 Subject: [PATCH 7/8] Fix crashes, logic errors, and lifecycle bugs found by code quality scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crashes: - application_context.py: model.shutdown() now called on close (was AttributeError on _widget) - widgets.py: saveState/restoreState crashed when state >= 256 bytes (bytes→struct.pack) - widgets.py: float division passed to QWidget.move() → integer division - widgets.py: findChild(QPushButton) null check, FilterBar sourceModel() null check - drag_and_drop.py: unguarded from_qurl in dropMimeData caused crash on invalid URLs - commands/__init__.py: tuple-unpack crash when zip has != 1 top-level directory - commands/__init__.py: OpenSelectedFiles passed None to _open_files - commands/__init__.py: HistoryListener KeyError on GoBack/GoForward - uniform_row_heights.py: max() on empty generator when model has 0 columns - quicksearch.py: guard negative sizeHintForRow return Logic fixes: - plugin.py: unload() catches per-action exceptions, reports all errors - plugin.py: guard spec=None, try/finally cleanup of sys.modules on failed load - plugin.py: deprecated load_module() → importlib.util spec-based loading - plugin.py: unbounded daemon threads → bounded ThreadPoolExecutor - plugin.py: validation break removed (was silently dropping subsequent errors) - command_registry.py: after_command no longer fired when command raised exception - commands/__init__.py: _from_human_readable used wrong scheme when src/dest differ - commands/__init__.py: _Delete used global show_alert instead of self.show_alert - zip.py: early return inside loop broke attribute lookup for non-first entries - zip.py: os.waitpid raw status → os.WEXITSTATUS for proper exit code - resize_cols_to_contents.py: pixel truncation in distribution + column shrink fix - key_bindings.py: if→elif preventing duplicate error messages - quicksearch_matchers.py: case-insensitive contains_chars and contains_substring - goto.py: dedup check was populated but never consulted - local/__init__.py: touch() and _rename() clear destination cache - onboarding/__init__.py: unclosed
    tag - session.py: redundant is_first_run diverged from instance attribute Lifecycle: - application_context.py: ListenerWrapper.shutdown_pool() and metrics.shutdown() on quit - model/__init__.py: SortedFileSystemModel memory leak (added shutdown + visited cap) - model/__init__.py: _on_file_removed recursion→iteration + null sourceModel guard - widgets.py: ProgressDialog state protected by Lock for thread safety - widgets.py: email URL encoding in SplashScreen login link --- .../python/fman/impl/application_context.py | 33 ++++------- src/main/python/fman/impl/model/__init__.py | 32 +++++++---- .../python/fman/impl/model/drag_and_drop.py | 11 +++- .../python/fman/impl/onboarding/__init__.py | 2 + .../fman/impl/plugins/command_registry.py | 21 +++---- .../python/fman/impl/plugins/key_bindings.py | 2 +- src/main/python/fman/impl/plugins/plugin.py | 40 +++++++++---- src/main/python/fman/impl/quicksearch.py | 2 + src/main/python/fman/impl/session.py | 5 +- .../fman/impl/view/resize_cols_to_contents.py | 15 ++++- .../fman/impl/view/uniform_row_heights.py | 2 +- src/main/python/fman/impl/widgets.py | 57 ++++++++++++------- .../Plugins/Core/core/commands/__init__.py | 27 ++++++--- .../base/Plugins/Core/core/commands/goto.py | 6 +- .../Plugins/Core/core/fs/local/__init__.py | 3 + .../base/Plugins/Core/core/fs/zip.py | 7 ++- .../Plugins/Core/core/quicksearch_matchers.py | 8 ++- 17 files changed, 172 insertions(+), 101 deletions(-) diff --git a/src/main/python/fman/impl/application_context.py b/src/main/python/fman/impl/application_context.py index 8744eaad..4592ae90 100644 --- a/src/main/python/fman/impl/application_context.py +++ b/src/main/python/fman/impl/application_context.py @@ -120,8 +120,13 @@ def _load(): Thread(target=_load, daemon=True).start() def on_main_window_close(self): self.session_manager.on_close(self.main_window) + for pane in self.main_window.get_panes(): + pane._model.shutdown() def on_quit(self): + from fman.impl.plugins.plugin import ListenerWrapper + ListenerWrapper.shutdown_pool(wait=False) self.config.on_quit() + self.metrics.shutdown() if self.metrics_logging_enabled: log_dir = dirname(self._get_metrics_json_path()) log_file_path = join(log_dir, 'Metrics.log') @@ -431,29 +436,11 @@ def init_logging(self): logging.basicConfig(level=logging.CRITICAL) def on_main_window_shown(self): if PLATFORM == 'Linux': - """ - PyInstaller sets LD_LIBRARY_PATH to /opt/fman. Processes we spawn, - be it via Popen(...) or QDesktopServices.openUrl(...), inherit this - value. This leads to problems, especially when the app we launch is - based on Qt. The reason is that the OS then attempts to load our - libraries, which are most likely incompatible with those of the app. - An example where this happens is VLC, which errors out with 'This - application failed to start because it could not find or load the Qt - platform plugin "xcb"'. Plugin developers have also encountered this - unexpected behaviour when trying to launch apps. - - To fix the problem, we restore LD_LIBRARY_PATH to its original value - here. According to the docs [1], PyInstaller stores this value in a - separate environment variable. - - A drawback of unsetting the environment variable here is that - libraries from PyInstaller's search path cannot be loaded after this - method was called. In other words, we assume that all required - libraries have been loaded once we reach here. This assumption may - turn out to be wrong in the future. - - [1]: http://pyinstaller.readthedocs.io/en/stable/runtime-information.html#ld-library-path-libpath-considerations - """ + # PyInstaller sets LD_LIBRARY_PATH to /opt/fman. Processes we spawn + # inherit this value, causing problems when the launched app is + # Qt-based (it tries to load our incompatible libraries). + # We restore LD_LIBRARY_PATH to its original value here. + # See: https://pyinstaller.org/en/stable/runtime-information.html lp_orig = os.environ.pop('LD_LIBRARY_PATH_ORIG', None) if lp_orig is not None: os.environ['LD_LIBRARY_PATH'] = lp_orig diff --git a/src/main/python/fman/impl/model/__init__.py b/src/main/python/fman/impl/model/__init__.py index 002a15e7..7024be35 100644 --- a/src/main/python/fman/impl/model/__init__.py +++ b/src/main/python/fman/impl/model/__init__.py @@ -20,6 +20,8 @@ class SortedFileSystemModel(QSortFilterProxyModel): sort_order_changed = pyqtSignal(int, int) transaction_ended = pyqtSignal() + _MAX_VISITED = 512 + def __init__(self, parent, fs, null_location): super().__init__(parent) self._fs = fs @@ -105,7 +107,9 @@ def _set_location_main( ) self.setSourceModel(new_model) self._connect_signals(new_model) - self._already_visited.add(url) + if len(self._already_visited) > self._MAX_VISITED: + self._already_visited.clear() + self._already_visited.add(url) self.location_changed.emit(url) order = Qt.AscendingOrder if ascending else Qt.DescendingOrder self.sort_order_changed.emit(sort_col_index, order) @@ -146,21 +150,19 @@ def url(self, index): def find(self, url): return self.mapFromSource(self.sourceModel().find(url)) def _on_file_removed(self, url): + if not self.sourceModel(): + return if is_pardir(url, self.get_location()): - dir_ = dirname(url) - if dir_ == url: - self.set_location(self._null_location) - else: + while True: + dir_ = dirname(url) + if dir_ == url: + self.set_location(self._null_location) + return try: self.set_location(dir_) + return except OSError: - # In a perfect world, would like to only handle - # FileNotFoundError here. But there can of course also be - # other reasons. For example, when on a network share on - # Windows, we may get a PermissionError trying to list a - # parent directory we don't have access to. So catch all - # OSErrors and in the worst case go to null://. - self._on_file_removed(dir_) + url = dir_ def _connect_signals(self, model): # Would prefer signal.connect(self.signal.emit) here. But PyQt doesn't # support it. So we need Python wrappers "_emit_...": @@ -189,5 +191,11 @@ def _emit_sort_order_changed(self, column, order): self.sort_order_changed.emit(column, order) def _emit_transaction_ended(self): self.transaction_ended.emit() + def shutdown(self): + self._fs.file_removed.remove_callback(self._on_file_removed) + model = self.sourceModel() + if model: + self._disconnect_signals(model) + model.shutdown() def __str__(self): return '<%s: %s>' % (self.__class__.__name__, self.get_location()) \ No newline at end of file diff --git a/src/main/python/fman/impl/model/drag_and_drop.py b/src/main/python/fman/impl/model/drag_and_drop.py index 93f32dde..8e89e37d 100644 --- a/src/main/python/fman/impl/model/drag_and_drop.py +++ b/src/main/python/fman/impl/model/drag_and_drop.py @@ -42,7 +42,7 @@ def mimeTypes(self): def mimeData(self, indexes): result = QMimeData() result.setUrls([as_qurl(self.url(index)) for index in indexes]) - # The Qt documentation (http://doc.qt.io/qt-5/dnd.html) states that the + # The Qt documentation (https://doc.qt.io/archives/qt-5.15/dnd.html) states that the # QMimeData should not be deleted, because the target of the drag and # drop operation takes ownership of it. We must therefore tell SIP not # to garbage-collect `result` once this method returns. Without this @@ -55,7 +55,14 @@ def dropMimeData(self, data, action, row, column, parent): return True if not data.hasUrls(): return False - urls = [from_qurl(qurl) for qurl in data.urls()] + urls = [] + for qurl in data.urls(): + try: + urls.append(from_qurl(qurl)) + except ValueError: + continue + if not urls: + return False dest = self._get_drop_dest(parent) if action in (MoveAction, CopyAction): self.files_dropped.emit(urls, dest, action == CopyAction) diff --git a/src/main/python/fman/impl/onboarding/__init__.py b/src/main/python/fman/impl/onboarding/__init__.py index 13aa9575..ea16ee83 100644 --- a/src/main/python/fman/impl/onboarding/__init__.py +++ b/src/main/python/fman/impl/onboarding/__init__.py @@ -159,4 +159,6 @@ def _get_body_html(self): line = re.subn(r'\*([^*]+)\*', highlight(r'\1'), line)[0] line = re.subn(r'_([^_]+)_', underline(r'\1'), line)[0] result += line + if is_list: + result += '
' return result \ No newline at end of file diff --git a/src/main/python/fman/impl/plugins/command_registry.py b/src/main/python/fman/impl/plugins/command_registry.py index 7bb1a0e6..f342617a 100644 --- a/src/main/python/fman/impl/plugins/command_registry.py +++ b/src/main/python/fman/impl/plugins/command_registry.py @@ -3,8 +3,11 @@ from threading import Thread, get_ident from weakref import WeakKeyDictionary +import logging import re +_LOG = logging.getLogger(__name__) + class CommandRegistry: def __init__(self, main_thread_id=None): if main_thread_id is None: @@ -61,11 +64,10 @@ def _execute_command(self, command, args): class_name = command.__class__.__name__ self._callback.before_command(class_name) msg_on_err = 'Command %r raised error.' % class_name - try: - with ReportExceptions(self._error_handler, msg_on_err): - command(**args) - except Exception: - pass + with ReportExceptions(self._error_handler, msg_on_err) as cm: + command(**args) + if cm.exception: + _LOG.debug('Command %s failed', class_name, exc_info=True) else: self._callback.after_command(class_name) @@ -123,11 +125,10 @@ def _execute_command(self, command, args, pane, file_under_cursor): self._callback.before_command(class_name) with self._set_context(pane, file_under_cursor): msg_on_err = 'Command %r raised error.' % class_name - try: - with ReportExceptions(self._error_handler, msg_on_err): - command(**args) - except Exception: - pass + with ReportExceptions(self._error_handler, msg_on_err) as cm: + command(**args) + if cm.exception: + _LOG.debug('Command %s failed', class_name, exc_info=True) else: self._callback.after_command(class_name) def _get_command(self, pane, name): diff --git a/src/main/python/fman/impl/plugins/key_bindings.py b/src/main/python/fman/impl/plugins/key_bindings.py index f2dc56f5..d57ae573 100644 --- a/src/main/python/fman/impl/plugins/key_bindings.py +++ b/src/main/python/fman/impl/plugins/key_bindings.py @@ -62,7 +62,7 @@ def sanitize_key_bindings(bindings, available_commands): 'Error: A key binding\'s "keys" must be a list ["..."], ' 'not %s.' % describe_type(keys) ) - if not keys: + elif not keys: this_binding_errors.append( 'Error: A key binding\'s "keys" must be a non-empty list ' '["..."], not [].' diff --git a/src/main/python/fman/impl/plugins/plugin.py b/src/main/python/fman/impl/plugins/plugin.py index c2e58a7a..1272b795 100644 --- a/src/main/python/fman/impl/plugins/plugin.py +++ b/src/main/python/fman/impl/plugins/plugin.py @@ -1,13 +1,13 @@ +from concurrent.futures import ThreadPoolExecutor from fman import DirectoryPaneCommand, DirectoryPaneListener, ApplicationCommand from fman.fs import FileSystem, Column from fman.impl.font_database import FontError from fman.impl.util import listdir_absolute from glob import glob -from importlib.machinery import SourceFileLoader +import importlib.util from inspect import getmro from json import JSONDecodeError from os.path import join, isdir, basename, isfile -from threading import Thread import inspect import json @@ -102,7 +102,8 @@ def _get_command_name(command_class): try: command_class = command_class.__name__ except AttributeError: - assert isinstance(command_class, str) + if not isinstance(command_class, str): + raise TypeError('Expected class or str, got %r' % type(command_class)) return re.sub(r'([a-z])([A-Z])', r'\1_\2', command_class).lower() class ExternalPlugin(Plugin): @@ -213,20 +214,37 @@ def _configure_component_from_json(self, component, json_name, *args): self._add_unload_action(component.unload, config, *args) for error in errors: self._error_handler.report(error) - break def _add_unload_action(self, f, *args, **kwargs): self._unload_actions.append((f, args, kwargs)) def unload(self): + errors = [] for f, args, kwargs in reversed(self._unload_actions): - f(*args, **kwargs) + try: + f(*args, **kwargs) + except Exception as e: + errors.append(e) self._unload_actions = [] + for e in errors: + self._error_handler.report(str(e)) def _load_packages(self): for dir_ in [d for d in listdir_absolute(self._path) if isdir(d)]: init = join(dir_, '__init__.py') if isfile(init): package_name = basename(dir_) - loader = SourceFileLoader(package_name, init) - yield loader.load_module() + spec = importlib.util.spec_from_file_location( + package_name, init, + submodule_search_locations=[dir_] + ) + if spec is None: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[package_name] = module + try: + spec.loader.exec_module(module) + except BaseException: + del sys.modules[package_name] + raise + yield module def _unregister_package(self, package): del sys.modules[package.__name__] def _iterate_classes(self, module): @@ -283,6 +301,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): return True class ListenerWrapper(Wrapper): + _POOL = ThreadPoolExecutor(max_workers=1, thread_name_prefix='listener') + @classmethod + def shutdown_pool(cls, wait=False): + cls._POOL.shutdown(wait=wait) def __init__(self, listener, error_handler): super().__init__(listener, 'DirectoryPaneListener', error_handler) def on_doubleclicked(self, *args): @@ -302,9 +324,7 @@ def before_location_change(self, *args): def on_location_bar_clicked(self, *args): self._notify_listener('on_location_bar_clicked', *args) def _notify_listener(self, *args): - Thread( - target=self._notify_listener_in_thread, args=args, daemon=True - ).start() + self._POOL.submit(self._notify_listener_in_thread, *args) def _notify_listener_in_thread(self, event, *args): listener_method = getattr(self._wrapped, event) with self._report_exceptions(): diff --git a/src/main/python/fman/impl/quicksearch.py b/src/main/python/fman/impl/quicksearch.py index 79314844..7a0dc51e 100644 --- a/src/main/python/fman/impl/quicksearch.py +++ b/src/main/python/fman/impl/quicksearch.py @@ -113,6 +113,8 @@ def _update_items(self, query): def _adjust_item_list_ize(self, min_num_items_to_display=7): num_items = len(self._curr_items) row_height = self._items.sizeHintForRow(0) + if row_height <= 0: + return max_height = num_items * row_height self._items.setMaximumHeight(max_height) if num_items >= min_num_items_to_display: diff --git a/src/main/python/fman/impl/session.py b/src/main/python/fman/impl/session.py index 18144e70..5a7b3df1 100644 --- a/src/main/python/fman/impl/session.py +++ b/src/main/python/fman/impl/session.py @@ -41,8 +41,7 @@ def show_main_window(self, window): pane_infos = [{}] * self.DEFAULT_NUM_PANES panes = [window.add_pane() for _ in range(len(pane_infos))] self._show_startup_messages(main_window) - is_first_run = not self._settings - if is_first_run: + if self.is_first_run: main_window.showMaximized() else: # In this case, we assume that _restore_window_geometry restored the @@ -156,7 +155,7 @@ def on_close(self, main_window): try: self._settings.flush() except OSError: - pass + _LOG.warning('Failed to save settings', exc_info=True) def _read_pane_settings(self, pane): return { 'location': pane.get_location(), diff --git a/src/main/python/fman/impl/view/resize_cols_to_contents.py b/src/main/python/fman/impl/view/resize_cols_to_contents.py index 857fd81a..e88c82b3 100644 --- a/src/main/python/fman/impl/view/resize_cols_to_contents.py +++ b/src/main/python/fman/impl/view/resize_cols_to_contents.py @@ -148,13 +148,21 @@ def _distribute_evenly(width, proportions): total = sum(proportions) if not total: return [0] * len(proportions) - return [int(p / total * width) for p in proportions] + result = [int(p / total * width) for p in proportions] + remainder = width - sum(result) + for i in range(remainder): + result[i] += 1 + return result def _distribute_exponentially(width, proportions): total = sum(p * p for p in proportions) if not total: return [0] * len(proportions) - return [int(p * p / total * width) for p in proportions] + result = [int(p * p / total * width) for p in proportions] + remainder = width - sum(result) + for i in range(remainder): + result[i] += 1 + return result def _resize_column(col, new_size, widths, min_widths, available_width): old_size = widths[col] @@ -176,7 +184,8 @@ def _resize_column(col, new_size, widths, min_widths, available_width): else: next_col = col + 1 if sum(result) < available_width: - result[next_col] -= delta + gain = min(-delta, available_width - sum(result)) + result[next_col] += gain to_distribute = available_width - sum(result) if to_distribute > 0: for c, (w, m_w) in enumerate(zip(widths, min_widths)): diff --git a/src/main/python/fman/impl/view/uniform_row_heights.py b/src/main/python/fman/impl/view/uniform_row_heights.py index 01ec76da..aa704ea5 100644 --- a/src/main/python/fman/impl/view/uniform_row_heights.py +++ b/src/main/python/fman/impl/view/uniform_row_heights.py @@ -18,7 +18,7 @@ def sizeHintForRow(self, row): return self.get_row_height() def get_row_height(self): if self._row_height is None: - self._row_height = max(self._get_cell_heights()) + self._row_height = max(self._get_cell_heights(), default=-1) return self._row_height def changeEvent(self, event): # This for instance happens when the style sheet changed. It may affect diff --git a/src/main/python/fman/impl/widgets.py b/src/main/python/fman/impl/widgets.py index 5a591d98..742034fc 100644 --- a/src/main/python/fman/impl/widgets.py +++ b/src/main/python/fman/impl/widgets.py @@ -16,6 +16,9 @@ QFrame, QAction, QSizePolicy, QProgressDialog, QProgressBar from random import randint, randrange +import struct +from threading import Lock +from urllib.parse import quote import re class Application(QApplication): @@ -259,7 +262,9 @@ def _on_scroll_range_changed(self, min_, max_): def _on_text_changed(self, text): text_re = '.*'.join(map(re.escape, text.split('*'))) self._filter_re = re.compile(text_re, re.I) - self._model.sourceModel().update() + source = self._model.sourceModel() + if source: + source.update() def _accepts(self, url): return bool(self._filter_re.search(basename(url))) @@ -301,9 +306,9 @@ def _init_help_menu(self, help_menu_actions): return help_menu_text = 'Help' if is_mac(): - # On OS X, any menu named "Help" has the "Spotlight search for + # On macOS, any menu named "Help" has the "Spotlight search for # Help" bar displayed in it. We don't need or want this. Add an - # invisible character to fool OS X into not treating it as + # invisible character to fool macOS into not treating it as # "Help" (' ' doesn't work): help_menu_text += '\u2063' help_menu = self.menuBar().addMenu(help_menu_text) @@ -421,26 +426,29 @@ def show_overlay(self, overlay): overlay.show() def _position_overlay(self, overlay): if self._dialog is None: - pos_x = (self.width() - overlay.width()) / 2 - pos_y = (self.height() - overlay.height()) / 2 + pos_x = (self.width() - overlay.width()) // 2 + pos_y = (self.height() - overlay.height()) // 2 else: dialog_pos = self._dialog.pos() pos_x = dialog_pos.x() - self.pos().x() + self._dialog.width() + 30 pos_y = dialog_pos.y() - self.pos().y() + self._dialog.height() + 30 right_margin = self.width() - pos_x - overlay.width() if right_margin / self.width() < 0.1: - pos_x = 0.9 * self.width() - overlay.width() - overlay.move(pos_x, pos_y) + pos_x = int(0.9 * self.width() - overlay.width()) + overlay.move(int(pos_x), int(pos_y)) def saveState(self, version=0): self_state = super().saveState(version) splitter_state = self._splitter.saveState() - return self_state + splitter_state + bytes([len(self_state)]) + return self_state + splitter_state + struct.pack('' '
' 'For more information, please ' - '' + '' 'log in to fman.io' '.', "To continue without a license, press button %s." @@ -718,6 +727,7 @@ def __init__(self, parent, title, size, progress_bar_palette): self._text = '' self._progress = 0 self._was_canceled = False + self._state_lock = Lock() self.findChild(QProgressBar).setPalette(progress_bar_palette) self.setMinimumDuration(self._MINIMUM_DURATION_MS) self.setAutoReset(False) @@ -730,15 +740,19 @@ def __init__(self, parent, title, size, progress_bar_palette): # Ensure the progress dialog appears in 1 sec starting *now*: self.setValue(0) def set_text(self, text): - self._text = text + with self._state_lock: + self._text = text @run_in_main_thread def set_task_size(self, size): - self._size = size + with self._state_lock: + self._size = size self.setMaximum(min(size, self._MAX_C_INT)) def set_progress(self, progress): - self._progress = progress + with self._state_lock: + self._progress = progress def get_progress(self): - return self._progress + with self._state_lock: + return self._progress def reject(self): # Called when the user presses the "Close window" button. self.request_cancel() @@ -749,7 +763,8 @@ def cancel(self): def request_cancel(self): self.set_text('Canceling...') cancel_button = self.findChild(QPushButton) - cancel_button.setEnabled(False) + if cancel_button is not None: + cancel_button.setEnabled(False) self._was_canceled = True @run_in_main_thread def show_alert(self, *args, **kwargs): @@ -776,8 +791,10 @@ def resizeEvent(self, e): def _update(self): if self.wasCanceled(): return - self.setLabelText(self._text) - self._set_value(self._progress) + with self._state_lock: + text, progress = self._text, self._progress + self.setLabelText(text) + self._set_value(progress) def _set_value(self, progress): if self._size > self._MAX_C_INT: # QProgressDialog#setValue(...) can only handle ints. If `progress` diff --git a/src/main/resources/base/Plugins/Core/core/commands/__init__.py b/src/main/resources/base/Plugins/Core/core/commands/__init__.py index b6c88f53..02c8b62d 100644 --- a/src/main/resources/base/Plugins/Core/core/commands/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/commands/__init__.py @@ -160,7 +160,7 @@ def __call__(self): self.show_alert(message) else: message += ' Do you want to continue?' - choice = show_alert(message, YES | NO | YES_TO_ALL) + choice = self.show_alert(message, YES | NO | YES_TO_ALL) if choice & NO: break if choice & YES_TO_ALL: @@ -400,6 +400,8 @@ def _run_executable(path): class OpenSelectedFiles(DirectoryPaneCommand): def __call__(self): file_under_cursor = self.pane.get_file_under_cursor() + if not file_under_cursor: + return selected_files = self.pane.get_selected_files() if file_under_cursor in selected_files: _open_files(selected_files, self.pane) @@ -683,13 +685,13 @@ def _from_human_readable(path_or_url, dest_dir, src_dir): splitscheme(path_or_url) except ValueError as no_scheme: dest_scheme, dest_dir_path = splitscheme(dest_dir) - if src_dir: - # Treat dest as relative to src_dir: + if src_dir and not PurePath(path_or_url).is_absolute(): src_scheme, src_path = splitscheme(src_dir) dest_path = PurePath(src_path, path_or_url).as_posix() + path_or_url = src_scheme + dest_path else: dest_path = PurePath(dest_dir_path, path_or_url).as_posix() - path_or_url = dest_scheme + dest_path + path_or_url = dest_scheme + dest_path return path_or_url def _split(url): @@ -935,7 +937,6 @@ def __call__(self): class CopyPathsToClipboard(DirectoryPaneCommand): def __call__(self): to_copy = self.get_chosen_files() or [self.pane.get_path()] - files = '\n'.join(to_copy) clipboard.clear() clipboard.set_text('\n'.join(map(as_human_readable, to_copy))) _report_clipboard_action('Copied', to_copy, ' to the clipboard', 'path') @@ -1315,11 +1316,15 @@ def __call__(self): class GoBack(DirectoryPaneCommand): def __call__(self): - HistoryListener.INSTANCES[self.pane].go_back() + history = HistoryListener.INSTANCES.get(self.pane) + if history: + history.go_back() class GoForward(DirectoryPaneCommand): def __call__(self): - HistoryListener.INSTANCES[self.pane].go_forward() + history = HistoryListener.INSTANCES.get(self.pane) + if history: + history.go_forward() class HistoryListener(DirectoryPaneListener): @@ -1440,7 +1445,13 @@ def _install_plugin(self, name, zipball_contents): with open(zip_path, 'wb') as f: f.write(zipball_contents) zip_url = as_url(zip_path, 'zip://') - dir_in_zip, = iterdir(zip_url) + dirs_in_zip = list(iterdir(zip_url)) + if len(dirs_in_zip) != 1: + raise ValueError( + 'Expected one top-level directory in zip, got %d' + % len(dirs_in_zip) + ) + dir_in_zip = dirs_in_zip[0] copy(join(zip_url, dir_in_zip), dest_dir_url) return dest_dir def _load_installed_plugin(self, plugin_dir): diff --git a/src/main/resources/base/Plugins/Core/core/commands/goto.py b/src/main/resources/base/Plugins/Core/core/commands/goto.py index 94fd8ad3..2ceb1077 100644 --- a/src/main/resources/base/Plugins/Core/core/commands/goto.py +++ b/src/main/resources/base/Plugins/Core/core/commands/goto.py @@ -50,7 +50,6 @@ def _get_tab_completion(self, query, curr_item): result += os.sep return result def _get_visited_paths(self): - # TODO: Rename to Visited Locations.json? result = load_json('Visited Paths.json', default={}) # Check for length 2 because the directories in which fman opens are # already in Visited Paths: @@ -126,8 +125,9 @@ def _traverse_by_mtime(self, dir_path, exclude=None): except OSError: pass else: - already_yielded.add(stat.st_ino) - to_visit.append((stat, file_path)) + if stat.st_ino not in already_yielded: + already_yielded.add(stat.st_ino) + to_visit.append((stat, file_path)) to_visit.sort(key=lambda tpl: tpl[0].st_mtime) class GoToListener(DirectoryPaneListener): diff --git a/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py b/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py index b3fbf022..1efc3b8d 100644 --- a/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/fs/local/__init__.py @@ -65,6 +65,8 @@ def touch(self, path): os_path.touch(exist_ok=False) except FileExistsError: os_path.touch(exist_ok=True) + self.cache.clear(path) + self.notify_file_changed(path) else: self.notify_file_added(path) def mkdir(self, path): @@ -152,6 +154,7 @@ def _rename(self, src_url, dst_url): dst_path = splitscheme(dst_url)[1] os_dst_path = self._url_to_os_path(dst_path) Path(os_src_path).replace(os_dst_path) + self.cache.clear(dst_path) self.notify_file_removed(src_path) self.notify_file_added(dst_path) def move_to_trash(self, path): diff --git a/src/main/resources/base/Plugins/Core/core/fs/zip.py b/src/main/resources/base/Plugins/Core/core/fs/zip.py index df13535d..54e48d82 100644 --- a/src/main/resources/base/Plugins/Core/core/fs/zip.py +++ b/src/main/resources/base/Plugins/Core/core/fs/zip.py @@ -184,7 +184,7 @@ def compute_value(): for info in self._iter_infos(path): if info.path == path_in_zip: return getattr(info, attr) - return folder_default + return folder_default return self.cache.query(path, attr, compute_value) def _preserve_empty_parent(self, zip_path, path_in_zip): # 7-Zip deletes empty directories that remain after an operation. For @@ -579,7 +579,10 @@ def __init__(self, args, cwd): def kill(self): os.kill(self._pid, signal.SIGTERM) def wait(self): - return os.waitpid(self._pid, 0)[1] + status = os.waitpid(self._pid, 0)[1] + if os.WIFEXITED(status): + return os.WEXITSTATUS(status) + return status def _spawn(self, argv, cwd=None, env=None): # Copied and adapted from pty.spawn(...). import pty # <- import late because pty is not available on Windows. diff --git a/src/main/resources/base/Plugins/Core/core/quicksearch_matchers.py b/src/main/resources/base/Plugins/Core/core/quicksearch_matchers.py index 4dc06381..8c8b2c39 100644 --- a/src/main/resources/base/Plugins/Core/core/quicksearch_matchers.py +++ b/src/main/resources/base/Plugins/Core/core/quicksearch_matchers.py @@ -15,11 +15,13 @@ def basename_starts_with(path, query): return [i + offset for i in range(len(query))] def contains_chars(text, query): + text_lower = text.lower() + query_lower = query.lower() indices = [] i = 0 - for char in query: + for char in query_lower: try: - i += text[i:].index(char) + i += text_lower[i:].index(char) except ValueError: return None indices.append(i) @@ -28,7 +30,7 @@ def contains_chars(text, query): def contains_substring(text, query): try: - start = text.index(query) + start = text.lower().index(query.lower()) except ValueError: return None return list(range(start, start + len(query))) From 179b3743fe36234ab496489750b3d42c5ead124f Mon Sep 17 00:00:00 2001 From: Srecko Skocilic Date: Mon, 11 May 2026 10:50:24 +0200 Subject: [PATCH 8/8] test bugfix --- .../plugin_tests/test_directory_pane_listener.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/integrationtest/python/fman_integrationtest/plugin_tests/test_directory_pane_listener.py b/src/integrationtest/python/fman_integrationtest/plugin_tests/test_directory_pane_listener.py index 21bdf70a..83b5da7f 100644 --- a/src/integrationtest/python/fman_integrationtest/plugin_tests/test_directory_pane_listener.py +++ b/src/integrationtest/python/fman_integrationtest/plugin_tests/test_directory_pane_listener.py @@ -1,20 +1,24 @@ +from fman.impl.plugins.plugin import ListenerWrapper from fman_integrationtest.plugin_tests import PluginTest class DirectoryPaneListenerTest(PluginTest): + def _broadcast_and_wait(self, *args): + self._left_pane._broadcast(*args) + ListenerWrapper._POOL.submit(lambda: None).result() def test_on_path_changed_error(self): - self._left_pane._broadcast('on_path_changed') + self._broadcast_and_wait('on_path_changed') self.assertEqual( ["DirectoryPaneListener 'ListenerRaisingError' raised error."], self._error_handler.error_messages ) def test_on_doubleclicked_error(self): - self._left_pane._broadcast('on_doubleclicked', self._settings_plugin) + self._broadcast_and_wait('on_doubleclicked', self._settings_plugin) self.assertEqual( ["DirectoryPaneListener 'ListenerRaisingError' raised error."], self._error_handler.error_messages ) def test_on_name_edited_error(self): - self._left_pane._broadcast( + self._broadcast_and_wait( 'on_name_edited', self._settings_plugin, 'New name' ) self.assertEqual(