Repository: spyder-ide/spyder

Issue:
Extend Find in Files to select other directories
As suggested by @jnsebgosselin and @avylove on #4809 


Discussion:

Resolving patch:
diff --git a/spyder/plugins/findinfiles.py b/spyder/plugins/findinfiles.py
--- a/spyder/plugins/findinfiles.py
+++ b/spyder/plugins/findinfiles.py
@@ -53,12 +53,13 @@ def __init__(self, parent=None):
         in_python_path = self.get_option('in_python_path')
         more_options = self.get_option('more_options')
         case_sensitive = self.get_option('case_sensitive')
+        path_history = self.get_option('path_history', [])
         FindInFilesWidget.__init__(self, parent,
                                    search_text, search_text_regexp,
                                    search_path, exclude, exclude_idx,
                                    exclude_regexp, supported_encodings,
                                    in_python_path, more_options,
-                                   case_sensitive)
+                                   case_sensitive, path_history)
         SpyderPluginMixin.__init__(self, parent)
         
         # Initialize plugin
@@ -160,11 +161,13 @@ def closing_plugin(self, cancelable=False):
         if options is not None:
             (search_text, text_re, search_path,
              exclude, exclude_idx, exclude_re,
-             in_python_path, more_options, case_sensitive) = options
+             in_python_path, more_options, case_sensitive,
+             path_history) = options
             hist_limit = 15
             search_text = search_text[:hist_limit]
             search_path = search_path[:hist_limit]
             exclude = exclude[:hist_limit]
+            path_history = path_history[-hist_limit:]
             self.set_option('search_text', search_text)
             self.set_option('search_text_regexp', text_re)
             self.set_option('search_path', search_path)
@@ -174,6 +177,7 @@ def closing_plugin(self, cancelable=False):
             self.set_option('in_python_path', in_python_path)
             self.set_option('more_options', more_options)
             self.set_option('case_sensitive', case_sensitive)
+            self.set_option('path_history', path_history)
         return True
 
 
diff --git a/spyder/widgets/findinfiles.py b/spyder/widgets/findinfiles.py
--- a/spyder/widgets/findinfiles.py
+++ b/spyder/widgets/findinfiles.py
@@ -25,10 +25,10 @@
 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, QRadioButton, QSizePolicy,
+from qtpy.QtWidgets import (QHBoxLayout, QLabel, QListWidget, QSizePolicy,
                             QTreeWidgetItem, QVBoxLayout, QWidget,
                             QStyledItemDelegate, QStyleOptionViewItem,
-                            QApplication, QStyle)
+                            QApplication, QStyle, QListWidgetItem)
 
 # Local imports
 from spyder.config.base import _
@@ -46,6 +46,22 @@
 ON = 'on'
 OFF = 'off'
 
+CWD = 0
+PROJECT = 1
+FILE_PATH = 2
+EXTERNAL_PATH = 4
+
+MAX_PATH_LENGTH = 60
+MAX_PATH_HISTORY = 15
+
+
+def truncate_path(text):
+    ellipsis = '...'
+    part_len = (MAX_PATH_LENGTH - len(ellipsis)) / 2.0
+    left_text = text[:int(math.ceil(part_len))]
+    right_text = text[-int(math.floor(part_len)):]
+    return left_text + ellipsis + right_text
+
 
 class SearchThread(QThread):
     """Find in files search thread"""
@@ -186,6 +202,23 @@ 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)
+
+    def __repr__(self):
+        if len(self.path) > MAX_PATH_LENGTH:
+            return truncate_path(self.path)
+        return self.path
+
+    def __str__(self):
+        return self.__repr__()
+
+    def __unicode__(self):
+        return self.__repr__()
+
+
 class FindOptions(QWidget):
     """Find widget with options"""
     REGEX_INVALID = "background-color:rgb(255, 175, 90);"
@@ -195,7 +228,7 @@ class FindOptions(QWidget):
     def __init__(self, parent, search_text, search_text_regexp, search_path,
                  exclude, exclude_idx, exclude_regexp,
                  supported_encodings, in_python_path, more_options,
-                 case_sensitive):
+                 case_sensitive, external_path_history):
         QWidget.__init__(self, parent)
 
         if search_path is None:
@@ -204,6 +237,8 @@ def __init__(self, parent, search_text, search_text_regexp, search_path,
         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]
