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/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(
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/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/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/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/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 e7204763..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):
@@ -153,9 +154,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/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
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 58278245..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
@@ -68,13 +67,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 = [
@@ -163,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/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
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/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 c92c08fc..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):
@@ -37,7 +40,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()
@@ -155,7 +158,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()
@@ -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 319553d4..02c8b62d 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
@@ -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):
@@ -1730,7 +1741,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):
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)))
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: