Repository: spyder-ide/spyder

Issue:
Paths in the "Search in" combobox of the "Find in File" plugin becomes corrupted
## Description of your problem

Related to PR #4822 that fixed Issues #4814 and #4809.

**What steps will reproduce the problem?**

1. I don't know... I'm sorry. I'll add more info in the future if I can. I'm using this feature a lot, so I'll keep my eyes opened!

**What is the expected output? What do you see instead?**

Sometimes, _Other directories_ in the  _Search in_ combobox of the _Find in File_ plugin becomes corrupted with no way of removing them. For instance, the `<PyQt5.QtWidgets.QListWidget...` shown in the screenshot below was actually `C:\Users\jsgosselin\spyder` before it became corrupted at some point.  This is the third time that this problem occurs for me. 

Besides, I think an option should be added below _Select other directory_ to clear the list.

![image](https://user-images.githubusercontent.com/10170372/30704150-ca6a9782-9ebf-11e7-9ac1-bc29d255b0c6.png)

**Please provide any additional information below**

## Versions and main components

* Spyder Version: branch 3.x and master
* Python Version: 3.6.2
* Qt Version: 5.9.1
* PyQt Version: 5.9
* Operating system: Windows 7 64 bits

## Dependencies

IPython >=4.0    :  6.1.0 (OK)
cython >=0.21    :  0.25.2 (OK)
jedi >=0.9.0     :  0.10.2 (OK)
nbconvert >=4.0  :  5.2.1 (OK)
numpy >=1.7      :  1.13.0 (OK)
pandas >=0.13.1  :  0.20.3 (OK)
pycodestyle >=2.3:  2.3.1 (OK)
pyflakes >=0.6.0 :  1.5.0 (OK)
pygments >=2.0   :  2.2.0 (OK)
pylint >=0.25    :  1.7.1 (OK)
qtconsole >=4.2.0:  4.4.0.dev (OK)
rope >=0.9.4     :  0.10.7 (OK)
sphinx >=0.6.6   :  1.6.2 (OK)
sympy >=0.7.3    :  None (NOK)


Discussion:
Here is a traceback:

```python
Traceback (most recent call last):
  File "C:\Users\jsgosselin\spyder\spyder\widgets\findinfiles.py", line 114, in run
    self.find_files_in_path(self.rootpath)
  File "C:\Users\jsgosselin\spyder\spyder\widgets\findinfiles.py", line 132, in find_files_in_path
    for path, dirs, files in os.walk(path):
  File "C:\Python36\lib\os.py", line 335, in walk
    top = fspath(top)
TypeError: expected str, bytes or os.PathLike object, not NoneType
```
@andfoy, please take a look at this one.
@andfoy Would you mind if I take a look at this? I would like to fix this bug for the next release.


Jean, please go ahead and fix it :+1: 
@ccordoba12 In addition to solve this issue, I had some ideas to improve how the `Search in` QComboBox works. What do you think?

- [x] When populating the `Search in` QComboBox, do not add the paths that do not exists anymore.
- [x] When hovering over an item of the QComboBox and pressing <kbd>Del</kbd>, remove the item from the list.
- [x] Add a  `Clear this list` option below `Select other directory`.
- [x] Show the full path when hovering over an item in the `Search in` QComboBox.
```python
  File "C:\Users\jsgosselin\spyder\spyder\widgets\findinfiles.py", line 408, in path_selection_changed
    self.external_path = item.path
AttributeError: 'QListWidgetItem' object has no attribute 'path'
```
Jean, I agree with all your suggestions.
The traceback posted in https://github.com/spyder-ide/spyder/issues/5306#issuecomment-334021119 does not seem to be related to the undesired behavior described in the OP.

```python
Traceback (most recent call last):
  File "C:\Users\jsgosselin\spyder\spyder\widgets\findinfiles.py", line 115, in run
    self.find_files_in_path(self.rootpath)
  File "C:\Users\jsgosselin\spyder\spyder\widgets\findinfiles.py", line 133, in find_files_in_path
    for path, dirs, files in os.walk(path):
  File "C:\Python36\lib\os.py", line 335, in walk
    top = fspath(top)
TypeError: expected str, bytes or os.PathLike object, not NoneType
```
To reproduce this error:
- Open a project in Spyder.
- Go in the `Find in Files` plugin and from the `Search in` combobox, select the `Project` option.
- Close the project.
- Start the search.

![fif_error1](https://user-images.githubusercontent.com/10170372/34446253-83263d82-eca7-11e7-9ffa-cd0d9c8d8094.gif)
The traceback posted in https://github.com/spyder-ide/spyder/issues/5306#issuecomment-353457679. does not seem to be related to the undesired behavior described in the OP. So far unfortunately, I have not been successful to produce a reproducible example of the issue described in the OP.

```python
Traceback (most recent call last):
  File "C:\Users\jsgosselin\spyder\spyder\widgets\findinfiles.py", line 408, in path_selection_changed
    item = self.path_selection_contents.item(idx)
AttributeError: 'QListWidgetItem' object has no attribute 'path'
```

To reproduce this error:
- Select a path in the `Search in` combobox.
- Change the selection by rolling up the mouse wheel until the separator below the `Select other directory` is hit.

![fif_error2](https://user-images.githubusercontent.com/10170372/34446429-df6cbdc2-eca8-11e7-874a-2da364140ac4.gif)
Ok, I found out how to reproduce the behaviour described in the OP 😂. This is happening when <kbd>Enter</kbd> is pressed when the focus is on the `Search in` combo-box and when an external path is selected.

The currently selected path is then moved at the beginning of the list, and an empty item is also added. This behaviour is explained by the `keyPressEvent` override in the [BaseComboBox](https://github.com/spyder-ide/spyder/blob/v3.2.5/spyder/widgets/comboboxes.py#L31), from which the `Search in` combo-box is inheriting

Because of this, the wrong information is saved in the configuration file when the `FindinFiles` plugin is closed afterward.  In the [get_options](https://github.com/spyder-ide/spyder/blob/v3.2.5/spyder/widgets/findinfiles.py#L417) method, the external paths are retrieved from index 6 of the `Search in` combo-box (see [here](https://github.com/spyder-ide/spyder/blob/v3.2.5/spyder/widgets/findinfiles.py#L478)). Due to what was explained above, the content that is supposed to be in index 1 to 5 is pushed down in the list. So running `to_text_string` on items that are not an instance of [ExternalPathItem](https://github.com/spyder-ide/spyder/blob/v3.2.5/spyder/widgets/findinfiles.py#L206), such as a separator, returns a string of the object such as `<PyQt5.QtWidgets.QListWidget...` 

![fif_error3](https://user-images.githubusercontent.com/10170372/34448337-4d6ef040-ecba-11e7-87d1-4fff7916a948.gif)
Great! So PR coming?
@ccordoba12 Do I fix the 3 issues I've found while investigating this (see https://github.com/spyder-ide/spyder/issues/5306#issuecomment-354492631, https://github.com/spyder-ide/spyder/issues/5306#issuecomment-354493874, and https://github.com/spyder-ide/spyder/issues/5306#issuecomment-354506402) and do I implement the 4 proposed enhancements (see https://github.com/spyder-ide/spyder/issues/5306#issuecomment-352950794) in the same PR or do I split this in smaller, individual PR?
Everything in one PR seems easier to me because all fixes/improvements are related.
@ccordoba12 Alright thank you. The PR is on the way :)
Ok, moving this one to 3.2.6 then :-)

Resolving patch:
diff --git a/spyder/widgets/findinfiles.py b/spyder/widgets/findinfiles.py
--- a/spyder/widgets/findinfiles.py
+++ b/spyder/widgets/findinfiles.py
@@ -24,17 +24,18 @@
 # Third party imports
 from qtpy.compat import getexistingdirectory
 from qtpy.QtGui import QAbstractTextDocumentLayout, QTextDocument
-from qtpy.QtCore import QMutex, QMutexLocker, Qt, QThread, Signal, Slot, QSize
-from qtpy.QtWidgets import (QHBoxLayout, QLabel, QListWidget, QSizePolicy,
-                            QTreeWidgetItem, QVBoxLayout, QWidget,
+from qtpy.QtCore import (QEvent, QMutex, QMutexLocker, QSize, Qt, QThread,
+                         Signal, Slot)
+from qtpy.QtWidgets import (QApplication, QComboBox, QHBoxLayout, QLabel,
+                            QMessageBox, QSizePolicy, QStyle,
                             QStyledItemDelegate, QStyleOptionViewItem,
-                            QApplication, QStyle, QListWidgetItem)
+                            QTreeWidgetItem, QVBoxLayout, QWidget)
 
 # Local imports
 from spyder.config.base import _
 from spyder.py3compat import to_text_string
 from spyder.utils import icon_manager as ima
-from spyder.utils.encoding import is_text_file
+from spyder.utils.encoding import is_text_file, to_unicode_from_fs
 from spyder.utils.misc import getcwd_or_home
 from spyder.widgets.comboboxes import PatternComboBox
 from spyder.widgets.onecolumntree import OneColumnTree
@@ -50,7 +51,9 @@
 CWD = 0
 PROJECT = 1
 FILE_PATH = 2
-EXTERNAL_PATH = 4
+SELECT_OTHER = 4
+CLEAR_LIST = 5
+EXTERNAL_PATHS = 7
 
 MAX_PATH_LENGTH = 60
 MAX_PATH_HISTORY = 15
@@ -203,21 +206,181 @@ def get_results(self):
         return self.results, self.pathlist, self.total_matches, self.error_flag
 
 
-class ExternalPathItem(QListWidgetItem):
-    def __init__(self, parent, path):
-        self.path = path
-        QListWidgetItem.__init__(self, self.__repr__(), parent)
+class SearchInComboBox(QComboBox):
+    """
+    Non editable combo box handling the path locations of the FindOptions
+    widget.
+    """
+    def __init__(self, external_path_history=[], parent=None):
+        super(SearchInComboBox, self).__init__(parent)
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+        self.setToolTip(_('Search directory'))
+        self.setEditable(False)
 
-    def __repr__(self):
-        if len(self.path) > MAX_PATH_LENGTH:
-            return truncate_path(self.path)
-        return self.path
+        self.path = ''
+        self.project_path = None
+        self.file_path = None
+        self.external_path = None
 
-    def __str__(self):
-        return self.__repr__()
+        self.addItem(_("Current working directory"))
+        ttip = ("Search in all files and directories present on the current"
+                " Spyder path")
+        self.setItemData(0, ttip, Qt.ToolTipRole)
 
-    def __unicode__(self):
-        return self.__repr__()
+        self.addItem(_("Project"))
+        ttip = _("Search in all files and directories present on the"
+                 " current project path (if opened)")
+        self.setItemData(1, ttip, Qt.ToolTipRole)
+        self.model().item(1, 0).setEnabled(False)
+
+        self.addItem(_("File").replace('&', ''))
+        ttip = _("Search in current opened file")
+        self.setItemData(2, ttip, Qt.ToolTipRole)
+
+        self.insertSeparator(3)
+
+        self.addItem(_("Select other directory"))
+        ttip = _("Search in other folder present on the file system")
+        self.setItemData(4, ttip, Qt.ToolTipRole)
+
+        self.addItem(_("Clear this list"))
+        ttip = _("Clear the list of other directories")
+        self.setItemData(5, ttip, Qt.ToolTipRole)
+
+        self.insertSeparator(6)
+
+        for path in external_path_history:
+            self.add_external_path(path)
+
+        self.currentIndexChanged.connect(self.path_selection_changed)
+        self.view().installEventFilter(self)
+
+    def add_external_path(self, path):
+        """
+        Adds an external path to the combobox if it exists on the file system.
+        If the path is already listed in the combobox, it is removed from its
+        current position and added back at the end. If the maximum number of
+        paths is reached, the oldest external path is removed from the list.
+        """
+        if not osp.exists(path):
+            return
+        self.removeItem(self.findText(path))
+        self.addItem(path)
+        self.setItemData(self.count() - 1, path, Qt.ToolTipRole)
+        while self.count() > MAX_PATH_HISTORY + EXTERNAL_PATHS:
+            self.removeItem(EXTERNAL_PATHS)
+
+    def get_external_paths(self):
+        """Returns a list of the external paths listed in the combobox."""
+        return [to_text_string(self.itemText(i))
+                for i in range(EXTERNAL_PATHS, self.count())]
+
+    def clear_external_paths(self):
+        """Remove all the external paths listed in the combobox."""
+        while self.count() > EXTERNAL_PATHS:
+            self.removeItem(EXTERNAL_PATHS)
+
+    def get_current_searchpath(self):
+        """
+        Returns the path corresponding to the currently selected item
+        in the combobox.
+        """
+        idx = self.currentIndex()
+        if idx == CWD:
+            return self.path
+        elif idx == PROJECT:
+            return self.project_path
+        elif idx == FILE_PATH:
+            return self.file_path
+        else:
+            return self.external_path
+
+    def is_file_search(self):
+        """Returns whether the current search path is a file."""
+        if self.currentIndex() == FILE_PATH:
+            return True
+        else:
+            return False
+
+    @Slot()
+    def path_selection_changed(self):
+        """Handles when the current index of the combobox changes."""
+        idx = self.currentIndex()
+        if idx == SELECT_OTHER:
+            external_path = self.select_directory()
+            if len(external_path) > 0:
+                self.add_external_path(external_path)
+                self.setCurrentIndex(self.count() - 1)
+            else:
+                self.setCurrentIndex(CWD)
+        elif idx == CLEAR_LIST:
+            reply = QMessageBox.question(
+                    self, _("Clear other directories"),
+                    _("Do you want to clear the list of other directories?"),
+                    QMessageBox.Yes | QMessageBox.No)
+            if reply == QMessageBox.Yes:
+                self.clear_external_paths()
+            self.setCurrentIndex(CWD)
+        elif idx >= EXTERNAL_PATHS:
+            self.external_path = to_text_string(self.itemText(idx))
+
+    @Slot()
+    def select_directory(self):
+        """Select directory"""
+        self.__redirect_stdio_emit(False)
+        directory = getexistingdirectory(
+                self, _("Select directory"), self.path)
+        if directory:
+            directory = to_unicode_from_fs(osp.abspath(directory))
+        self.__redirect_stdio_emit(True)
+        return directory
+
+    def set_project_path(self, path):
+        """
+        Sets the project path and disables the project search in the combobox
+        if the value of path is None.
+        """
+        if path is None:
+            self.project_path = None
+            self.model().item(PROJECT, 0).setEnabled(False)
+            if self.currentIndex() == PROJECT:
+                self.setCurrentIndex(CWD)
+        else:
+            path = osp.abspath(path)
+            self.project_path = path
+            self.model().item(PROJECT, 0).setEnabled(True)
+
+    def eventFilter(self, widget, event):
+        """Used to handle key events on the QListView of the combobox."""
+        if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Delete:
+            index = self.view().currentIndex().row()
+            if index >= EXTERNAL_PATHS:
+                # Remove item and update the view.
+                self.removeItem(index)
+                self.showPopup()
+                # Set the view selection so that it doesn't bounce around.
+                new_index = min(self.count() - 1, index)
+                new_index = 0 if new_index < EXTERNAL_PATHS else new_index
+                self.view().setCurrentIndex(self.model().index(new_index, 0))
+                self.setCurrentIndex(new_index)
+            return True
+        return QComboBox.eventFilter(self, widget, event)
+
+    def __redirect_stdio_emit(self, value):
+        """
+        Searches through the parent tree to see if it is possible to emit the
+        redirect_stdio signal.
+        This logic allows to test the SearchInComboBox select_directory method
+        outside of the FindInFiles plugin.
+        """
+        parent = self.parent()
+        while parent is not None:
+            try:
+                parent.redirect_stdio.emit(value)
+            except AttributeError:
+                parent = parent.parent()
+            else:
+                break
 
 
 class FindOptions(QWidget):
@@ -235,12 +398,6 @@ def __init__(self, parent, search_text, search_text_regexp, search_path,
         if search_path is None:
             search_path = getcwd_or_home()
 
-        self.path = ''
-        self.project_path = None
-        self.file_path = None
-        self.external_path = None
-        self.external_path_history = external_path_history
-
         if not isinstance(search_text, (list, tuple)):
             search_text = [search_text]
         if not isinstance(search_path, (list, tuple)):
@@ -311,44 +468,8 @@ def __init__(self, parent, search_text, search_text_regexp, search_path,
         hlayout3 = QHBoxLayout()
 
         search_on_label = QLabel(_("Search in:"))
-        self.path_selection_combo = PatternComboBox(self, exclude,
-                                                    _('Search directory'))
-        self.path_selection_combo.setEditable(False)
-        self.path_selection_contents = QListWidget(self.path_selection_combo)
-        self.path_selection_contents.hide()
-        self.path_selection_combo.setModel(
-            self.path_selection_contents.model())
-
-        self.path_selection_contents.addItem(_("Current working directory"))
-        item = self.path_selection_contents.item(0)
-        item.setToolTip(_("Search in all files and "
-                          "directories present on the"
-                          "current Spyder path"))
-
-        self.path_selection_contents.addItem(_("Project"))
-        item = self.path_selection_contents.item(1)
-        item.setToolTip(_("Search in all files and "
-                          "directories present on the"
-                          "current project path "
-                          "(If opened)"))
-        item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
-
-        self.path_selection_contents.addItem(_("File").replace('&', ''))
-        item = self.path_selection_contents.item(2)
-        item.setToolTip(_("Search in current opened file"))
-
-        self.path_selection_contents.addItem(_("Select other directory"))
-        item = self.path_selection_contents.item(3)
-        item.setToolTip(_("Search in other folder present on the file system"))
-
-        self.path_selection_combo.insertSeparator(3)
-        self.path_selection_combo.insertSeparator(5)
-        for path in external_path_history:
-            item = ExternalPathItem(None, path)
-            self.path_selection_contents.addItem(item)
-
-        self.path_selection_combo.currentIndexChanged.connect(
-            self.path_selection_changed)
+        self.path_selection_combo = SearchInComboBox(
+                external_path_history, parent)
 
         hlayout3.addWidget(search_on_label)
         hlayout3.addWidget(self.path_selection_combo)
@@ -382,28 +503,6 @@ def toggle_more_options(self, state):
         self.more_options.setIcon(icon)
         self.more_options.setToolTip(tip)
 
-    @Slot()
-    def path_selection_changed(self):
-        idx = self.path_selection_combo.currentIndex()
-        if idx == EXTERNAL_PATH:
-            external_path = self.select_directory()
-            if len(external_path) > 0:
-                item = ExternalPathItem(None, external_path)
-                self.path_selection_contents.addItem(item)
-
-                total_items = (self.path_selection_combo.count() -
-                               MAX_PATH_HISTORY)
-                for i in range(6, total_items):
-                    self.path_selection_contents.takeItem(i)
-
-                self.path_selection_combo.setCurrentIndex(
-                    self.path_selection_combo.count() - 1)
-            else:
-                self.path_selection_combo.setCurrentIndex(CWD)
-        elif idx > EXTERNAL_PATH:
-            item = self.path_selection_contents.item(idx)
-            self.external_path = item.path
-
     def update_combos(self):
         self.search_text.lineEdit().returnPressed.emit()
         self.exclude_pattern.lineEdit().returnPressed.emit()
@@ -440,17 +539,8 @@ def get_options(self, all=False):
         if not case_sensitive:
             texts = [(text[0].lower(), text[1]) for text in texts]
 
-        file_search = False
-        selection_idx = self.path_selection_combo.currentIndex()
-        if selection_idx == CWD:
-            path = self.path
-        elif selection_idx == PROJECT:
-            path = self.project_path
-        elif selection_idx == FILE_PATH:
-            path = self.file_path
-            file_search = True
-        else:
-            path = self.external_path
+        file_search = self.path_selection_combo.is_file_search()
+        path = self.path_selection_combo.get_current_searchpath()
 
         # Finding text occurrences
         if not exclude_re:
@@ -475,10 +565,7 @@ def get_options(self, all=False):
                            for index in range(self.search_text.count())]
             exclude = [to_text_string(self.exclude_pattern.itemText(index))
                        for index in range(self.exclude_pattern.count())]
-            path_history = [to_text_string(
-                            self.path_selection_contents.item(index))
-                            for index in range(
-                                6, self.path_selection_combo.count())]
+            path_history = self.path_selection_combo.get_external_paths()
             exclude_idx = self.exclude_pattern.currentIndex()
             more_options = self.more_options.isChecked()
             return (search_text, text_re, [],
@@ -487,32 +574,29 @@ def get_options(self, all=False):
         else:
             return (path, file_search, exclude, texts, text_re, case_sensitive)
 
-    @Slot()
-    def select_directory(self):
-        """Select directory"""
-        self.parent().redirect_stdio.emit(False)
-        directory = getexistingdirectory(self, _("Select directory"),
-                                         self.path)
-        if directory:
-            directory = to_text_string(osp.abspath(to_text_string(directory)))
-        self.parent().redirect_stdio.emit(True)
-        return directory
+    @property
+    def path(self):
+        return self.path_selection_combo.path
 
     def set_directory(self, directory):
-        self.path = to_text_string(osp.abspath(to_text_string(directory)))
+        self.path_selection_combo.path = osp.abspath(directory)
+
+    @property
+    def project_path(self):
+        return self.path_selection_combo.project_path
 
     def set_project_path(self, path):
-        self.project_path = to_text_string(osp.abspath(to_text_string(path)))
-        item = self.path_selection_contents.item(PROJECT)
-        item.setFlags(item.flags() | Qt.ItemIsEnabled)
+        self.path_selection_combo.set_project_path(path)
 
     def disable_project_search(self):
-        item = self.path_selection_contents.item(PROJECT)
-        item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
-        self.project_path = None
+        self.path_selection_combo.set_project_path(None)
+
+    @property
+    def file_path(self):
+        return self.path_selection_combo.file_path
 
     def set_file_path(self, path):
-        self.file_path = path
+        self.path_selection_combo.file_path = path
 
     def keyPressEvent(self, event):
         """Reimplemented to handle key events"""
@@ -927,10 +1011,19 @@ def search_complete(self, completed):
 def test():
     """Run Find in Files widget test"""
     from spyder.utils.qthelpers import qapplication
+    from os.path import dirname
     app = qapplication()
     widget = FindInFilesWidget(None)
     widget.resize(640, 480)
     widget.show()
+    external_paths = [
+            dirname(__file__),
+            dirname(dirname(__file__)),
+            dirname(dirname(dirname(__file__))),
+            dirname(dirname(dirname(dirname(__file__))))
+            ]
+    for path in external_paths:
+        widget.find_options.path_selection_combo.add_external_path(path)
     sys.exit(app.exec_())
 
 


Test patch:
diff --git a/spyder/plugins/tests/test_findinfiles.py b/spyder/plugins/tests/test_findinfiles.py
--- a/spyder/plugins/tests/test_findinfiles.py
+++ b/spyder/plugins/tests/test_findinfiles.py
@@ -9,12 +9,29 @@
 
 # Standard library imports
 import re
+import os
+import os.path as osp
 
 # 3rd party imports
 import pytest
 
 # Local imports
 from spyder.config.main import EXCLUDE_PATTERNS
+from spyder.plugins.findinfiles import FindInFiles
+from spyder.widgets.findinfiles import SELECT_OTHER
+
+LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__)))
+NONASCII_DIR = osp.join(LOCATION, u"èáïü Øαôå 字分误")
+if not osp.exists(NONASCII_DIR):
+    os.makedirs(NONASCII_DIR)
+
+
+@pytest.fixture
+def findinfiles_bot(qtbot):
+    """Set up SearchInComboBox combobox."""
+    findinfiles_plugin = FindInFiles()
+    qtbot.addWidget(findinfiles_plugin)
+    return findinfiles_plugin, qtbot
 
 
 def check_regex(patterns):
@@ -38,5 +55,45 @@ def test_exclude_patterns_are_valid_regex():
     assert all(checks)
 
 
+# ---- Tests for FindInFiles plugin
+
+def test_closing_plugin(qtbot, mocker):
+    """
+    Test that the external paths listed in the combobox are saved and loaded
+    correctly from the spyder config file.
+    """
+    findinfiles_plugin, qtbot = findinfiles_bot(qtbot)
+    path_selection_combo = findinfiles_plugin.find_options.path_selection_combo
+    path_selection_combo.clear_external_paths()
+    assert path_selection_combo.get_external_paths() == []
+
+    # Add external paths to the path_selection_combo.
+    expected_results = [
+            LOCATION,
+            osp.dirname(LOCATION),
+            osp.dirname(osp.dirname(LOCATION)),
+            NONASCII_DIR
+            ]
+    for external_path in expected_results:
+        mocker.patch('spyder.widgets.findinfiles.getexistingdirectory',
+                     return_value=external_path)
+        path_selection_combo.setCurrentIndex(SELECT_OTHER)
+    assert path_selection_combo.get_external_paths() == expected_results
+
+    # Force the options to be saved to the config file. Something needs to be
+    # set in the search_text combobox first or else the find in files options
+    # won't be save to the config file (see PR #6095).
+    findinfiles_plugin.find_options.search_text.set_current_text("test")
+    findinfiles_plugin.closing_plugin()
+    assert findinfiles_plugin.get_option('path_history') == expected_results
+
+    # Close and restart the plugin and assert that the external_path_history
+    # has been saved and loaded as expected.
+    findinfiles_plugin.close()
+    findinfiles_plugin, qtbot = findinfiles_bot(qtbot)
+    path_selection_combo = findinfiles_plugin.find_options.path_selection_combo
+    assert path_selection_combo.get_external_paths() == expected_results
+
+
 if __name__ == "__main__":
-    pytest.main()
+    pytest.main(['-x', osp.basename(__file__), '-v', '-rw'])
diff --git a/spyder/widgets/tests/test_findinfiles.py b/spyder/widgets/tests/test_findinfiles.py
--- a/spyder/widgets/tests/test_findinfiles.py
+++ b/spyder/widgets/tests/test_findinfiles.py
@@ -12,14 +12,21 @@
 import os
 import pytest
 import os.path as osp
-from pytestqt import qtbot
+
+# Third party imports
+from qtpy.QtCore import Qt
 
 # Local imports
-import spyder.widgets.findinfiles
-from spyder.widgets.findinfiles import FindInFilesWidget
+from spyder.widgets import findinfiles
+from spyder.widgets.findinfiles import (FindInFilesWidget, SearchInComboBox,
+                                        EXTERNAL_PATHS, SELECT_OTHER, CWD,
+                                        CLEAR_LIST, PROJECT, FILE_PATH,
+                                        QMessageBox)
 
-LOCATION = os.path.realpath(os.path.join(os.getcwd(),
-                                         os.path.dirname(__file__)))
+LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__)))
+NONASCII_DIR = osp.join(LOCATION, u"èáïü Øαôå 字分误")
+if not osp.exists(NONASCII_DIR):
+    os.makedirs(NONASCII_DIR)
 
 
 def process_search_results(results):