@@ -211,6 +246,8 @@ def __init__(self, parent, search_text, search_text_regexp, search_path,
             search_path = [search_path]
         if not isinstance(exclude, (list, tuple)):
             exclude = [exclude]
+        if not isinstance(external_path_history, (list, tuple)):
+            external_path_history = [external_path_history]
 
         self.supported_encodings = supported_encodings
 
@@ -272,28 +309,48 @@ def __init__(self, parent, search_text, search_text_regexp, search_path,
         # Layout 3
         hlayout3 = QHBoxLayout()
 
-        self.global_path_search = QRadioButton(_("Current working "
-                                                 "directory"), self)
-        self.global_path_search.setChecked(True)
-        self.global_path_search.setToolTip(_("Search in all files and "
-                                             "directories present on the"
-                                             "current Spyder path"))
-
-        self.project_search = QRadioButton(_("Project"), self)
-        self.project_search.setToolTip(_("Search in all files and "
-                                         "directories present on the"
-                                         "current project path (If opened)"))
-
-        self.project_search.setEnabled(False)
-
-        self.file_search = QRadioButton(_("File"), self)
-        self.file_search.setToolTip(_("Search in current opened file"))
-
-        for wid in [self.global_path_search,
-                    self.project_search, self.file_search]:
-            hlayout3.addWidget(wid)
-
-        hlayout3.addStretch(1)
+        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"))
+        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)
+
+        hlayout3.addWidget(search_on_label)
+        hlayout3.addWidget(self.path_selection_combo)
 
         self.search_text.valid.connect(lambda valid: self.find.emit())
         self.exclude_pattern.valid.connect(lambda valid: self.find.emit())
@@ -324,6 +381,28 @@ 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()
@@ -360,16 +439,17 @@ def get_options(self, all=False):
         if not case_sensitive:
             texts = [(text[0].lower(), text[1]) for text in texts]
 
-        global_path_search = self.global_path_search.isChecked()
-        project_search = self.project_search.isChecked()
-        file_search = self.file_search.isChecked()
-
-        if global_path_search:
+        file_search = False
+        selection_idx = self.path_selection_combo.currentIndex()
+        if selection_idx == CWD:
             path = self.path
-        elif project_search:
+        elif selection_idx == PROJECT:
             path = self.project_path
-        else:
+        elif selection_idx == FILE_PATH:
             path = self.file_path
+            file_search = True
+        else:
+            path = self.external_path
 
         # Finding text occurrences
         if not exclude_re:
@@ -394,34 +474,38 @@ 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())]
             exclude_idx = self.exclude_pattern.currentIndex()
             more_options = self.more_options.isChecked()
             return (search_text, text_re, [],
                     exclude, exclude_idx, exclude_re,
-                    python_path, more_options, case_sensitive)
+                    python_path, more_options, case_sensitive, path_history)
         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.dir_combo.currentText())
+                                         self.path)
         if directory:
-            self.set_directory(directory)
-        self.parent().redirect_stdio.emit(True)
+            directory = to_text_string(osp.abspath(to_text_string(directory)))
+        return directory
 
     def set_directory(self, directory):
         self.path = to_text_string(osp.abspath(to_text_string(directory)))
 
     def set_project_path(self, path):
         self.project_path = to_text_string(osp.abspath(to_text_string(path)))
-        self.project_search.setEnabled(True)
+        item = self.path_selection_contents.item(PROJECT)
+        item.setFlags(item.flags() | Qt.ItemIsEnabled)
 
     def disable_project_search(self):
-        self.project_search.setEnabled(False)
-        self.project_search.setChecked(False)
+        item = self.path_selection_contents.item(PROJECT)
+        item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
         self.project_path = None
 
     def set_file_path(self, path):
@@ -687,7 +771,6 @@ def append_result(self, results, num_matches):
 
 class FileProgressBar(QWidget):
     """Simple progress spinner with a label"""
-    MAX_LABEL_LENGTH = 60
 
     def __init__(self, parent):
         QWidget.__init__(self, parent)
@@ -702,16 +785,9 @@ def __init__(self, parent):
         layout.addWidget(self.status_text)
         self.setLayout(layout)
 
-    def __truncate(self, text):
-        ellipsis = '...'
-        part_len = (self.MAX_LABEL_LENGTH - len(ellipsis)) / 2.0
-        left_text = text[:int(math.ceil(part_len))]
-        right_text = text[-int(math.floor(part_len)):]
-        return left_text + ellipsis + right_text
-
     @Slot(str)
     def set_label_path(self, path, folder=False):
-        text = self.__truncate(path)
+        text = truncate_path(path)
         if not folder:
             status_str = _(u' Scanning: {0}').format(text)
         else:
@@ -735,7 +811,7 @@ def __init__(self, parent,
                  exclude_regexp=True,
                  supported_encodings=("utf-8", "iso-8859-1", "cp1252"),
                  in_python_path=False, more_options=False,
-                 case_sensitive=True):
+                 case_sensitive=True, external_path_history=[]):
         QWidget.__init__(self, parent)
 
         self.setWindowTitle(_('Find in files'))
@@ -750,7 +826,8 @@ def __init__(self, parent,
                                         search_path,
                                         exclude, exclude_idx, exclude_regexp,
                                         supported_encodings, in_python_path,
-                                        more_options, case_sensitive)
+                                        more_options, case_sensitive,
+                                        external_path_history)
         self.find_options.find.connect(self.find)
         self.find_options.stop.connect(self.stop_and_reset_thread)
 
@@ -778,8 +855,8 @@ def find(self):
             return
         self.stop_and_reset_thread(ignore_results=True)
         self.search_thread = SearchThread(self)
-        self.search_thread.get_pythonpath_callback = \
-                                                self.get_pythonpath_callback
+        self.search_thread.get_pythonpath_callback = (
+            self.get_pythonpath_callback)
         self.search_thread.sig_finished.connect(self.search_complete)
         self.search_thread.sig_current_file.connect(
             lambda x: self.status_bar.set_label_path(x, folder=False)


Test patch:
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
@@ -85,7 +85,6 @@ def test_find_in_files_search(qtbot):
     blocker = qtbot.waitSignal(find_in_files.sig_finished)
     blocker.wait()
     matches = process_search_results(find_in_files.result_browser.data)
-    print(matches)
     assert expected_results() == matches