From 0cf27572fabd31e0bd93c6b4c24f47e83860dcea Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 14 Aug 2023 22:47:56 -0500 Subject: [PATCH 01/26] Utils: Don't use threads when starting ProcessWorkers in WorkerManager - ProcessWorkers don't need threads, so they are not necessary. - Also, add a method to ProcessWorker to set its current working directory. --- spyder/utils/workers.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/spyder/utils/workers.py b/spyder/utils/workers.py index 5dbd1f9eebe..9691aeae83c 100644 --- a/spyder/utils/workers.py +++ b/spyder/utils/workers.py @@ -220,6 +220,10 @@ def start(self): self.sig_started.emit(self) self._started = True + def set_cwd(self, cwd): + """Set the process current working directory.""" + self._process.setWorkingDirectory(cwd) + class WorkerManager(QObject): """Spyder Worker Manager for Generic Workers.""" @@ -262,19 +266,19 @@ def _start(self, worker=None): # self._running_threads, # len(self._workers), # len(self._threads))) - self._running_threads += 1 worker = self._queue_workers.popleft() - thread = QThread(None) + if isinstance(worker, PythonWorker): + self._running_threads += 1 + thread = QThread(None) + self._threads.append(thread) + worker.moveToThread(thread) worker.sig_finished.connect(thread.quit) thread.started.connect(worker._start) thread.start() elif isinstance(worker, ProcessWorker): - thread.quit() - thread.wait() worker._start() - self._threads.append(thread) else: self._timer.start() From 6bfa0e0aa181e683c722c9d73df000560f3d3fbc Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 14 Aug 2023 23:10:45 -0500 Subject: [PATCH 02/26] Utils: Add some logging to WorkerManager --- spyder/utils/workers.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/spyder/utils/workers.py b/spyder/utils/workers.py index 9691aeae83c..be2c783e2da 100644 --- a/spyder/utils/workers.py +++ b/spyder/utils/workers.py @@ -12,6 +12,7 @@ # Standard library imports from collections import deque +import logging import os import sys @@ -23,7 +24,7 @@ from spyder.py3compat import to_text_string -WIN = os.name == 'nt' +logger = logging.getLogger(__name__) def handle_qbytearray(obj, encoding): @@ -123,7 +124,7 @@ def _get_encoding(self): enco = 'utf-8' # Currently only cp1252 is allowed? - if WIN: + if os.name == 'nt': import ctypes codepage = to_text_string(ctypes.cdll.kernel32.GetACP()) # import locale @@ -226,11 +227,12 @@ def set_cwd(self, cwd): class WorkerManager(QObject): - """Spyder Worker Manager for Generic Workers.""" + """Manager for generic workers.""" + + def __init__(self, parent=None, max_threads=10): + super().__init__(parent=parent) + self.parent = parent - def __init__(self, max_threads=10): - """Spyder Worker Manager for Generic Workers.""" - super().__init__() self._queue = deque() self._queue_workers = deque() self._threads = [] @@ -261,13 +263,16 @@ def _start(self, worker=None): self._queue_workers.append(worker) if self._queue_workers and self._running_threads < self._max_threads: - #print('Queue: {0} Running: {1} Workers: {2} ' - # 'Threads: {3}'.format(len(self._queue_workers), - # self._running_threads, - # len(self._workers), - # len(self._threads))) - worker = self._queue_workers.popleft() + if self.parent is not None: + logger.debug( + f"Workers managed in {self.parent} -- " + f"In queue: {len(self._queue_workers)} -- " + f"Running threads: {self._running_threads} -- " + f"Workers: {len(self._workers)} -- " + f"Threads: {len(self._threads)}" + ) + worker = self._queue_workers.popleft() if isinstance(worker, PythonWorker): self._running_threads += 1 thread = QThread(None) From 47a9d37cbd6e5fa4bc237dbc24c5f8774b3bf3c3 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 14 Aug 2023 23:35:08 -0500 Subject: [PATCH 03/26] Projects: Remove unnecessary method --- spyder/plugins/projects/tests/test_plugin.py | 2 +- spyder/plugins/projects/widgets/main_widget.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/spyder/plugins/projects/tests/test_plugin.py b/spyder/plugins/projects/tests/test_plugin.py index 812950ce6e4..fe7419ab637 100644 --- a/spyder/plugins/projects/tests/test_plugin.py +++ b/spyder/plugins/projects/tests/test_plugin.py @@ -351,7 +351,7 @@ def test_project_explorer_tree_root(projects, tmpdir, qtbot): # Open the projects. for ppath in [ppath1, ppath2]: projects.open_project(path=ppath) - projects.get_widget()._update_explorer(None) + projects.get_widget()._setup_project(ppath) # Check that the root path of the project explorer tree widget is # set correctly. diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index f7adbdaad0f..40429f9541d 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -183,7 +183,7 @@ def __init__(self, name, plugin, parent=None): self.watcher.connect_signals(self) # Signals - self.sig_project_loaded.connect(self._update_explorer) + self.sig_project_loaded.connect(self._setup_project) # Layout layout = QVBoxLayout() @@ -996,10 +996,6 @@ def _load_config(self): if expanded_state is not None: self.treewidget.set_expanded_state(expanded_state) - def _update_explorer(self, _unused): - """Update explorer tree""" - self._setup_project(self.get_active_project_path()) - def _get_valid_recent_projects(self, recent_projects): """ Get the list of valid recent projects. From 071e163e5a10c6a846aaf1dce0b3a1a132e4595a Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Tue, 15 Aug 2023 12:03:39 -0500 Subject: [PATCH 04/26] Projects: Call fzf in a worker to not block the interface Also, reduce the number of results shown in the switcher to display it without lags. --- spyder/plugins/projects/plugin.py | 12 +- .../plugins/projects/widgets/main_widget.py | 170 +++++++++++------- spyder/utils/workers.py | 4 + 3 files changed, 114 insertions(+), 72 deletions(-) diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 92144612ce6..2c0644ec3ca 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -216,7 +216,7 @@ def on_switcher_available(self): self._switcher.sig_item_selected.connect( self._handle_switcher_selection) self._switcher.sig_search_text_available.connect( - self._handle_switcher_results) + self._handle_switcher_filtering) @on_plugin_teardown(plugin=Plugins.Editor) def on_editor_teardown(self): @@ -281,7 +281,7 @@ def on_switcher_teardown(self): self._switcher.sig_item_selected.disconnect( self._handle_switcher_selection) self._switcher.sig_search_text_available.disconnect( - self._handle_switcher_results) + self._handle_switcher_filtering) self._switcher = None def on_close(self, cancelable=False): @@ -540,7 +540,7 @@ def _handle_switcher_selection(self, item, mode, search_text): self.get_widget().handle_switcher_selection(item, mode, search_text) self._switcher.hide() - def _handle_switcher_results(self, search_text, items_data): + def _handle_switcher_filtering(self, search_text, items_data): """ Handle user typing in switcher to filter results. @@ -552,8 +552,10 @@ def _handle_switcher_results(self, search_text, items_data): items_data: list List of items shown in the switcher. """ - items = self.get_widget().handle_switcher_results(search_text, - items_data) + self.get_widget().handle_switcher_filtering(search_text, items_data) + + def _display_items_in_switcher(self, items): + """Display a list of items in the switcher.""" for (title, description, icon, section, path, is_last_item) in items: self._switcher.add_item( title=title, diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 40429f9541d..05e969c97a1 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -14,7 +14,6 @@ import os.path as osp import pathlib import shutil -import subprocess # Third party imports from qtpy.compat import getexistingdirectory @@ -43,6 +42,7 @@ from spyder.widgets.helperwidgets import PaneEmptyWidget from spyder.utils import encoding from spyder.utils.misc import getcwd_or_home +from spyder.utils.workers import WorkerManager # For logging @@ -77,8 +77,14 @@ class RecentProjectsMenuSections: # ----------------------------------------------------------------------------- @class_register class ProjectExplorerWidget(PluginMainWidget): - """Project Explorer""" + """Project explorer main widget.""" + # ---- Constants + # ------------------------------------------------------------------------- + MAX_SWITCHER_RESULTS = 50 + + # ---- Signals + # ------------------------------------------------------------------------- sig_open_file_requested = Signal(str) """ This signal is emitted when a file is requested to be opened. @@ -150,11 +156,11 @@ class ProjectExplorerWidget(PluginMainWidget): def __init__(self, name, plugin, parent=None): super().__init__(name, plugin=plugin, parent=parent) - # Attributes from conf + # -- Attributes from conf self.name_filters = self.get_conf('name_filters') self.show_hscrollbar = self.get_conf('show_hscrollbar') - # Main attributes + # -- Main attributes self.recent_projects = self._get_valid_recent_projects( self.get_conf('recent_projects', []) ) @@ -162,8 +168,10 @@ def __init__(self, name, plugin, parent=None): self.current_active_project = None self.latest_project = None self.completions_available = False + self._default_switcher_paths = [] + self._switcher_items_data = [] - # Tree widget + # -- Tree widget self.treewidget = ProjectExplorerTreeWidget(self, self.show_hscrollbar) self.treewidget.setup() self.treewidget.setup_view() @@ -178,14 +186,24 @@ def __init__(self, name, plugin, parent=None): _("Create one using the menu entry Projects > New project.") ) - # Watcher + # -- Watcher self.watcher = WorkspaceWatcher(self) self.watcher.connect_signals(self) - # Signals + # To manage the worker that calls fzf + self._worker_manager = WorkerManager(self) + + # -- Signals self.sig_project_loaded.connect(self._setup_project) - # Layout + # This is necessary to populate the switcher with some default list of + # paths instead of computing it every open is shown. + self.sig_project_loaded.connect(lambda p: self._call_fzf()) + + # Clear saved paths for the switcher when closing the project. + self.sig_project_closed.connect(lambda p: self._clear_switcher_paths()) + + # -- Layout layout = QVBoxLayout() layout.addWidget(self.pane_empty) layout.addWidget(self.treewidget) @@ -254,11 +272,13 @@ def setup(self): def set_pane_empty(self): self.treewidget.hide() self.pane_empty.show() - def update_actions(self): pass + def on_close(self): + self._worker_manager.terminate_all() + # ---- Public API # ------------------------------------------------------------------------- @Slot() @@ -610,6 +630,8 @@ def show_widget(self): self.raise_() self.update() + # ---- Public API for the Switcher + # ------------------------------------------------------------------------- def handle_switcher_modes(self): """ Populate switcher with files in active project. @@ -617,10 +639,11 @@ def handle_switcher_modes(self): List the file names of the current active project with their directories in the switcher. """ - paths = self._execute_fzf_subprocess() + paths = self._default_switcher_paths if paths == []: return [] - # the paths that are opened in the editor need to be excluded because + + # The paths that are opened in the editor need to be excluded because # they are shown already in the switcher in the "editor" section. open_files = self.get_plugin()._get_open_filenames() for file in open_files: @@ -670,7 +693,7 @@ def handle_switcher_selection(self, item, mode, search_text): # Open file in editor self.sig_open_file_requested.emit(item.get_data()) - def handle_switcher_results(self, search_text, items_data): + def handle_switcher_filtering(self, search_text, items_data): """ Handle user typing in switcher to filter results. @@ -682,28 +705,8 @@ def handle_switcher_results(self, search_text, items_data): items_data: list List of items shown in the switcher. """ - paths = self._execute_fzf_subprocess(search_text) - for sw_path in items_data: - if (sw_path in paths): - paths.remove(sw_path) - - is_unsaved = [False] * len(paths) - short_paths = shorten_paths(paths, is_unsaved) - section = self.get_title() - - items = [] - for i, (path, short_path) in enumerate(zip(paths, short_paths)): - title = osp.basename(path) - icon = get_file_icon(path) - description = osp.dirname(path).lower() - if len(path) > 75: - description = short_path - is_last_item = (i+1 == len(paths)) - - item_tuple = (title, description, icon, - section, path, is_last_item) - items.append(item_tuple) - return items + self._call_fzf(search_text) + self._switcher_items_data = items_data # ---- Public API for the LSP # ------------------------------------------------------------------------- @@ -1011,55 +1014,88 @@ def _get_valid_recent_projects(self, recent_projects): return valid_projects - def _execute_fzf_subprocess(self, search_text=""): + # ---- Private API for the Switcher + # ------------------------------------------------------------------------- + def _call_fzf(self, search_text=""): """ - Execute fzf subprocess to get the list of files in the current - project filtered by `search_text`. + Call fzf in a worker to get the list of files in the current project + that match with `search_text`. Parameters ---------- - search_text: str - The current search text in the switcher dialog box. + search_text: str, optional + The search text to pass to fzf. """ project_path = self.get_active_project_path() if project_path is None: return [] - # command = fzf --filter - cmd_list = ["fzf", "--filter", search_text] - shell = False - env = os.environ.copy() + self._worker_manager.terminate_all() + + worker = self._worker_manager.create_process_worker( + ["fzf", "--filter", search_text], + os.environ.copy() + ) + + worker.set_cwd(project_path) + worker.sig_finished.connect(self._process_fzf_output) + worker.start() + + def _process_fzf_output(self, worker, output, error): + """Process output that comes from the fzf worker.""" + if output is None or error: + return + + relative_path_list = output.decode('utf-8').strip().split("\n") + project_path = self.get_active_project_path() + + # List of tuples with the absolute path + result_list = [ + osp.normpath(os.path.join(project_path, path)).lower() + for path in relative_path_list + ] + + # Limit the number of results to not introduce lags when displaying + # them in the switcher. + if len(result_list) > self.MAX_SWITCHER_RESULTS: + result_list = result_list[:self.MAX_SWITCHER_RESULTS] - # This is only available on Windows - if os.name == 'nt': - startupinfo = subprocess.STARTUPINFO() + if not self._default_switcher_paths: + self._default_switcher_paths = result_list else: - startupinfo = None + self._display_paths_in_switcher(result_list) - try: - out = subprocess.check_output( - cmd_list, - cwd=project_path, - shell=shell, - env=env, - startupinfo=startupinfo, - stderr=subprocess.STDOUT - ) + def _display_paths_in_switcher(self, paths): + """Display a list of paths in the switcher.""" + for sw_path in self._switcher_items_data: + if (sw_path in paths): + paths.remove(sw_path) - relative_path_list = out.decode('UTF-8').strip().split("\n") + is_unsaved = [False] * len(paths) + short_paths = shorten_paths(paths, is_unsaved) + section = self.get_title() - # List of tuples with the absolute path - result_list = [ - osp.normpath(os.path.join(project_path, path)).lower() - for path in relative_path_list] + items = [] + for i, (path, short_path) in enumerate(zip(paths, short_paths)): + title = osp.basename(path) + icon = get_file_icon(path) + description = osp.dirname(path).lower() + if len(path) > 75: + description = short_path + is_last_item = (i+1 == len(paths)) - # Limit the number of results to 500 - if (len(result_list) > 500): - result_list = result_list[:500] - return result_list - except (subprocess.CalledProcessError, FileNotFoundError): - return [] + item_tuple = (title, description, icon, + section, path, is_last_item) + items.append(item_tuple) + + # Call directly the plugin's method instead of emitting a signal + # because it's faster + self._plugin._display_items_in_switcher(items) + def _clear_switcher_paths(self): + """Clear saved switcher results.""" + self._default_switcher_paths = [] + self._switcher_items_data = [] # ============================================================================= # Tests diff --git a/spyder/utils/workers.py b/spyder/utils/workers.py index be2c783e2da..f7e961ee1ab 100644 --- a/spyder/utils/workers.py +++ b/spyder/utils/workers.py @@ -115,6 +115,10 @@ def __init__(self, parent, cmd_list, environ=None): self._process = QProcess(self) self._set_environment(environ) + # This is necessary to pass text input to the process as part of + # cmd_list + self._process.setInputChannelMode(QProcess.ForwardedInputChannel) + self._timer.setInterval(150) self._timer.timeout.connect(self._communicate) self._process.readyReadStandardOutput.connect(self._partial) From 79bbc04cd88f250b7b5320d4e7b6495c180d80c5 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Tue, 15 Aug 2023 12:25:20 -0500 Subject: [PATCH 05/26] Projects: Detect if fzf is present in the system before trying to use it --- spyder/plugins/projects/widgets/main_widget.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 05e969c97a1..9ce87d51963 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -39,10 +39,11 @@ from spyder.plugins.projects.widgets.projectexplorer import ( ProjectExplorerTreeWidget) from spyder.plugins.switcher.utils import get_file_icon, shorten_paths -from spyder.widgets.helperwidgets import PaneEmptyWidget from spyder.utils import encoding from spyder.utils.misc import getcwd_or_home +from spyder.utils.programs import find_program from spyder.utils.workers import WorkerManager +from spyder.widgets.helperwidgets import PaneEmptyWidget # For logging @@ -168,6 +169,7 @@ def __init__(self, name, plugin, parent=None): self.current_active_project = None self.latest_project = None self.completions_available = False + self._fzf = find_program('fzf') self._default_switcher_paths = [] self._switcher_items_data = [] @@ -1027,13 +1029,13 @@ def _call_fzf(self, search_text=""): The search text to pass to fzf. """ project_path = self.get_active_project_path() - if project_path is None: - return [] + if self._fzf is None or project_path is None: + return self._worker_manager.terminate_all() worker = self._worker_manager.create_process_worker( - ["fzf", "--filter", search_text], + [self._fzf, "--filter", search_text], os.environ.copy() ) From a81d5b2d29a3e6c95201790f3484019713895224 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 18 Aug 2023 12:06:29 -0500 Subject: [PATCH 06/26] Projects: Call switcher setup method after adding project items This is necessary now that fzf is called asynchronously. --- spyder/plugins/projects/plugin.py | 5 +- spyder/plugins/switcher/widgets/switcher.py | 75 +++++++++++++++------ 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 2c0644ec3ca..bbca4382bc5 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -518,6 +518,7 @@ def _handle_switcher_modes(self, mode): data=path, last_item=is_last_item ) + self._switcher.set_current_row(0) def _handle_switcher_selection(self, item, mode, search_text): @@ -564,5 +565,7 @@ def _display_items_in_switcher(self, items): section=section, data=path, last_item=is_last_item, - score=100 + score=1e10 # To make the editor results appear first ) + + self._switcher.setup() diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index c29906a5f22..e7096e40af3 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -160,31 +160,36 @@ class Switcher(QDialog): def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, item_separator_styles=ITEM_SEPARATOR_STYLES): """Multi purpose switcher.""" - super(Switcher, self).__init__(parent) + super().__init__(parent) + + # Attributes self._modes = {} self._mode_on = '' self._item_styles = item_styles self._item_separator_styles = item_separator_styles # Widgets - self.timer = QTimer() self.edit = QLineEdit(self) self.list = QListView(self) self.model = QStandardItemModel(self.list) self.proxy = SwitcherProxyModel(self.list) self.filter = KeyPressFilter() - # Widgets setup - self.timer.setInterval(300) - self.timer.setSingleShot(True) - self.timer.timeout.connect(self.setup) + # Search timer + self._search_timer = QTimer(self) + self._search_timer.setInterval(300) + self._search_timer.setSingleShot(True) + self._search_timer.timeout.connect(self._on_search_text_changed) + # Widgets setup self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint) self.setWindowOpacity(0.95) -# self.setMinimumHeight(self._MIN_HEIGHT) + # self.setMinimumHeight(self._MIN_HEIGHT) self.setMaximumHeight(self._MAX_HEIGHT) + self.edit.installEventFilter(self.filter) self.edit.setPlaceholderText(help_text if help_text else '') + self.list.setMinimumWidth(self._MIN_WIDTH) self.list.setItemDelegate(SwitcherDelegate(self)) self.list.setFocusPolicy(Qt.NoFocus) @@ -204,13 +209,17 @@ def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, self.filter.sig_up_key_pressed.connect(self.previous_row) self.filter.sig_down_key_pressed.connect(self.next_row) self.filter.sig_enter_key_pressed.connect(self.enter) + self.edit.textChanged.connect(self.sig_text_changed) - self.edit.textChanged.connect(lambda: self.timer.start()) + self.edit.textChanged.connect(lambda: self._search_timer.start()) self.edit.returnPressed.connect(self.enter) + self.list.clicked.connect(self.enter) self.list.clicked.connect(self.edit.setFocus) self.list.selectionModel().currentChanged.connect( self.current_item_changed) + + # Gives focus to text edit self.edit.setFocus() # ---- Helper methods @@ -320,22 +329,15 @@ def setup(self): # Filter by text titles = [] - items_data = [] - for row in range(self.model.rowCount() - 1, -1, -1): - # As we are removing items from the model, we need to iterate - # backwards so that the indexes are not affected + for row in range(self.model.rowCount()): item = self.model.item(row) if isinstance(item, SwitcherItem): - if item._section == "Projects": - self.model.removeRow(row) - continue - else: - title = item.get_title() - if item._data is not None: - items_data.append(item._data._filename.lower()) + title = item.get_title() else: title = '' - titles.insert(0, title) + + titles.append(title) + search_text = clean_string(search_text) scores = get_search_scores(to_text_string(search_text), titles, template=u"{0}") @@ -345,16 +347,22 @@ def setup(self): if not self._is_separator(item) and not item.is_action_item(): rich_title = rich_title.replace(" ", " ") item.set_rich_title(rich_title) - item.set_score(score_value) - self.sig_search_text_available.emit(search_text, items_data) + # Results come from Projects in the right order, so we don't need + # to sort them here. + if item._section != "Projects": + item.set_score(score_value) + self.proxy.set_filter_by_score(True) + # Graphical setup self.setup_sections() + if self.count(): self.set_current_row(0) else: self.set_current_row(-1) + self.set_height() def setup_sections(self): @@ -466,6 +474,29 @@ def set_search_text(self, string): """Set the content of the search text.""" self.edit.setText(string) + def _on_search_text_changed(self): + """Actions to take when the search text has changed.""" + if not self._mode_on: + search_text = clean_string(self.search_text()) + items_data = [] + + # Remove project rows and get data of editor items + for row in range(self.model.rowCount() - 1, -1, -1): + # As we are removing items from the model, we need to iterate + # backwards so that the indexes are not affected + item = self.model.item(row) + if isinstance(item, SwitcherItem): + if item._section == "Projects": + self.model.removeRow(row) + continue + else: + if item._data is not None: + items_data.append(item._data._filename.lower()) + + self.sig_search_text_available.emit(search_text, items_data) + else: + self.setup() + # ---- Helper methods: List widget def _is_separator(self, item): """Check if item is an separator item (SwitcherSeparatorItem).""" From 7a9d7b1d8bb5ef0d49e3a3d50d351ffd35be9316 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 18 Aug 2023 13:25:04 -0500 Subject: [PATCH 07/26] Projects: Remove repeated code in several places --- spyder/plugins/projects/plugin.py | 19 ++--- .../plugins/projects/widgets/main_widget.py | 76 ++++++++----------- 2 files changed, 37 insertions(+), 58 deletions(-) diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index bbca4382bc5..b072b0c6c32 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -508,17 +508,11 @@ def _handle_switcher_modes(self, mode): mode: str The selected mode (open files "", symbol "@" or line ":"). """ - items = self.get_widget().handle_switcher_modes() - for (title, description, icon, section, path, is_last_item) in items: - self._switcher.add_item( - title=title, - description=description, - icon=icon, - section=section, - data=path, - last_item=is_last_item - ) + # Don't compute anything if we're not in files mode + if mode != "": + return + self.get_widget().display_default_switcher_items() self._switcher.set_current_row(0) def _handle_switcher_selection(self, item, mode, search_text): @@ -555,7 +549,7 @@ def _handle_switcher_filtering(self, search_text, items_data): """ self.get_widget().handle_switcher_filtering(search_text, items_data) - def _display_items_in_switcher(self, items): + def _display_items_in_switcher(self, items, setup=True): """Display a list of items in the switcher.""" for (title, description, icon, section, path, is_last_item) in items: self._switcher.add_item( @@ -568,4 +562,5 @@ def _display_items_in_switcher(self, items): score=1e10 # To make the editor results appear first ) - self._switcher.setup() + if setup: + self._switcher.setup() diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 9ce87d51963..54fddd28c09 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -634,42 +634,14 @@ def show_widget(self): # ---- Public API for the Switcher # ------------------------------------------------------------------------- - def handle_switcher_modes(self): - """ - Populate switcher with files in active project. - - List the file names of the current active project with their - directories in the switcher. - """ - paths = self._default_switcher_paths - if paths == []: - return [] - - # The paths that are opened in the editor need to be excluded because - # they are shown already in the switcher in the "editor" section. - open_files = self.get_plugin()._get_open_filenames() - for file in open_files: - normalized_path = osp.normpath(file).lower() - if normalized_path in paths: - paths.remove(normalized_path) - - is_unsaved = [False] * len(paths) - short_paths = shorten_paths(paths, is_unsaved) - section = self.get_title() - - items = [] - for i, (path, short_path) in enumerate(zip(paths, short_paths)): - title = osp.basename(path) - icon = get_file_icon(path) - description = osp.dirname(path) - if len(path) > 75: - description = short_path - is_last_item = (i+1 == len(paths)) + def display_default_switcher_items(self): + """Populate switcher with a default set of files in the project.""" + if not self._default_switcher_paths: + return - item_tuple = (title, description, icon, - section, path, is_last_item) - items.append(item_tuple) - return items + self._display_paths_in_switcher( + self._default_switcher_paths, setup=False + ) def handle_switcher_selection(self, item, mode, search_text): """ @@ -688,7 +660,6 @@ def handle_switcher_selection(self, item, mode, search_text): search_text: str Cleaned search/filter text. """ - if item.get_section() != self.get_title(): return @@ -1067,11 +1038,17 @@ def _process_fzf_output(self, worker, output, error): else: self._display_paths_in_switcher(result_list) - def _display_paths_in_switcher(self, paths): - """Display a list of paths in the switcher.""" - for sw_path in self._switcher_items_data: - if (sw_path in paths): - paths.remove(sw_path) + def _convert_paths_to_switcher_items(self, paths): + """ + Convert a list of paths to items that can be shown in the switcher. + """ + # The paths that are opened in the editor need to be excluded because + # they are shown already in the switcher in the "editor" section. + open_files = self.get_plugin()._get_open_filenames() + for file in open_files: + normalized_path = osp.normpath(file).lower() + if normalized_path in paths: + paths.remove(normalized_path) is_unsaved = [False] * len(paths) short_paths = shorten_paths(paths, is_unsaved) @@ -1081,18 +1058,25 @@ def _display_paths_in_switcher(self, paths): for i, (path, short_path) in enumerate(zip(paths, short_paths)): title = osp.basename(path) icon = get_file_icon(path) - description = osp.dirname(path).lower() + description = osp.dirname(path) if len(path) > 75: description = short_path - is_last_item = (i+1 == len(paths)) + is_last_item = (i + 1 == len(paths)) - item_tuple = (title, description, icon, - section, path, is_last_item) + item_tuple = ( + title, description, icon, section, path, is_last_item + ) items.append(item_tuple) + return items + + def _display_paths_in_switcher(self, paths, setup=True): + """Display a list of paths in the switcher.""" + items = self._convert_paths_to_switcher_items(paths) + # Call directly the plugin's method instead of emitting a signal # because it's faster - self._plugin._display_items_in_switcher(items) + self._plugin._display_items_in_switcher(items, setup=setup) def _clear_switcher_paths(self): """Clear saved switcher results.""" From 3aa2b35c4206d95377b2fc2b5a3de2f0962da998 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 18 Aug 2023 13:41:18 -0500 Subject: [PATCH 08/26] Switcher: Remove items_data of sig_search_text_available signal Also, rename methods in Projects related to switcher searching to be more meaningful. --- spyder/plugins/projects/plugin.py | 11 ++++------- spyder/plugins/projects/widgets/main_widget.py | 7 +------ spyder/plugins/switcher/plugin.py | 4 +--- spyder/plugins/switcher/widgets/switcher.py | 10 ++-------- 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index b072b0c6c32..f6bfd97cc72 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -216,7 +216,7 @@ def on_switcher_available(self): self._switcher.sig_item_selected.connect( self._handle_switcher_selection) self._switcher.sig_search_text_available.connect( - self._handle_switcher_filtering) + self._handle_switcher_search) @on_plugin_teardown(plugin=Plugins.Editor) def on_editor_teardown(self): @@ -281,7 +281,7 @@ def on_switcher_teardown(self): self._switcher.sig_item_selected.disconnect( self._handle_switcher_selection) self._switcher.sig_search_text_available.disconnect( - self._handle_switcher_filtering) + self._handle_switcher_search) self._switcher = None def on_close(self, cancelable=False): @@ -535,19 +535,16 @@ def _handle_switcher_selection(self, item, mode, search_text): self.get_widget().handle_switcher_selection(item, mode, search_text) self._switcher.hide() - def _handle_switcher_filtering(self, search_text, items_data): + def _handle_switcher_search(self, search_text): """ Handle user typing in switcher to filter results. - Load switcher results when a search text is typed for projects. Parameters ---------- text: str The current search text in the switcher dialog box. - items_data: list - List of items shown in the switcher. """ - self.get_widget().handle_switcher_filtering(search_text, items_data) + self.get_widget().handle_switcher_search(search_text) def _display_items_in_switcher(self, items, setup=True): """Display a list of items in the switcher.""" diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 54fddd28c09..609317bf3e3 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -171,7 +171,6 @@ def __init__(self, name, plugin, parent=None): self.completions_available = False self._fzf = find_program('fzf') self._default_switcher_paths = [] - self._switcher_items_data = [] # -- Tree widget self.treewidget = ProjectExplorerTreeWidget(self, self.show_hscrollbar) @@ -666,7 +665,7 @@ def handle_switcher_selection(self, item, mode, search_text): # Open file in editor self.sig_open_file_requested.emit(item.get_data()) - def handle_switcher_filtering(self, search_text, items_data): + def handle_switcher_search(self, search_text): """ Handle user typing in switcher to filter results. @@ -675,11 +674,8 @@ def handle_switcher_filtering(self, search_text, items_data): ---------- text: str The current search text in the switcher dialog box. - items_data: list - List of items shown in the switcher. """ self._call_fzf(search_text) - self._switcher_items_data = items_data # ---- Public API for the LSP # ------------------------------------------------------------------------- @@ -1081,7 +1077,6 @@ def _display_paths_in_switcher(self, paths, setup=True): def _clear_switcher_paths(self): """Clear saved switcher results.""" self._default_switcher_paths = [] - self._switcher_items_data = [] # ============================================================================= # Tests diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index 160a68fedde..d0e294fa5ff 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -90,7 +90,7 @@ class Switcher(SpyderPluginV2): The selected mode (open files "", symbol "@" or line ":"). """ - sig_search_text_available = Signal(str, list) + sig_search_text_available = Signal(str) """ This signal is emitted when the user stops typing the search/filter text. @@ -98,8 +98,6 @@ class Switcher(SpyderPluginV2): ---------- search_text: str The current search/filter text. - items_data: list - List of items shown in the switcher. """ # --- SpyderPluginV2 API diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index e7096e40af3..9a71b399b06 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -139,7 +139,7 @@ class Switcher(QDialog): The selected mode (open files "", symbol "@" or line ":"). """ - sig_search_text_available = Signal(str, list) + sig_search_text_available = Signal(str) """ This signal is emitted when the user stops typing in the filter line edit. @@ -147,8 +147,6 @@ class Switcher(QDialog): ---------- search_text: str The current search text. - items_data: list - List of items shown in the switcher. """ _MAX_NUM_ITEMS = 15 @@ -478,7 +476,6 @@ def _on_search_text_changed(self): """Actions to take when the search text has changed.""" if not self._mode_on: search_text = clean_string(self.search_text()) - items_data = [] # Remove project rows and get data of editor items for row in range(self.model.rowCount() - 1, -1, -1): @@ -489,11 +486,8 @@ def _on_search_text_changed(self): if item._section == "Projects": self.model.removeRow(row) continue - else: - if item._data is not None: - items_data.append(item._data._filename.lower()) - self.sig_search_text_available.emit(search_text, items_data) + self.sig_search_text_available.emit(search_text) else: self.setup() From ed7d6fac1d81e0fc691702c840bf64f4c3588fa6 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 18 Aug 2023 14:40:32 -0500 Subject: [PATCH 09/26] Remove lower case path names in the Switcher It's not clear why that was required before. --- .../plugins/editor/utils/switcher_manager.py | 23 ++++++++++--------- .../plugins/projects/widgets/main_widget.py | 6 ++--- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/spyder/plugins/editor/utils/switcher_manager.py b/spyder/plugins/editor/utils/switcher_manager.py index 4f570700733..830fac9e704 100644 --- a/spyder/plugins/editor/utils/switcher_manager.py +++ b/spyder/plugins/editor/utils/switcher_manager.py @@ -84,10 +84,8 @@ def create_editor_switcher(self): editor_list = editorstack.data.copy() editor_list.reverse() - paths = [data.filename.lower() - for data in editor_list] - save_statuses = [data.newly_created - for data in editor_list] + paths = [data.filename for data in editor_list] + save_statuses = [data.newly_created for data in editor_list] short_paths = shorten_paths(paths, save_statuses) for idx, data in enumerate(editor_list): @@ -99,14 +97,17 @@ def create_editor_switcher(self): if len(paths[idx]) > 75: path = short_paths[idx] else: - path = osp.dirname(data.filename.lower()) + path = osp.dirname(data.filename) last_item = (idx + 1 == len(editor_list)) - self._switcher.add_item(title=title, - description=path, - icon=icon, - section=self._section, - data=data, - last_item=last_item) + self._switcher.add_item( + title=title, + description=path, + icon=icon, + section=self._section, + data=data, + last_item=last_item + ) + self._switcher.set_current_row(0) def create_line_switcher(self): diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 609317bf3e3..9358255849f 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -1020,7 +1020,7 @@ def _process_fzf_output(self, worker, output, error): # List of tuples with the absolute path result_list = [ - osp.normpath(os.path.join(project_path, path)).lower() + osp.normpath(os.path.join(project_path, path)) for path in relative_path_list ] @@ -1039,10 +1039,10 @@ def _convert_paths_to_switcher_items(self, paths): Convert a list of paths to items that can be shown in the switcher. """ # The paths that are opened in the editor need to be excluded because - # they are shown already in the switcher in the "editor" section. + # they are shown already in the Editor section of the switcher. open_files = self.get_plugin()._get_open_filenames() for file in open_files: - normalized_path = osp.normpath(file).lower() + normalized_path = osp.normpath(file) if normalized_path in paths: paths.remove(normalized_path) From df972f485f77889406ff5b7e2eeb4eb58d712637 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 18 Aug 2023 14:58:33 -0500 Subject: [PATCH 10/26] Projects: Remove binary files from the results returned by fzf --- spyder/plugins/projects/widgets/main_widget.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 9358255849f..6c91449c81a 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -1024,6 +1024,11 @@ def _process_fzf_output(self, worker, output, error): for path in relative_path_list ] + # Remove binary files + result_list = [ + path for path in result_list if encoding.is_text_file(path) + ] + # Limit the number of results to not introduce lags when displaying # them in the switcher. if len(result_list) > self.MAX_SWITCHER_RESULTS: From 4840e174cffed4bdc20f836b0a64d4fc029c9296 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sat, 19 Aug 2023 12:55:58 -0500 Subject: [PATCH 11/26] Switcher: Detect when a project is active This is necessary to make some adjustments in the switcher when that's the case. --- spyder/plugins/switcher/plugin.py | 27 ++++++++++++++++++--- spyder/plugins/switcher/widgets/switcher.py | 1 + 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index d0e294fa5ff..cd2aeb5d986 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -39,7 +39,7 @@ class Switcher(SpyderPluginV2): """ NAME = "switcher" - OPTIONAL = [Plugins.MainMenu] + OPTIONAL = [Plugins.MainMenu, Plugins.Projects] CONTAINER_CLASS = SwitcherContainer CONF_SECTION = NAME CONF_FILE = False @@ -160,16 +160,27 @@ def on_main_menu_teardown(self): menu_id=ApplicationMenus.File ) - # --- Public API - # ------------------------------------------------------------------------ + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self._set_project_dir) + projects.sig_project_closed.connect(self._unset_project_dir) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardown(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self._set_project_dir) + projects.sig_project_closed.connect(self._unset_project_dir) + # ---- Public API + # ------------------------------------------------------------------------- # Switcher methods def set_placeholder_text(self, text): """Set the text appearing on the empty line edit.""" self._switcher.set_placeholder_text(text) def setup(self): - """Set-up list widget content based on the filtering.""" + """Setup list widget content based on filtering.""" self._switcher.setup() def open_switcher(self, symbol=False): @@ -247,3 +258,11 @@ def clear_modes(self): def set_search_text(self, string): """Set the content of the search text.""" self._switcher.set_search_text(string) + + # ---- Private API + # ------------------------------------------------------------------------- + def _set_project_dir(self, path): + self._switcher.current_project = path + + def _unset_project_dir(self, path): + self._switcher.current_project = None diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index 9a71b399b06..4b260c10af3 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -165,6 +165,7 @@ def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, self._mode_on = '' self._item_styles = item_styles self._item_separator_styles = item_separator_styles + self.current_project = None # Widgets self.edit = QLineEdit(self) From d96203f7874a9a7e121a9c39a3aa537e3ec27a7c Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sat, 19 Aug 2023 13:00:32 -0500 Subject: [PATCH 12/26] Switcher: Fix searching when no project is active Also, avoid a small lag when opening the symbol finder by calling the `setup` method directly after that. --- spyder/plugins/switcher/container.py | 5 ++++- spyder/plugins/switcher/widgets/switcher.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/switcher/container.py b/spyder/plugins/switcher/container.py index 24f055a7219..9becff78d96 100644 --- a/spyder/plugins/switcher/container.py +++ b/spyder/plugins/switcher/container.py @@ -58,11 +58,14 @@ def open_switcher(self, symbol=False): switcher.hide() return + # Set mode and setup if symbol: switcher.set_search_text('@') else: switcher.set_search_text('') - switcher.setup() + + # Setup + switcher.setup() # Set position mainwindow = self._plugin.get_main() diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index 4b260c10af3..1f337039df6 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -475,7 +475,7 @@ def set_search_text(self, string): def _on_search_text_changed(self): """Actions to take when the search text has changed.""" - if not self._mode_on: + if self.search_text() != "" and self.current_project is not None: search_text = clean_string(self.search_text()) # Remove project rows and get data of editor items From 6ee0ef33d35aacf90d0f6e87f4b3fa6963b37665 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sat, 19 Aug 2023 13:11:34 -0500 Subject: [PATCH 13/26] Switcher: Fix showing sections when a project is active Also, simplify when `setup_sections` is called: instead of doing it every time an item is added, now we do it after all of them have been sorted. --- .../plugins/editor/utils/switcher_manager.py | 11 +-- spyder/plugins/projects/plugin.py | 1 - spyder/plugins/switcher/widgets/item.py | 4 +- spyder/plugins/switcher/widgets/switcher.py | 86 +++++++++++-------- 4 files changed, 56 insertions(+), 46 deletions(-) diff --git a/spyder/plugins/editor/utils/switcher_manager.py b/spyder/plugins/editor/utils/switcher_manager.py index 830fac9e704..6481114cfc6 100644 --- a/spyder/plugins/editor/utils/switcher_manager.py +++ b/spyder/plugins/editor/utils/switcher_manager.py @@ -78,12 +78,7 @@ def create_editor_switcher(self): _('Start typing the name of an open file')) editorstack = self._editorstack() - - # Since editor open files are inserted at position 0, the - # list needs to be reversed so they're shown in order. editor_list = editorstack.data.copy() - editor_list.reverse() - paths = [data.filename for data in editor_list] save_statuses = [data.newly_created for data in editor_list] short_paths = shorten_paths(paths, save_statuses) @@ -99,17 +94,17 @@ def create_editor_switcher(self): else: path = osp.dirname(data.filename) last_item = (idx + 1 == len(editor_list)) + self._switcher.add_item( title=title, description=path, icon=icon, section=self._section, data=data, - last_item=last_item + last_item=last_item, + score=0 # To make these items appear above those from Projects ) - self._switcher.set_current_row(0) - def create_line_switcher(self): """Populate switcher with line info.""" editor = self._editor() diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index f6bfd97cc72..47fbfce3ae5 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -513,7 +513,6 @@ def _handle_switcher_modes(self, mode): return self.get_widget().display_default_switcher_items() - self._switcher.set_current_row(0) def _handle_switcher_selection(self, item, mode, search_text): """ diff --git a/spyder/plugins/switcher/widgets/item.py b/spyder/plugins/switcher/widgets/item.py index f19a487e96e..8702366daa5 100644 --- a/spyder/plugins/switcher/widgets/item.py +++ b/spyder/plugins/switcher/widgets/item.py @@ -232,7 +232,9 @@ def __init__(self, parent=None, icon=None, title=None, description=None, self._score = -1 self._action_item = action_item - self._section_visible = True + # Section visibility is computed by the setup_sections method of the + # switcher. + self._section_visible = False # Setup self.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index 1f337039df6..9f8eb7dce00 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -228,13 +228,7 @@ def _add_item(self, item, last_item=True, score=None): item.set_score(score) item.set_width(self._ITEM_WIDTH) - if isinstance(item, SwitcherItem): - if item._section == "Editor": - self.model.insertRow(0, item) - else: - self.model.appendRow(item) - else: - self.model.appendRow(item) + self.model.appendRow(item) if last_item: # Only set the current row to the first item when the added item is @@ -242,7 +236,6 @@ def _add_item(self, item, last_item=True, score=None): # adding multiple items self.set_current_row(0) self.set_height() - self.setup_sections() # ---- API def clear(self): @@ -303,8 +296,7 @@ def add_separator(self): self._add_item(item) def setup(self): - """Set-up list widget content based on the filtering.""" - # Check exited mode + """Setup list widget content based on filtering.""" mode = self._mode_on if mode: search_text = self.search_text()[len(mode):] @@ -317,6 +309,17 @@ def setup(self): self.clear() self.proxy.set_filter_by_score(False) self.sig_mode_selected.emit(self._mode_on) + + # This is necessary to show the Editor items first when results + # come back from the Editor and Projects. + self.proxy.sortBy('_score') + + # Show sections + self.setup_sections() + + # Give focus to the first row + self.set_current_row(0) + return # Check entered mode @@ -353,6 +356,7 @@ def setup(self): item.set_score(score_value) self.proxy.set_filter_by_score(True) + self.proxy.sortBy('_score') # Graphical setup self.setup_sections() @@ -365,37 +369,46 @@ def setup(self): self.set_height() def setup_sections(self): - """Set-up which sections appear on the item list.""" + """Setup which sections appear on the item list.""" mode = self._mode_on + sections = [] + if mode: search_text = self.search_text()[len(mode):] else: search_text = self.search_text() - if search_text: - for row in range(self.model.rowCount()): - item = self.model.item(row) - if isinstance(item, SwitcherItem): - item.set_section_visible(False) - else: - sections = [] - for row in range(self.model.rowCount()): - item = self.model.item(row) - if isinstance(item, SwitcherItem): - sections.append(item.get_section()) - item.set_section_visible(bool(search_text)) - else: - sections.append('') - - if row != 0: - visible = sections[row] != sections[row - 1] - if not self._is_separator(item): - item.set_section_visible(visible) - else: - item.set_section_visible(True) + for row in range(self.model.rowCount()): + item_row = row - self.proxy.sortBy('_score') - self.sig_item_changed.emit(self.current_item()) + # When there is search_text, we need to use the proxy model to get + # the actual item's row. + if search_text: + model_index = self.proxy.mapToSource(self.proxy.index(row, 0)) + item_row = model_index.row() + + # Get item + item = self.model.item(item_row) + + # When searching gives no result, the mapped items are None + if item is None: + continue + + # Get item section + if isinstance(item, SwitcherItem): + sections.append(item.get_section()) + else: + sections.append('') + + # Decide if we need to make the item's section visible + if row != 0: + visible = sections[row] != sections[row - 1] + if not self._is_separator(item): + item.set_section_visible(visible) + else: + # We need to remove this if when a mode has several sections + if not mode: + item.set_section_visible(True) def set_height(self): """Set height taking into account the number of items.""" @@ -447,8 +460,9 @@ def enter(self, itemClicked=None): item = self.model.item(model_index.row()) if item: mode = self._mode_on - self.sig_item_selected.emit(item, mode, - self.search_text()[len(mode):]) + self.sig_item_selected.emit( + item, mode, self.search_text()[len(mode):] + ) def accept(self): """Override Qt method.""" From 5099dfe5c3490a1f04278433ff743ac322dfb8bb Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sat, 19 Aug 2023 19:21:52 -0500 Subject: [PATCH 14/26] Switcher: Remove hard-coded projects section Also, change that section name to "Project" because we don't support multiple projects. --- spyder/plugins/projects/widgets/main_widget.py | 2 +- spyder/plugins/switcher/plugin.py | 3 +++ spyder/plugins/switcher/widgets/switcher.py | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 6c91449c81a..437c9e4a487 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -217,7 +217,7 @@ def __init__(self, name, plugin, parent=None): # ---- PluginMainWidget API # ------------------------------------------------------------------------- def get_title(self): - return _("Projects") + return _("Project") def setup(self): """Setup the widget.""" diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index cd2aeb5d986..47744cbe7ba 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -163,9 +163,12 @@ def on_main_menu_teardown(self): @on_plugin_available(plugin=Plugins.Projects) def on_projects_available(self): projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self._set_project_dir) projects.sig_project_closed.connect(self._unset_project_dir) + self._switcher.projects_section = projects.get_widget().get_title() + @on_plugin_teardown(plugin=Plugins.Projects) def on_projects_teardown(self): projects = self.get_plugin(Plugins.Projects) diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index 9f8eb7dce00..c4829185bde 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -166,6 +166,7 @@ def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, self._item_styles = item_styles self._item_separator_styles = item_separator_styles self.current_project = None + self.projects_section = None # Widgets self.edit = QLineEdit(self) @@ -352,7 +353,7 @@ def setup(self): # Results come from Projects in the right order, so we don't need # to sort them here. - if item._section != "Projects": + if item._section != self.projects_section: item.set_score(score_value) self.proxy.set_filter_by_score(True) @@ -498,7 +499,7 @@ def _on_search_text_changed(self): # backwards so that the indexes are not affected item = self.model.item(row) if isinstance(item, SwitcherItem): - if item._section == "Projects": + if item._section == self.projects_section: self.model.removeRow(row) continue From db685efb2d6f57e300e2a0c22a23fdbc275df499 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sun, 20 Aug 2023 10:31:33 -0500 Subject: [PATCH 15/26] Projects: Update default switcher paths on files creation, deletion and moves --- .../plugins/projects/widgets/main_widget.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 437c9e4a487..5b522d7b67f 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -198,8 +198,10 @@ def __init__(self, name, plugin, parent=None): self.sig_project_loaded.connect(self._setup_project) # This is necessary to populate the switcher with some default list of - # paths instead of computing it every open is shown. - self.sig_project_loaded.connect(lambda p: self._call_fzf()) + # paths instead of computing that list every time it's shown. + self.sig_project_loaded.connect( + lambda p: self._update_default_switcher_paths() + ) # Clear saved paths for the switcher when closing the project. self.sig_project_closed.connect(lambda p: self._clear_switcher_paths()) @@ -709,6 +711,9 @@ def handle_response(self, method, params): @Slot(str, bool) def file_created(self, src_file, is_dir): """Notify LSP server about file creation.""" + self._update_default_switcher_paths() + + # LSP specification only considers file updates if is_dir: return @@ -725,7 +730,8 @@ def file_created(self, src_file, is_dir): requires_response=False) def file_moved(self, src_file, dest_file, is_dir): """Notify LSP server about a file that is moved.""" - # LSP specification only considers file updates + self._update_default_switcher_paths() + if is_dir: return @@ -750,6 +756,8 @@ def file_moved(self, src_file, dest_file, is_dir): @Slot(str, bool) def file_deleted(self, src_file, is_dir): """Notify LSP server about file deletion.""" + self._update_default_switcher_paths() + if is_dir: return @@ -1083,6 +1091,11 @@ def _clear_switcher_paths(self): """Clear saved switcher results.""" self._default_switcher_paths = [] + def _update_default_switcher_paths(self): + """Update default paths to be shown in the switcher.""" + self._default_switcher_paths = [] + self._call_fzf() + # ============================================================================= # Tests # ============================================================================= From e932b7f11d1323693bbf6ea0f28d22d240b5a562 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sun, 20 Aug 2023 12:26:34 -0500 Subject: [PATCH 16/26] Testing: Fix test_switcher_project_files Also, expand that test to check that: - We make sections visible for the right number of items. - Searching in the switcher is working as expected. - The switcher is updated when removing files. --- spyder/app/tests/test_mainwindow.py | 69 +++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index b6c4f0dc43c..d845cb46670 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -2535,9 +2535,14 @@ def example_def_2(): @flaky(max_runs=3) -def test_switcher_project_files(main_window, qtbot, tmpdir): +def test_switcher_project_files(main_window, pytestconfig, qtbot, tmp_path): """Test the number of items in the switcher when a project is active.""" - # Wait until the window is fully up + # Disable pytest stdin capture to make calls to fzf work. Idea taken from: + # https://github.com/pytest-dev/pytest/issues/2189#issuecomment-449512764 + capmanager = pytestconfig.pluginmanager.getplugin('capturemanager') + capmanager.suspend_global_capture(in_=True) + + # Wait until the console is fully up shell = main_window.ipyconsole.get_current_shellwidget() qtbot.waitUntil( lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, @@ -2550,47 +2555,75 @@ def test_switcher_project_files(main_window, qtbot, tmpdir): editorstack = main_window.editor.get_current_editorstack() # Create a temp project directory - project_dir = to_text_string(tmpdir.mkdir('test')) + project_dir = tmp_path / 'test-projects-switcher' + project_dir.mkdir() # Create project with qtbot.waitSignal(projects.sig_project_loaded): - projects.create_project(project_dir) + projects.create_project(str(project_dir)) - # Create four empty files in the project dir - for i in range(3): - main_window.editor.new("test_file"+str(i)+".py") + # Create some empty files in the project dir + n_files_project = 3 + for i in range(n_files_project): + fpath = project_dir / f"test_file{i}.py" + fpath.touch() + # Check that the switcher has been populated in Projects + qtbot.waitUntil( + lambda: projects.get_widget()._default_switcher_paths != [], + timeout=1000 + ) + + # Assert that the number of items in the switcher is correct switcher.open_switcher() - n_files_project = len(projects.get_project_filenames()) n_files_open = editorstack.get_stack_count() + assert switcher.count() == n_files_open + n_files_project + switcher.on_close() - # Assert that the number of items in the switcher is correct - assert switcher_widget.model.rowCount() == n_files_open + n_files_project + # Assert only two items have visible sections + switcher.open_switcher() + + sections = [] + for row in range(switcher.count()): + item = switcher_widget.model.item(row) + if item._section_visible: + sections.append(item.get_section()) + + assert set(sections) == {"Editor", "Project"} switcher.on_close() - # Close all files opened in editorstack - main_window.editor.close_all_files() + # Assert searching text in the switcher works as expected + switcher.open_switcher() + switcher.set_search_text('0') + qtbot.wait(500) + assert switcher.count() == 1 + switcher.on_close() + # Remove project file and check the switcher is updated + n_files_project -= 1 + os.remove(osp.join(str(project_dir), 'test_file1.py')) switcher.open_switcher() - n_files_project = len(projects.get_project_filenames()) - n_files_open = editorstack.get_stack_count() - assert switcher_widget.model.rowCount() == n_files_open + n_files_project + qtbot.wait(500) + assert switcher.count() == n_files_open + n_files_project switcher.on_close() # Select file in the project explorer idx = projects.get_widget().treewidget.get_index( - osp.join(project_dir, 'test_file0.py')) + osp.join(str(project_dir), 'test_file0.py') + ) projects.get_widget().treewidget.setCurrentIndex(idx) # Press Enter there qtbot.keyClick(projects.get_widget().treewidget, Qt.Key_Enter) switcher.open_switcher() - n_files_project = len(projects.get_project_filenames()) n_files_open = editorstack.get_stack_count() - assert switcher_widget.model.rowCount() == n_files_open + n_files_project + assert switcher.count() == n_files_open + n_files_project - 1 switcher.on_close() + # Resume capturing + capmanager.resume_global_capture() + @flaky(max_runs=3) @pytest.mark.skipif(sys.platform == 'darwin', From 843535f7762edf690aef8424345bc64b33674155 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sun, 20 Aug 2023 13:06:22 -0500 Subject: [PATCH 17/26] App: Set app and monospace interface fonts when running single tests That avoids a ton of Qt warnings to be displayed. --- spyder/app/utils.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/spyder/app/utils.py b/spyder/app/utils.py index 5ff9c51f95c..1ef905bea9f 100644 --- a/spyder/app/utils.py +++ b/spyder/app/utils.py @@ -275,8 +275,20 @@ def create_application(): # The try/except is necessary to run the main window tests on their own. try: app.set_font() - except AttributeError: - pass + except AttributeError as error: + if running_under_pytest(): + # Set font options to avoid a ton of Qt warnings when running tests + app_family = app.font().family() + app_size = app.font().pointSize() + CONF.set('appearance', 'app_font/family', app_family) + CONF.set('appearance', 'app_font/size', app_size) + + from spyder.config.fonts import MEDIUM, MONOSPACE + CONF.set('appearance', 'monospace_app_font/family', MONOSPACE[0]) + CONF.set('appearance', 'monospace_app_font/size', MEDIUM) + else: + # Raise in case the error is valid + raise error # Required for correct icon on GNOME/Wayland: if hasattr(app, 'setDesktopFileName'): From 3843986722a69a029f3b4ed17c950827568d2300 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 21 Aug 2023 20:30:15 -0500 Subject: [PATCH 18/26] Projects: Filter switcher files according to their extension That's much faster than trying to detect if they are binary to exclude them. --- spyder/config/utils.py | 12 ++++++------ spyder/plugins/projects/widgets/main_widget.py | 11 ++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/spyder/config/utils.py b/spyder/config/utils.py index 6f7738f5a0a..eb5b77b5e36 100644 --- a/spyder/config/utils.py +++ b/spyder/config/utils.py @@ -116,11 +116,11 @@ def get_filter(filetypes, ext): return '' -def get_edit_filetypes(): +def get_edit_filetypes(ignore_pygments_extensions=True): """Get all file types supported by the Editor""" - # The filter details are not hidden on Windows, so we can't use - # all Pygments extensions on that platform - if os.name == 'nt': + # The filter details are not hidden on Windows, so we can't use all + # Pygments extensions on that platform. + if os.name == 'nt' and ignore_pygments_extensions: supported_exts = [] else: try: @@ -154,8 +154,8 @@ def get_edit_extensions(): Return extensions associated with the file types supported by the Editor """ - edit_filetypes = get_edit_filetypes() - return _get_extensions(edit_filetypes)+[''] + edit_filetypes = get_edit_filetypes(ignore_pygments_extensions=False) + return _get_extensions(edit_filetypes) + [''] #============================================================================== diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 5b522d7b67f..e58b36eaeb6 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -27,6 +27,7 @@ from spyder.api.widgets.main_widget import PluginMainWidget from spyder.config.base import ( get_home_dir, get_project_config_folder, running_under_pytest) +from spyder.config.utils import get_edit_extensions from spyder.plugins.completion.api import ( CompletionRequestTypes, FileChangeType) from spyder.plugins.completion.decorators import ( @@ -191,9 +192,12 @@ def __init__(self, name, plugin, parent=None): self.watcher = WorkspaceWatcher(self) self.watcher.connect_signals(self) - # To manage the worker that calls fzf + # -- Worker manager for calls to fzf self._worker_manager = WorkerManager(self) + # -- List of possible file extensions that can be opened in the Editor + self._edit_extensions = get_edit_extensions() + # -- Signals self.sig_project_loaded.connect(self._setup_project) @@ -1032,9 +1036,10 @@ def _process_fzf_output(self, worker, output, error): for path in relative_path_list ] - # Remove binary files + # Filter files that can be opened in the editor result_list = [ - path for path in result_list if encoding.is_text_file(path) + path for path in result_list + if osp.splitext(path)[1] in self._edit_extensions ] # Limit the number of results to not introduce lags when displaying From e5483aebd16bc156da739f13ae77f87ed676fdb5 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Wed, 23 Aug 2023 11:01:48 -0500 Subject: [PATCH 19/26] Projects: Prevent showing project path when fzf results are empty This only happened on Windows --- spyder/plugins/projects/widgets/main_widget.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index e58b36eaeb6..ac054d0faa1 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -1028,9 +1028,11 @@ def _process_fzf_output(self, worker, output, error): return relative_path_list = output.decode('utf-8').strip().split("\n") - project_path = self.get_active_project_path() + if relative_path_list == ['']: + return # List of tuples with the absolute path + project_path = self.get_active_project_path() result_list = [ osp.normpath(os.path.join(project_path, path)) for path in relative_path_list @@ -1057,7 +1059,7 @@ def _convert_paths_to_switcher_items(self, paths): Convert a list of paths to items that can be shown in the switcher. """ # The paths that are opened in the editor need to be excluded because - # they are shown already in the Editor section of the switcher. + # they are already shown in the Editor section of the switcher. open_files = self.get_plugin()._get_open_filenames() for file in open_files: normalized_path = osp.normpath(file) From b3624fef9ab0fd3635f2c66692ec2ed08dcccd76 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Wed, 23 Aug 2023 11:52:10 -0500 Subject: [PATCH 20/26] Switcher: Prevent to populate the switcher when closing it --- spyder/plugins/switcher/widgets/switcher.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index c4829185bde..ccbde4a0897 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -407,7 +407,7 @@ def setup_sections(self): if not self._is_separator(item): item.set_section_visible(visible) else: - # We need to remove this if when a mode has several sections + # We need to remove this when a mode has several sections if not mode: item.set_section_visible(True) @@ -471,14 +471,15 @@ def accept(self): def reject(self): """Override Qt method.""" + # This prevents calling _on_search_text_changed, which unnecessarily + # tries to populate the switcher when we're closing it. + self.edit.blockSignals(True) self.set_search_text('') + self.edit.blockSignals(False) + self.sig_rejected.emit() super(Switcher, self).reject() - def resizeEvent(self, event): - """Override Qt method.""" - super(Switcher, self).resizeEvent(event) - # ---- Helper methods: Lineedit widget def search_text(self): """Get the normalized (lowecase) content of the search text.""" From 36a39c7f76ef9cfdaab78275921ffb472a49bbae Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 24 Aug 2023 19:45:01 -0500 Subject: [PATCH 21/26] Switcher: Add a new public method to remove all items in a section - This helps to simplify the _on_search_text_changed method of its widget. - Use that method from Projects to remove the items added by it before painting new ones. --- spyder/plugins/projects/plugin.py | 18 +++++++++++-- .../plugins/projects/widgets/main_widget.py | 12 +++++---- spyder/plugins/switcher/plugin.py | 20 ++++----------- spyder/plugins/switcher/widgets/switcher.py | 25 +++++++++---------- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 47fbfce3ae5..4b15ec412a1 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -545,8 +545,22 @@ def _handle_switcher_search(self, search_text): """ self.get_widget().handle_switcher_search(search_text) - def _display_items_in_switcher(self, items, setup=True): - """Display a list of items in the switcher.""" + def _display_items_in_switcher(self, items, setup, clear_section): + """ + Display a list of items in the switcher. + + Parameters + ---------- + items: list + Items to display. + setup: bool + Call the switcher's setup after adding the items. + clear_section: bool + Clear Projects section before adding the items. + """ + if clear_section: + self._switcher.remove_section(self.get_widget().get_title()) + for (title, description, icon, section, path, is_last_item) in items: self._switcher.add_item( title=title, diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index ac054d0faa1..0bd73cb15e9 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -645,7 +645,7 @@ def display_default_switcher_items(self): return self._display_paths_in_switcher( - self._default_switcher_paths, setup=False + self._default_switcher_paths, setup=False, clear_section=False ) def handle_switcher_selection(self, item, mode, search_text): @@ -1052,7 +1052,9 @@ def _process_fzf_output(self, worker, output, error): if not self._default_switcher_paths: self._default_switcher_paths = result_list else: - self._display_paths_in_switcher(result_list) + self._display_paths_in_switcher( + result_list, setup=True, clear_section=True + ) def _convert_paths_to_switcher_items(self, paths): """ @@ -1086,13 +1088,13 @@ def _convert_paths_to_switcher_items(self, paths): return items - def _display_paths_in_switcher(self, paths, setup=True): + def _display_paths_in_switcher(self, paths, setup, clear_section): """Display a list of paths in the switcher.""" items = self._convert_paths_to_switcher_items(paths) # Call directly the plugin's method instead of emitting a signal - # because it's faster - self._plugin._display_items_in_switcher(items, setup=setup) + # because it's faster. + self._plugin._display_items_in_switcher(items, setup, clear_section) def _clear_switcher_paths(self): """Clear saved switcher results.""" diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index 47744cbe7ba..acf1d88ee66 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -163,17 +163,11 @@ def on_main_menu_teardown(self): @on_plugin_available(plugin=Plugins.Projects) def on_projects_available(self): projects = self.get_plugin(Plugins.Projects) - - projects.sig_project_loaded.connect(self._set_project_dir) - projects.sig_project_closed.connect(self._unset_project_dir) - self._switcher.projects_section = projects.get_widget().get_title() @on_plugin_teardown(plugin=Plugins.Projects) def on_projects_teardown(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self._set_project_dir) - projects.sig_project_closed.connect(self._unset_project_dir) + self._switcher.projects_section = None # ---- Public API # ------------------------------------------------------------------------- @@ -240,6 +234,10 @@ def count(self): """Get the item count in the list widget.""" return self._switcher.count() + def remove_section(self, section): + """Remove all items in a section of the switcher.""" + self._switcher.remove_section(section) + # Mode methods def add_mode(self, token, description): """Add mode by token key and description.""" @@ -261,11 +259,3 @@ def clear_modes(self): def set_search_text(self, string): """Set the content of the search text.""" self._switcher.set_search_text(string) - - # ---- Private API - # ------------------------------------------------------------------------- - def _set_project_dir(self, path): - self._switcher.current_project = path - - def _unset_project_dir(self, path): - self._switcher.current_project = None diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index ccbde4a0897..e23bdb90c2c 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -165,7 +165,6 @@ def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, self._mode_on = '' self._item_styles = item_styles self._item_separator_styles = item_separator_styles - self.current_project = None self.projects_section = None # Widgets @@ -411,6 +410,17 @@ def setup_sections(self): if not mode: item.set_section_visible(True) + def remove_section(self, section): + """Remove all items in a section of the switcher.""" + # As we are removing items from the model, we need to iterate backwards + # so that the indexes are not affected. + for row in range(self.model.rowCount() - 1, -1, -1): + item = self.model.item(row) + if isinstance(item, SwitcherItem): + if item._section == section: + self.model.removeRow(row) + continue + def set_height(self): """Set height taking into account the number of items.""" if self.count() >= self._MAX_NUM_ITEMS: @@ -491,19 +501,8 @@ def set_search_text(self, string): def _on_search_text_changed(self): """Actions to take when the search text has changed.""" - if self.search_text() != "" and self.current_project is not None: + if self.search_text() != "": search_text = clean_string(self.search_text()) - - # Remove project rows and get data of editor items - for row in range(self.model.rowCount() - 1, -1, -1): - # As we are removing items from the model, we need to iterate - # backwards so that the indexes are not affected - item = self.model.item(row) - if isinstance(item, SwitcherItem): - if item._section == self.projects_section: - self.model.removeRow(row) - continue - self.sig_search_text_available.emit(search_text) else: self.setup() From 60466aa7ed9907b3a66a53ad6d239622366d33d2 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 24 Aug 2023 19:50:33 -0500 Subject: [PATCH 22/26] Editor: Connect to the Switcher's sig_search_text_available signal That way its results can be filtered when fzf is not present. --- spyder/plugins/editor/utils/switcher_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spyder/plugins/editor/utils/switcher_manager.py b/spyder/plugins/editor/utils/switcher_manager.py index 6481114cfc6..6f8e1d40ffd 100644 --- a/spyder/plugins/editor/utils/switcher_manager.py +++ b/spyder/plugins/editor/utils/switcher_manager.py @@ -61,6 +61,9 @@ def setup_switcher(self): self._switcher.sig_rejected.connect(self.handle_switcher_rejection) self._switcher.sig_item_changed.connect( self.handle_switcher_item_change) + self._switcher.sig_search_text_available.connect( + lambda text: self._switcher.setup() + ) def handle_switcher_modes(self, mode): """Handle switcher for registered modes.""" From 548096656a219f241e174f34f3e84460998b0092 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 24 Aug 2023 20:31:58 -0500 Subject: [PATCH 23/26] Switcher: Add attribute to items to check if their score can be changed --- spyder/plugins/switcher/plugin.py | 4 ++-- spyder/plugins/switcher/widgets/item.py | 19 ++++++++++--------- spyder/plugins/switcher/widgets/switcher.py | 18 +++++++++--------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index acf1d88ee66..a2eba60fc87 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -212,11 +212,11 @@ def current_item(self): def add_item(self, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, action_item=False, - last_item=True, score=None): + last_item=True, score=None, use_score=True): """Add a switcher list item.""" self._switcher.add_item(icon, title, description, shortcut, section, data, tool_tip, action_item, - last_item, score) + last_item, score, use_score) def set_current_row(self, row): """Set the current selected row in the switcher.""" diff --git a/spyder/plugins/switcher/widgets/item.py b/spyder/plugins/switcher/widgets/item.py index 8702366daa5..e7cec5e3632 100644 --- a/spyder/plugins/switcher/widgets/item.py +++ b/spyder/plugins/switcher/widgets/item.py @@ -28,9 +28,9 @@ class SwitcherBaseItem(QStandardItem): _STYLES = None _TEMPLATE = None - def __init__(self, parent=None, styles=_STYLES): + def __init__(self, parent=None, styles=_STYLES, use_score=True): """Create basic List Item.""" - super(SwitcherBaseItem, self).__init__() + super().__init__() # Style self._width = self._WIDTH @@ -39,6 +39,7 @@ def __init__(self, parent=None, styles=_STYLES): self._action_item = False self._score = -1 self._height = self._get_height() + self._use_score = use_score # Setup # self._height is a float from QSizeF but @@ -84,8 +85,9 @@ def get_score(self): def set_score(self, value): """Set the search text fuzzy match score.""" - self._score = value - self._set_rendered_text() + if self._use_score: + self._score = value + self._set_rendered_text() def is_action_item(self): """Return whether the item is of action type.""" @@ -122,8 +124,7 @@ class SwitcherSeparatorItem(SwitcherBaseItem): def __init__(self, parent=None, styles=_STYLES): """Separator Item represented as
.""" - super(SwitcherSeparatorItem, self).__init__(parent=parent, - styles=styles) + super().__init__(parent=parent, styles=styles) self.setFlags(Qt.NoItemFlags) self._set_rendered_text() @@ -218,9 +219,9 @@ class SwitcherItem(SwitcherBaseItem): def __init__(self, parent=None, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, - action_item=False, styles=_STYLES): + action_item=False, styles=_STYLES, score=-1, use_score=True): """Switcher item with title, description, shortcut and section.""" - super(SwitcherItem, self).__init__(parent=parent, styles=styles) + super().__init__(parent=parent, styles=styles, use_score=use_score) self._title = title if title else '' self._rich_title = '' @@ -229,7 +230,7 @@ def __init__(self, parent=None, icon=None, title=None, description=None, self._section = section if section else '' self._icon = icon self._data = data - self._score = -1 + self._score = score self._action_item = action_item # Section visibility is computed by the setup_sections method of the diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index e23bdb90c2c..b4e16b8e006 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -222,11 +222,8 @@ def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, self.edit.setFocus() # ---- Helper methods - def _add_item(self, item, last_item=True, score=None): + def _add_item(self, item, last_item=True): """Perform common actions when adding items.""" - if score is not None: - item.set_score(score) - item.set_width(self._ITEM_WIDTH) self.model.appendRow(item) @@ -273,7 +270,7 @@ def clear_modes(self): def add_item(self, icon=None, title=None, description=None, shortcut=None, section=None, data=None, tool_tip=None, action_item=False, - last_item=True, score=None): + last_item=True, score=-1, use_score=True): """Add switcher list item.""" item = SwitcherItem( parent=self.list, @@ -285,14 +282,17 @@ def add_item(self, icon=None, title=None, description=None, shortcut=None, section=section, action_item=action_item, tool_tip=tool_tip, - styles=self._item_styles + styles=self._item_styles, + score=score, + use_score=use_score ) - self._add_item(item, last_item=last_item, score=score) + self._add_item(item, last_item=last_item) def add_separator(self): """Add separator item.""" - item = SwitcherSeparatorItem(parent=self.list, - styles=self._item_separator_styles) + item = SwitcherSeparatorItem( + parent=self.list, styles=self._item_separator_styles + ) self._add_item(item) def setup(self): From e6140b1324d1df09c3707060bfe50efcf7c44b81 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 24 Aug 2023 20:38:41 -0500 Subject: [PATCH 24/26] Switcher: Remove direct dependency on Projects That created a special case for Projects, which is not really necessary. --- spyder/plugins/projects/plugin.py | 3 ++- spyder/plugins/switcher/plugin.py | 11 +---------- spyder/plugins/switcher/widgets/switcher.py | 6 +----- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 4b15ec412a1..f5e31358ec8 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -569,7 +569,8 @@ def _display_items_in_switcher(self, items, setup, clear_section): section=section, data=path, last_item=is_last_item, - score=1e10 # To make the editor results appear first + score=1e10, # To make the editor results appear first + use_score=False # Results come from fzf in the right order ) if setup: diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index a2eba60fc87..14164a5de40 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -39,7 +39,7 @@ class Switcher(SpyderPluginV2): """ NAME = "switcher" - OPTIONAL = [Plugins.MainMenu, Plugins.Projects] + OPTIONAL = [Plugins.MainMenu] CONTAINER_CLASS = SwitcherContainer CONF_SECTION = NAME CONF_FILE = False @@ -160,15 +160,6 @@ def on_main_menu_teardown(self): menu_id=ApplicationMenus.File ) - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - projects = self.get_plugin(Plugins.Projects) - self._switcher.projects_section = projects.get_widget().get_title() - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardown(self): - self._switcher.projects_section = None - # ---- Public API # ------------------------------------------------------------------------- # Switcher methods diff --git a/spyder/plugins/switcher/widgets/switcher.py b/spyder/plugins/switcher/widgets/switcher.py index b4e16b8e006..7693c5c6518 100644 --- a/spyder/plugins/switcher/widgets/switcher.py +++ b/spyder/plugins/switcher/widgets/switcher.py @@ -165,7 +165,6 @@ def __init__(self, parent, help_text=None, item_styles=ITEM_STYLES, self._mode_on = '' self._item_styles = item_styles self._item_separator_styles = item_separator_styles - self.projects_section = None # Widgets self.edit = QLineEdit(self) @@ -350,10 +349,7 @@ def setup(self): rich_title = rich_title.replace(" ", " ") item.set_rich_title(rich_title) - # Results come from Projects in the right order, so we don't need - # to sort them here. - if item._section != self.projects_section: - item.set_score(score_value) + item.set_score(score_value) self.proxy.set_filter_by_score(True) self.proxy.sortBy('_score') From a36cc6f7ca4d4beffc4c9774fab278316b8852f8 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sat, 26 Aug 2023 11:21:55 -0500 Subject: [PATCH 25/26] Projects: Fix clearing the switcher when fzf can't find any match --- spyder/plugins/projects/widgets/main_widget.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/spyder/plugins/projects/widgets/main_widget.py b/spyder/plugins/projects/widgets/main_widget.py index 0bd73cb15e9..da82b654782 100644 --- a/spyder/plugins/projects/widgets/main_widget.py +++ b/spyder/plugins/projects/widgets/main_widget.py @@ -1027,16 +1027,18 @@ def _process_fzf_output(self, worker, output, error): if output is None or error: return + # Get list of paths from fzf output relative_path_list = output.decode('utf-8').strip().split("\n") - if relative_path_list == ['']: - return - # List of tuples with the absolute path - project_path = self.get_active_project_path() - result_list = [ - osp.normpath(os.path.join(project_path, path)) - for path in relative_path_list - ] + # List of results with absolute path + if relative_path_list != ['']: + project_path = self.get_active_project_path() + result_list = [ + osp.normpath(os.path.join(project_path, path)) + for path in relative_path_list + ] + else: + result_list = [] # Filter files that can be opened in the editor result_list = [ From d51c98d5612afe9fe6f251823b17d236b351f92e Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sat, 26 Aug 2023 11:39:45 -0500 Subject: [PATCH 26/26] Testing: Expand test that checks Switcher/Projects integration This includes new cases for binary and nonexistent files, and missing fzf. --- spyder/app/tests/test_mainwindow.py | 60 ++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index d845cb46670..c3bc44e982f 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -14,6 +14,7 @@ import gc import os import os.path as osp +from pathlib import Path import random import re import shutil @@ -2535,8 +2536,9 @@ def example_def_2(): @flaky(max_runs=3) -def test_switcher_project_files(main_window, pytestconfig, qtbot, tmp_path): - """Test the number of items in the switcher when a project is active.""" +def test_switcher_projects_integration(main_window, pytestconfig, qtbot, + tmp_path): + """Test integration between the Switcher and Projects plugins.""" # Disable pytest stdin capture to make calls to fzf work. Idea taken from: # https://github.com/pytest-dev/pytest/issues/2189#issuecomment-449512764 capmanager = pytestconfig.pluginmanager.getplugin('capturemanager') @@ -2558,16 +2560,22 @@ def test_switcher_project_files(main_window, pytestconfig, qtbot, tmp_path): project_dir = tmp_path / 'test-projects-switcher' project_dir.mkdir() - # Create project - with qtbot.waitSignal(projects.sig_project_loaded): - projects.create_project(str(project_dir)) - # Create some empty files in the project dir n_files_project = 3 for i in range(n_files_project): fpath = project_dir / f"test_file{i}.py" fpath.touch() + # Copy binary file from our source tree to the project to check it's not + # displayed in the switcher. + binary_file = Path(LOCATION).parents[1] / 'images' / 'windows_app_icon.ico' + binary_file_copy = project_dir / 'windows.ico' + shutil.copyfile(binary_file, binary_file_copy) + + # Create project + with qtbot.waitSignal(projects.sig_project_loaded): + projects.create_project(str(project_dir)) + # Check that the switcher has been populated in Projects qtbot.waitUntil( lambda: projects.get_widget()._default_switcher_paths != [], @@ -2589,7 +2597,7 @@ def test_switcher_project_files(main_window, pytestconfig, qtbot, tmp_path): if item._section_visible: sections.append(item.get_section()) - assert set(sections) == {"Editor", "Project"} + assert len(sections) == 2 switcher.on_close() # Assert searching text in the switcher works as expected @@ -2599,21 +2607,34 @@ def test_switcher_project_files(main_window, pytestconfig, qtbot, tmp_path): assert switcher.count() == 1 switcher.on_close() + # Assert searching for a non-existent file leaves the switcher empty + switcher.open_switcher() + switcher.set_search_text('foo') + qtbot.wait(500) + assert switcher.count() == 0 + switcher.on_close() + + # Assert searching for a binary file leaves the switcher empty + switcher.open_switcher() + switcher.set_search_text('windows') + qtbot.wait(500) + assert switcher.count() == 0 + switcher.on_close() + # Remove project file and check the switcher is updated n_files_project -= 1 - os.remove(osp.join(str(project_dir), 'test_file1.py')) - switcher.open_switcher() + os.remove(str(project_dir / 'test_file1.py')) qtbot.wait(500) + switcher.open_switcher() assert switcher.count() == n_files_open + n_files_project switcher.on_close() - # Select file in the project explorer + # Check that a project file opened in the editor is not shown twice in the + # switcher idx = projects.get_widget().treewidget.get_index( - osp.join(str(project_dir), 'test_file0.py') + str(project_dir / 'test_file0.py') ) projects.get_widget().treewidget.setCurrentIndex(idx) - - # Press Enter there qtbot.keyClick(projects.get_widget().treewidget, Qt.Key_Enter) switcher.open_switcher() @@ -2621,6 +2642,19 @@ def test_switcher_project_files(main_window, pytestconfig, qtbot, tmp_path): assert switcher.count() == n_files_open + n_files_project - 1 switcher.on_close() + # Check the switcher works without fzf + fzf = projects.get_widget()._fzf + projects.get_widget()._fzf = None + projects.get_widget()._default_switcher_paths = [] + + switcher.open_switcher() + switcher.set_search_text('0') + qtbot.wait(500) + assert switcher.count() == 1 + switcher.on_close() + + projects.get_widget()._fzf = fzf + # Resume capturing capmanager.resume_global_capture()