@@ -46,6 +53,22 @@ def setup_findinfiles(qtbot, *args, **kwargs):
     return widget
 
 
+@pytest.fixture
+def searchin_combobox_bot(qtbot):
+    """Set up SearchInComboBox combobox."""
+    external_path_history = [
+            LOCATION,
+            osp.dirname(LOCATION),
+            osp.dirname(osp.dirname(LOCATION)),
+            osp.dirname(osp.dirname(osp.dirname(LOCATION))),
+            osp.dirname(LOCATION),
+            osp.join(LOCATION, 'path_that_does_not_exist')
+            ]
+    searchin_combobox = SearchInComboBox(external_path_history)
+    qtbot.addWidget(searchin_combobox)
+    return searchin_combobox, qtbot
+
+
 def expected_results():
     results = {'spam.txt': [(1, 0), (1, 5), (3, 22)],
                'spam.py': [(2, 7), (5, 1), (7, 12)],
@@ -129,5 +152,282 @@ def test_case_sensitive_search(qtbot):
     assert matches == {'ham.txt': [(9, 0)]}
 
 
+# ---- Tests for SearchInComboBox
+
+def test_add_external_paths(searchin_combobox_bot, mocker):
+    """
+    Test that the external_path_history is added correctly to the
+    combobox and test that adding new external path to the combobox
+    with the QFileDialog is working as expected.
+    """
+    searchin_combobox, qtbot = searchin_combobox_bot
+    searchin_combobox.show()
+
+    # Assert that the external_path_history was added correctly to the
+    # combobox
+    expected_results = [
+            LOCATION,
+            osp.dirname(osp.dirname(LOCATION)),
+            osp.dirname(osp.dirname(osp.dirname(LOCATION))),
+            osp.dirname(LOCATION)
+            ]
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    for i, expected_result in enumerate(expected_results):
+        assert expected_result == searchin_combobox.itemText(i+EXTERNAL_PATHS)
+
+    # Add a new external path to the combobox. The new path is added at the
+    # end of the combobox.
+    new_path = NONASCII_DIR
+    mocker.patch('spyder.widgets.findinfiles.getexistingdirectory',
+                 return_value=new_path)
+    searchin_combobox.setCurrentIndex(SELECT_OTHER)
+
+    expected_results.append(new_path)
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == searchin_combobox.count()-1
+
+    # Add an external path that is already listed in the combobox. In this
+    # case, the new path is removed from the list and is added back at the end.
+    new_path = LOCATION
+    mocker.patch('spyder.widgets.findinfiles.getexistingdirectory',
+                 return_value=new_path)
+    searchin_combobox.setCurrentIndex(SELECT_OTHER)
+
+    expected_results.pop(0)
+    expected_results.append(new_path)
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == searchin_combobox.count()-1
+
+    # Cancel the action of adding a new external path. In this case, the
+    # expected results do not change.
+    mocker.patch('spyder.widgets.findinfiles.getexistingdirectory',
+                 return_value='')
+    searchin_combobox.setCurrentIndex(SELECT_OTHER)
+
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == CWD
+
+
+def test_clear_this_list(searchin_combobox_bot, mocker):
+    """
+    Test the option in the searchin combobox to clear the list of
+    external paths.
+    """
+    searchin_combobox, qtbot = searchin_combobox_bot
+    searchin_combobox.show()
+
+    # Cancel the Clear the list action and assert the result.
+    mocker.patch.object(QMessageBox, 'question', return_value=QMessageBox.No)
+    searchin_combobox.setCurrentIndex(CLEAR_LIST)
+
+    expected_results = [
+            LOCATION,
+            osp.dirname(osp.dirname(LOCATION)),
+            osp.dirname(osp.dirname(osp.dirname(LOCATION))),
+            osp.dirname(LOCATION)
+            ]
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == CWD
+
+    # Clear the list of external paths and assert that the list of
+    # external paths is empty.
+    mocker.patch.object(QMessageBox, 'question', return_value=QMessageBox.Yes)
+    searchin_combobox.setCurrentIndex(CLEAR_LIST)
+
+    assert searchin_combobox.count() == EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == []
+    assert searchin_combobox.currentIndex() == CWD
+
+
+def test_delete_path(searchin_combobox_bot, mocker):
+    """
+    Test that the selected external path in the combobox view is removed
+    correctly when the Delete key is pressed.
+    """
+    searchin_combobox, qtbot = searchin_combobox_bot
+    searchin_combobox.show()
+
+    expected_results = [
+            LOCATION,
+            osp.dirname(osp.dirname(LOCATION)),
+            osp.dirname(osp.dirname(osp.dirname(LOCATION))),
+            osp.dirname(LOCATION)
+            ]
+
+    searchin_combobox.showPopup()
+    assert searchin_combobox.currentIndex() == CWD
+    assert searchin_combobox.view().currentIndex().row() == CWD
+
+    # Assert that the delete action does nothing when the selected item in
+    # the combobox view is not an external path.
+    for i in range(EXTERNAL_PATHS):
+        searchin_combobox.view().setCurrentIndex(
+            searchin_combobox.model().index(i, 0))
+        qtbot.keyPress(searchin_combobox.view(), Qt.Key_Delete)
+
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == CWD
+
+    # Delete the first external path in the list.
+    searchin_combobox.view().setCurrentIndex(
+            searchin_combobox.model().index(EXTERNAL_PATHS, 0))
+    qtbot.keyPress(searchin_combobox.view(), Qt.Key_Delete)
+
+    expected_results.pop(0)
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == EXTERNAL_PATHS
+    assert searchin_combobox.view().currentIndex().row() == EXTERNAL_PATHS
+
+    # Delete the second external path in the remaining list.
+    searchin_combobox.view().setCurrentIndex(
+            searchin_combobox.model().index(EXTERNAL_PATHS+1, 0))
+    qtbot.keyPress(searchin_combobox.view(), Qt.Key_Delete)
+
+    expected_results.pop(1)
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == EXTERNAL_PATHS+1
+    assert searchin_combobox.view().currentIndex().row() == EXTERNAL_PATHS+1
+
+    # Delete the last external path in the list.
+    searchin_combobox.view().setCurrentIndex(
+            searchin_combobox.model().index(searchin_combobox.count()-1, 0))
+    qtbot.keyPress(searchin_combobox.view(), Qt.Key_Delete)
+
+    expected_results.pop()
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+    assert searchin_combobox.currentIndex() == searchin_combobox.count()-1
+    assert (searchin_combobox.view().currentIndex().row() ==
+            searchin_combobox.count()-1)
+
+    # Delete the last remaining external path in the list.
+    searchin_combobox.view().setCurrentIndex(
+            searchin_combobox.model().index(EXTERNAL_PATHS, 0))
+    qtbot.keyPress(searchin_combobox.view(), Qt.Key_Delete)
+
+    assert searchin_combobox.count() == EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == []
+    assert searchin_combobox.currentIndex() == CWD
+    assert searchin_combobox.view().currentIndex().row() == CWD
+
+
+def test_set_project_path(qtbot):
+    """
+    Test setting the project path of the SearchInComboBox from the
+    FindInFilesWidget.
+    """
+    findinfiles_widget = setup_findinfiles(qtbot)
+    find_options = findinfiles_widget.find_options
+    path_selection_combo = find_options.path_selection_combo
+    findinfiles_widget.show()
+
+    assert path_selection_combo.model().item(PROJECT, 0).isEnabled() is False
+    assert find_options.project_path is None
+    assert path_selection_combo.project_path is None
+
+    # Set the project path to an existing directory. For the purpose of this
+    # test, it doesn't need to be a valid Spyder project path.
+    project_path = NONASCII_DIR
+    find_options.set_project_path(project_path)
+    assert path_selection_combo.model().item(PROJECT, 0).isEnabled() is True
+    assert find_options.project_path == project_path
+    assert path_selection_combo.project_path == project_path
+
+    # Disable the project path search in the widget.
+    path_selection_combo.setCurrentIndex(PROJECT)
+    find_options.disable_project_search()
+    assert path_selection_combo.model().item(PROJECT, 0).isEnabled() is False
+    assert find_options.project_path is None
+    assert path_selection_combo.project_path is None
+    assert path_selection_combo.currentIndex() == CWD
+
+
+def test_current_search_path(qtbot):
+    """
+    Test that the expected search path is returned for the corresponding
+    option selected in the SearchInComboBox. This test is done using the
+    FindInFilesWidget.
+    """
+    external_paths = [
+            LOCATION,
+            osp.dirname(LOCATION),
+            osp.dirname(osp.dirname(LOCATION)),
+            NONASCII_DIR
+            ]
+
+    findinfiles_widget = setup_findinfiles(
+            qtbot, external_path_history=external_paths)
+    find_options = findinfiles_widget.find_options
+    path_selection_combo = find_options.path_selection_combo
+    findinfiles_widget.show()
+
+    # Set the project, file, and spyder path of the SearchInComboBox.
+    # For the purpose of this test, the project path doesn't need to be a
+    # valid Spyder project path.
+
+    directory = NONASCII_DIR
+    project_path = NONASCII_DIR
+    file_path = osp.join(directory, "spam.py")
+
+    find_options.set_directory(directory)
+    assert find_options.path == directory
+    assert path_selection_combo.path == directory
+
+    find_options.set_project_path(project_path)
+    assert find_options.project_path == project_path
+    assert path_selection_combo.project_path == project_path
+
+    find_options.set_file_path(file_path)
+    assert find_options.file_path == file_path
+    assert path_selection_combo.file_path == file_path
+
+    # Assert that the good path is returned for each option selected.
+
+    # Test for the current working directory :
+    path_selection_combo.setCurrentIndex(CWD)
+    assert path_selection_combo.get_current_searchpath() == directory
+    assert path_selection_combo.is_file_search() is False
+    # Test for the project path :
+    path_selection_combo.setCurrentIndex(PROJECT)
+    assert path_selection_combo.get_current_searchpath() == project_path
+    assert path_selection_combo.is_file_search() is False
+    # Test for the file path :
+    path_selection_combo.setCurrentIndex(FILE_PATH)
+    assert path_selection_combo.get_current_searchpath() == file_path
+    assert path_selection_combo.is_file_search() is True
+    # Test for the external file path :
+    for i, path in enumerate(external_paths):
+        path_selection_combo.setCurrentIndex(EXTERNAL_PATHS+i)
+        assert path_selection_combo.get_current_searchpath() == path
+        assert path_selection_combo.is_file_search() is False
+
+
+def test_max_history(qtbot, mocker):
+    """
+    Test that the specified maximum number of external path is observed.
+    """
+    findinfiles.MAX_PATH_HISTORY = 3
+    searchin_combobox, qtbot = searchin_combobox_bot(qtbot)
+    searchin_combobox.show()
+
+    # In this case, the first path of the external_path_history was removed to
+    # respect the MAX_PATH_HISTORY of 3.
+    expected_results = [
+            osp.dirname(osp.dirname(LOCATION)),
+            osp.dirname(osp.dirname(osp.dirname(LOCATION))),
+            osp.dirname(LOCATION)
+            ]
+    assert searchin_combobox.count() == len(expected_results)+EXTERNAL_PATHS
+    assert searchin_combobox.get_external_paths() == expected_results
+
+
 if __name__ == "__main__":
-    pytest.main()
+    pytest.main(['-x', osp.basename(__file__), '-v', '-rw'])