Repository: spyder-ide/spyder

Issue:
Find in files freezes spyder
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

### Issue Report Checklist

* [x] Searched the [issues page](https://github.com/spyder-ide/spyder/issues?q=is%3Aissue) for similar reports
* [x] Read the relevant sections of the [Spyder Troubleshooting Guide](https://github.com/spyder-ide/spyder/wiki/Troubleshooting-Guide-and-FAQ) and followed its advice
* [ ] Reproduced the issue after updating with ``conda update spyder`` (or ``pip``, if not using Anaconda)
* [ ] Could not reproduce inside ``jupyter qtconsole`` (if console-related)
* [ ] Tried basic troubleshooting (if a bug/error)
    * [x] Restarted Spyder
    * [ ] Reset preferences with ``spyder --reset``
    * [ ] Reinstalled the latest version of [Anaconda](https://www.anaconda.com/download/)
    * [ ] Tried the other applicable steps from the Troubleshooting Guide
* [ ] Completed the **Problem Description**, **Steps to Reproduce** and **Version** sections below


## Problem Description
Find in files will freeze spyder (alsmost completely not resonsive) if there is too many results.

The profiles tells me that most of the time is spent in `spyder.plugins.findinfiles.ItemDelegate.sizeHint`


### What steps reproduce the problem?

1. Change cwd to `/usr/local/lib/python3.7/site-packages` (or whatever the site-packages folder is)
2. Search for `in`
3. Try to use spyder while the search takes place.

### What is the expected output? What do you see instead?

Spyder becomes almost completely unresponsive.


### Paste Traceback/Error Below (if applicable)
<!--- Copy from error dialog or View > Panes > Internal Console --->

```python-traceback

PASTE TRACEBACK HERE

```

## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: 
* Python version: 
* Qt version: 
* PyQt version: 
* Operating System name/version: 

### Dependencies
<!--- Please go to the menu entry Help > Dependencies,
press the Copy to clipboard button and paste below --->

```
PASTE DEPENDENCIES HERE
```


Discussion:

Resolving patch:
diff --git a/spyder/config/main.py b/spyder/config/main.py
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -288,8 +288,9 @@
               'search_text_regexp': False,
               'search_text': [''],
               'search_text_samples': [TASKS_PATTERN],
-              'more_options': True,
+              'more_options': False,
               'case_sensitive': False,
+              'max_results': 1000,
               }),
             ('breakpoints',
              {
@@ -610,4 +611,4 @@
 #    or if you want to *rename* options, then you need to do a MAJOR update in
 #    version, e.g. from 3.0.0 to 4.0.0
 # 3. You don't need to touch this value if you're just adding a new option
-CONF_VERSION = '53.1.0'
+CONF_VERSION = '53.2.0'
diff --git a/spyder/plugins/findinfiles/plugin.py b/spyder/plugins/findinfiles/plugin.py
--- a/spyder/plugins/findinfiles/plugin.py
+++ b/spyder/plugins/findinfiles/plugin.py
@@ -4,7 +4,7 @@
 # Licensed under the terms of the MIT License
 # (see spyder/__init__.py for details)
 
-"""Find in Files Plugin"""
+"""Find in Files Plugin."""
 
 # pylint: disable=C0103
 # pylint: disable=R0903
@@ -17,7 +17,7 @@
 # Third party imports
 from qtpy.QtCore import Signal, Qt
 from qtpy.QtGui import QKeySequence
-from qtpy.QtWidgets import QApplication, QVBoxLayout
+from qtpy.QtWidgets import QApplication, QInputDialog, QVBoxLayout
 
 # Local imports
 from spyder.api.plugins import SpyderPluginWidget
@@ -42,7 +42,7 @@ def __init__(self, parent=None):
         supported_encodings = self.get_option('supported_encodings')
         self.search_text_samples = self.get_option('search_text_samples')
         search_text = self.get_option('search_text')
-        search_text = [txt for txt in search_text \
+        search_text = [txt for txt in search_text
                        if txt not in self.search_text_samples]
         search_text += self.search_text_samples
         search_text_regexp = self.get_option('search_text_regexp')
@@ -53,24 +53,30 @@ def __init__(self, parent=None):
         case_sensitive = self.get_option('case_sensitive')
         path_history = self.get_option('path_history', [])
         search_in_index = self.get_option('search_in_index', default=0)
+        max_results = self.get_option('max_results')
 
         self.findinfiles = FindInFilesWidget(
-                                   self,
-                                   search_text, search_text_regexp,
-                                   exclude, exclude_idx, exclude_regexp,
-                                   supported_encodings,
-                                   more_options,
-                                   case_sensitive, path_history,
-                                   search_in_index,
-                                   options_button=self.options_button,
-                                   text_color=ima.MAIN_FG_COLOR)
+            self,
+            search_text,
+            search_text_regexp,
+            exclude,
+            exclude_idx,
+            exclude_regexp,
+            supported_encodings,
+            more_options,
+            case_sensitive,
+            path_history,
+            search_in_index,
+            options_button=self.options_button,
+            text_color=ima.MAIN_FG_COLOR,
+            max_results=max_results)
 
         layout = QVBoxLayout()
         layout.addWidget(self.findinfiles)
         self.setLayout(layout)
 
         self.toggle_visibility.connect(self.toggle)
-        
+
     def toggle(self, state):
         """Toggle widget visibility"""
         if self.dockwidget:
@@ -107,6 +113,24 @@ def findinfiles_callback(self):
         if text:
             self.findinfiles.find()
 
+    def show_max_results_input(self):
+        """Show input dialog to set maximum amount of results."""
+        value, valid = QInputDialog.getInt(
+            self,
+            self.get_plugin_title(),
+            _('Set maximum number of results: '),
+            value=self.get_option('max_results'),
+            min=1,
+            step=1,
+        )
+        if valid:
+            self.set_max_results(value)
+
+    def set_max_results(self, value):
+        """Set maximum amount of results to add to result browser."""
+        self.set_option('max_results', value)
+        self.findinfiles.set_max_results(value)
+
     #------ SpyderPluginMixin API ---------------------------------------------
     def switch_to_plugin(self):
         """
@@ -129,11 +153,16 @@ def get_focus_widget(self):
         this plugin's dockwidget is raised on top-level
         """
         return self.findinfiles.find_options.search_text
-    
+
     def get_plugin_actions(self):
-        """Return a list of actions related to plugin"""
-        return self.findinfiles.result_browser.get_menu_actions()
-    
+        """Return a list of actions related to plugin."""
+        set_max_results_action = create_action(
+            self,
+            _("Set maximum number of results"),
+            triggered=self.show_max_results_input)
+        browser_actions = self.findinfiles.result_browser.get_menu_actions()
+        return [set_max_results_action, None] + browser_actions
+
     def register_plugin(self):
         """Register plugin in Spyder's main window"""
         self.add_dockwidget()
@@ -147,12 +176,13 @@ def register_plugin(self):
         self.main.editor.open_file_update.connect(self.set_current_opened_file)
 
         findinfiles_action = create_action(
-                                   self, _("&Find in files"),
-                                   icon=ima.icon('findf'),
-                                   triggered=self.switch_to_plugin,
-                                   shortcut=QKeySequence(self.shortcut),
-                                   context=Qt.WidgetShortcut,
-                                   tip=_("Search text in multiple files"))
+            self,
+            _("&Find in files"),
+            icon=ima.icon('findf'),
+            triggered=self.switch_to_plugin,
+            shortcut=QKeySequence(self.shortcut),
+            context=Qt.WidgetShortcut,
+            tip=_("Search text in multiple files"))
 
         self.main.search_menu_actions += [MENU_SEPARATOR, findinfiles_action]
         self.main.search_toolbar_actions += [MENU_SEPARATOR,
diff --git a/spyder/plugins/findinfiles/widgets.py b/spyder/plugins/findinfiles/widgets.py
--- a/spyder/plugins/findinfiles/widgets.py
+++ b/spyder/plugins/findinfiles/widgets.py
@@ -67,18 +67,24 @@ def truncate_path(text):
 
 
 class SearchThread(QThread):
-    """Find in files search thread"""
+    """Find in files search thread."""
     sig_finished = Signal(bool)
     sig_current_file = Signal(str)
     sig_current_folder = Signal(str)
-    sig_file_match = Signal(tuple, int)
+    sig_file_match = Signal(object)
+    sig_line_match = Signal(object, object)
     sig_out_print = Signal(object)
 
-    def __init__(self, parent):
+    # Batch power sizes (2**power)
+    power = 0       # 0**1 = 1
+    max_power = 9   # 2**9 = 512
+
+    def __init__(self, parent, search_text, text_color=None):
         QThread.__init__(self, parent)
         self.mutex = QMutex()
         self.stopped = None
-        self.results = None
+        self.search_text = search_text
+        self.text_color = text_color
         self.pathlist = None
         self.total_matches = None
         self.error_flag = None
@@ -88,9 +94,13 @@ def __init__(self, parent):
         self.text_re = None
         self.completed = None
         self.case_sensitive = True
-        self.results = {}
         self.total_matches = 0
         self.is_file = False
+        self.results = {}
+
+        self.num_files = 0
+        self.files = []
+        self.partial_results = []
 
     def initialize(self, path, is_file, exclude,
                    texts, text_re, case_sensitive):
@@ -155,6 +165,11 @@ def find_files_in_path(self, path):
             except re.error:
                 self.error_flag = _("invalid regular expression")
                 return False
+
+        # Process any pending results
+        if self.partial_results:
+            self.process_results()
+
         return True
 
     def find_string_in_file(self, fname):
@@ -181,19 +196,25 @@ def find_string_in_file(self, fname):
                     line_dec = line.decode(enc)
                 except UnicodeDecodeError:
                     line_dec = line
+
                 if not self.case_sensitive:
                     line = line.lower()
+
                 if self.text_re:
                     for match in re.finditer(text, line):
                         with QMutexLocker(self.mutex):
                             if self.stopped:
                                 return False
                         self.total_matches += 1
-                        self.sig_file_match.emit((osp.abspath(fname),
-                                                  lineno + 1,
-                                                  match.start(),
-                                                  match.end(), line_dec),
-                                                 self.total_matches)
+                        self.partial_results.append((osp.abspath(fname),
+                                                     lineno + 1,
+                                                     match.start(),
+                                                     match.end(),
+                                                     line_dec))
+                        if len(self.partial_results) > (2**self.power):
+                            self.process_results()
+                            if self.power < self.max_power:
+                                self.power += 1
                 else:
                     found = line.find(text)
                     while found > -1:
@@ -201,20 +222,145 @@ def find_string_in_file(self, fname):
                             if self.stopped:
                                 return False
                         self.total_matches += 1
-                        self.sig_file_match.emit((osp.abspath(fname),
-                                                  lineno + 1,
-                                                  found,
-                                                  found + len(text), line_dec),
-                                                 self.total_matches)
+                        self.partial_results.append((osp.abspath(fname),
+                                                     lineno + 1,
+                                                     found,
+                                                     found + len(text),
+                                                     line_dec))
+                        if len(self.partial_results) > (2**self.power):
+                            self.process_results()
+                            if self.power < self.max_power:
+                                self.power += 1
+
                         for text, enc in self.texts:
                             found = line.find(text, found + 1)
                             if found > -1:
                                 break
+
         except IOError as xxx_todo_changeme:
             (_errno, _strerror) = xxx_todo_changeme.args
             self.error_flag = _("permission denied errors were encountered")
+
         self.completed = True
 
+    def process_results(self):
+        """
+        Process all matches found inside a file.
+
+        Creates the necessary files and emits signal for the creation of file
+        item.
+
+        Creates the necessary data for lines found and emits signal for the
+        creation of line items in batch.
+
+        Creates the title based on the last entry of the lines batch.
+        """
+        items = []
+        num_matches = self.total_matches
+        for result in self.partial_results:
+            filename, lineno, colno, match_end, line = result
+
+            if filename not in self.files:
+                self.files.append(filename)
+                self.sig_file_match.emit(filename)
+                self.num_files += 1
+
+            line = self.truncate_result(line, colno, match_end)
+            item = (filename, lineno, colno, line)
+            items.append(item)
+
+        # Process title
+        title = "'%s' - " % self.search_text
+        nb_files = self.num_files
+        if nb_files == 0:
+            text = _('String not found')
+        else:
+            text_matches = _('matches in')
+            text_files = _('file')
+            if nb_files > 1:
+                text_files += 's'
+            text = "%d %s %d %s" % (num_matches, text_matches,
+                                    nb_files, text_files)
+        title = title + text
+
+        self.partial_results = []
+        self.sig_line_match.emit(items, title)
+
+    def truncate_result(self, line, start, end):
+        """
+        Shorten text on line to display the match within `max_line_length`.
+        """
+        ellipsis = u'...'
+        max_line_length = 80
+        max_num_char_fragment = 40
+
+        html_escape_table = {
+            u"&": u"&amp;",
+            u'"': u"&quot;",
+            u"'": u"&apos;",
+            u">": u"&gt;",
+            u"<": u"&lt;",
+        }
+
+        def html_escape(text):
+            """Produce entities within text."""
+            return u"".join(html_escape_table.get(c, c) for c in text)
+
+        if PY2:
+            line = to_text_string(line, encoding='utf8')
+        else:
+            line = to_text_string(line)
+        left, match, right = line[:start], line[start:end], line[end:]
+
+        if len(line) > max_line_length:
+            offset = (len(line) - len(match)) // 2
+
+            left = left.split(u' ')
+            num_left_words = len(left)
+
+            if num_left_words == 1:
+                left = left[0]
+                if len(left) > max_num_char_fragment:
+                    left = ellipsis + left[-offset:]
+                left = [left]
+
+            right = right.split(u' ')
+            num_right_words = len(right)
+
+            if num_right_words == 1:
+                right = right[0]
+                if len(right) > max_num_char_fragment:
+                    right = right[:offset] + ellipsis
+                right = [right]
+
+            left = left[-4:]
+            right = right[:4]
+
+            if len(left) < num_left_words:
+                left = [ellipsis] + left
+
+            if len(right) < num_right_words:
+                right = right + [ellipsis]
+
+            left = u' '.join(left)
+            right = u' '.join(right)
+
+            if len(left) > max_num_char_fragment:
+                left = ellipsis + left[-30:]
+
+            if len(right) > max_num_char_fragment:
+                right = right[:30] + ellipsis
+
+        line_match_format = (u'<span style="color:{0}">{{0}}'
+                             '<b>{{1}}</b>{{2}}</span>')
+        line_match_format = line_match_format.format(self.text_color)
+
+        left = html_escape(left)
+        right = html_escape(right)
+        match = html_escape(match)
+        trunc_line = line_match_format.format(left, match, right)
+        return trunc_line
+
     def get_results(self):
         return self.results, self.pathlist, self.total_matches, self.error_flag
 
@@ -417,7 +563,7 @@ def __init__(self, parent, search_text, search_text_regexp,
                  case_sensitive, external_path_history, search_in_index,
                  options_button=None):
         QWidget.__init__(self, parent)
-
+        self._running = False
         if not isinstance(search_text, (list, tuple)):
             search_text = [search_text]
         if not isinstance(exclude, (list, tuple)):
@@ -443,8 +589,9 @@ def __init__(self, parent, search_text, search_text_regexp,
         self.edit_regexp.setCheckable(True)
         self.edit_regexp.setChecked(search_text_regexp)
         self.more_widgets = ()
-        self.more_options = create_toolbutton(self,
-                                              toggled=self.toggle_more_options)
+        self.more_options = create_toolbutton(
+            self,
+            toggled=self.toggle_more_options)
         self.more_options.setCheckable(True)
         self.more_options.setChecked(more_options)
 
@@ -460,7 +607,6 @@ def __init__(self, parent, search_text, search_text_regexp,
                                              self.stop.emit(),
                                              tip=_("Stop search"),
                                              text_beside_icon=True)
-        self.stop_button.setEnabled(False)
         for widget in [self.search_text, self.edit_regexp, self.case_button,
                        self.ok_button, self.stop_button, self.more_options]:
             hlayout1.addWidget(widget)
@@ -491,13 +637,14 @@ def __init__(self, parent, search_text, search_text_regexp,
         search_on_label = QLabel(_("Search in:"))
         self.path_selection_combo = SearchInComboBox(
                 external_path_history, parent)
-        self.path_selection_combo.set_current_searchpath_index(search_in_index)
+        self.path_selection_combo.set_current_searchpath_index(
+            search_in_index)
 
         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())
+        self.search_text.valid.connect(lambda valid: self._check_find())
+        self.exclude_pattern.valid.connect(lambda valid: self._check_find())
 
         vlayout = QVBoxLayout()
         vlayout.setContentsMargins(0, 0, 0, 0)
@@ -510,6 +657,32 @@ def __init__(self, parent, search_text, search_text_regexp,
 
         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
 
+        fm = self.ok_button.fontMetrics()
+        width = fm.width('Search') * 1.8
+        self.ok_button.setMinimumWidth(width)
+        self.stop_button.setMinimumWidth(width)
+        self.refresh_buttons(start=False)
+
+    def _check_find(self):
+        """
+        Check if find signal should be emitted.
+
+        This allows the Enter to function as starting and stopping action
+        depending on the state.
+        """
+        if self._running:
+            self.stop.emit()
+        else:
+            self.find.emit()
+
+    def refresh_buttons(self, start=False):
+        """Refresh start/stop of buttons."""
+        self._running = start
+        self.setUpdatesEnabled(False)
+        self.ok_button.setVisible(not start)
+        self.stop_button.setVisible(start)
+        self.setUpdatesEnabled(True)
+
     @Slot(bool)
     def toggle_more_options(self, state):
         for layout in self.more_widgets:
@@ -655,21 +828,24 @@ def keyPressEvent(self, event):
 
 
 class LineMatchItem(QTreeWidgetItem):
-    def __init__(self, parent, lineno, colno, match, text_color=None):
+
+    def __init__(self, parent, lineno, colno, match, font,
+                 text_color=None):
         self.lineno = lineno
         self.colno = colno
         self.match = match
         self.text_color = text_color
+        self.font = font
         QTreeWidgetItem.__init__(self, parent, [self.__repr__()],
                                  QTreeWidgetItem.Type)
 
     def __repr__(self):
         match = to_text_string(self.match).rstrip()
-        font = get_font()
-        _str = to_text_string("<p style=color:'{4}'><b>{1}</b> ({2}): "
+        _str = to_text_string("<!-- LineMatchItem -->"
+                              "<p style=\"color:'{4}';\"><b>{1}</b> ({2}): "
                               "<span style='font-family:{0};"
                               "font-size:75%;'>{3}</span></p>")
-        return _str.format(font.family(), self.lineno, self.colno, match,
+        return _str.format(self.font.family(), self.lineno, self.colno, match,
                            self.text_color)
 
     def __unicode__(self):
@@ -686,12 +862,14 @@ def __ge__(self, x):
 
 
 class FileMatchItem(QTreeWidgetItem):
+
     def __init__(self, parent, filename, sorting, text_color=None):
 
         self.sorting = sorting
         self.filename = osp.basename(filename)
 
-        title_format = to_text_string('<b style="color:{2}">{0}</b><br>'
+        title_format = to_text_string('<!-- FileMatchItem -->'
+                                      '<b style="color:{2}">{0}</b><br>'
                                       '<small style="color:{2}"><em>{1}</em>'
                                       '</small>')
         title = (title_format.format(osp.basename(filename),
@@ -715,8 +893,10 @@ def __ge__(self, x):
 
 
 class ItemDelegate(QStyledItemDelegate):
+
     def __init__(self, parent):
         QStyledItemDelegate.__init__(self, parent)
+        self._margin = None
 
     def paint(self, painter, option, index):
         options = QStyleOptionViewItem(option)
@@ -726,9 +906,21 @@ def paint(self, painter, option, index):
                  else options.widget.style())
 
         doc = QTextDocument()
-        doc.setDocumentMargin(0)
-        doc.setHtml(options.text)
+        text = options.text
+        doc.setHtml(text)
+
+        if 'FileMatchItem' in text:
+            doc.setDocumentMargin(0)
+        else:
+            if self._margin is None:
+                # To increase performance setUniformRowHeights is now True
+                # so we add a margin to file items to center them vertically
+                self._margin = int(doc.size().height() / 3)
 
+            doc.setDocumentMargin(self._margin)
+
+        # This needs to be an empty string to avoid the overlapping the
+        # normal text of the QTreeWidgetItem
         options.text = ""
         style.drawControl(QStyle.CE_ItemViewItem, options, painter)
 
@@ -746,39 +938,45 @@ def paint(self, painter, option, index):
     def sizeHint(self, option, index):
         options = QStyleOptionViewItem(option)
         self.initStyleOption(options, index)
-
         doc = QTextDocument()
         doc.setHtml(options.text)
         doc.setTextWidth(options.rect.width())
-
-        return QSize(int(doc.idealWidth()), int(doc.size().height()))
+        size = QSize(int(doc.idealWidth()), int(doc.size().height()))
+        return size
 
 
 class ResultsBrowser(OneColumnTree):
     sig_edit_goto = Signal(str, int, str)
+    sig_max_results_reached = Signal()
 
-    def __init__(self, parent, text_color=None):
+    def __init__(self, parent, text_color=None, max_results=1000):
         OneColumnTree.__init__(self, parent)
         self.search_text = None
         self.results = None
+        self.max_results = max_results
         self.total_matches = None
         self.error_flag = None
         self.completed = None
         self.sorting = {}
+        self.font = get_font()
         self.data = None
         self.files = None
+        self.root_items = None
+        self.text_color = text_color
+
+        # Setup
         self.set_title('')
         self.set_sorting(OFF)
         self.setSortingEnabled(False)
-        self.root_items = None
-        self.text_color = text_color
-        self.sortByColumn(0, Qt.AscendingOrder)
         self.setItemDelegate(ItemDelegate(self))
-        self.setUniformRowHeights(False)
+        self.setUniformRowHeights(True)  # Needed for performance
+        self.sortByColumn(0, Qt.AscendingOrder)
+
+        # Signals
         self.header().sectionClicked.connect(self.sort_section)
 
     def activated(self, item):
-        """Double-click event"""
+        """Double-click event."""
         itemdata = self.data.get(id(self.currentItem()))
         if itemdata is not None:
             filename, lineno, colno = itemdata
@@ -794,10 +992,11 @@ def sort_section(self, idx):
         self.setSortingEnabled(True)
 
     def clicked(self, item):
-        """Click event"""
+        """Click event."""
         self.activated(item)
 
     def clear_title(self, search_text):
+        self.font = get_font()
         self.clear()
         self.setSortingEnabled(False)
         self.num_files = 0
@@ -809,112 +1008,46 @@ def clear_title(self, search_text):
         text = _('String not found')
         self.set_title(title + text)
 
-    def truncate_result(self, line, start, end):
-        ellipsis = u'...'
-        max_line_length = 80
-        max_num_char_fragment = 40
-
-        html_escape_table = {
-            u"&": u"&amp;",
-            u'"': u"&quot;",
-            u"'": u"&apos;",
-            u">": u"&gt;",
-            u"<": u"&lt;",
-        }
-
-        def html_escape(text):
-            """Produce entities within text."""
-            return u"".join(html_escape_table.get(c, c) for c in text)
-
-        if PY2:
-            line = to_text_string(line, encoding='utf8')
-        else:
-            line = to_text_string(line)
-        left, match, right = line[:start], line[start:end], line[end:]
-
-        if len(line) > max_line_length:
-            offset = (len(line) - len(match)) // 2
-
-            left = left.split(u' ')
-            num_left_words = len(left)
-
-            if num_left_words == 1:
-                left = left[0]
-                if len(left) > max_num_char_fragment:
-                    left = ellipsis + left[-offset:]
-                left = [left]
-
-            right = right.split(u' ')
-            num_right_words = len(right)
-
-            if num_right_words == 1:
-                right = right[0]
-                if len(right) > max_num_char_fragment:
-                    right = right[:offset] + ellipsis
-                right = [right]
-
-            left = left[-4:]
-            right = right[:4]
-
-            if len(left) < num_left_words:
-                left = [ellipsis] + left
-
-            if len(right) < num_right_words:
-                right = right + [ellipsis]
-
-            left = u' '.join(left)
-            right = u' '.join(right)
-
-            if len(left) > max_num_char_fragment:
-                left = ellipsis + left[-30:]
+    @Slot(object)
+    def append_file_result(self, filename):
+        """Real-time update of file items."""
+        if len(self.data) < self.max_results:
+            self.files[filename] = FileMatchItem(
+                self, filename, self.sorting, self.text_color)
+            self.files[filename].setExpanded(True)
+            self.num_files += 1
 
-            if len(right) > max_num_char_fragment:
-                right = right[:30] + ellipsis
+    @Slot(object, object)
+    def append_result(self, items, title):
+        """Real-time update of line items."""
+        if len(self.data) >= self.max_results:
+            self.set_title(_('Maximum number of results reached! Try '
+                             'narrowing the search.'))
+            self.sig_max_results_reached.emit()
+            return
 
-        line_match_format = (u'<span style="color:{0}">{{0}}'
-                             '<b>{{1}}</b>{{2}}</span>')
-        line_match_format = line_match_format.format(self.text_color)
+        available = self.max_results - len(self.data)
+        if available < len(items):
+            items = items[:available]
 
-        left = html_escape(left)
-        right = html_escape(right)
-        match = html_escape(match)
-        trunc_line = line_match_format.format(left, match, right)
-        return trunc_line
+        self.setUpdatesEnabled(False)
+        self.set_title(title)
+        for item in items:
+            filename, lineno, colno, line = item
+            file_item = self.files.get(filename, None)
+            if file_item:
+                item = LineMatchItem(file_item, lineno, colno, line,
+                                     self.font, self.text_color)
+                self.data[id(item)] = (filename, lineno, colno)
+        self.setUpdatesEnabled(True)
 
-    @Slot(tuple, int)
-    def append_result(self, results, num_matches):
-        """Real-time update of search results"""
-        filename, lineno, colno, match_end, line = results
-
-        if filename not in self.files:
-            file_item = FileMatchItem(self, filename, self.sorting,
-                                      self.text_color)
-            file_item.setExpanded(True)
-            self.files[filename] = file_item
-            self.num_files += 1
-
-        search_text = self.search_text
-        title = "'%s' - " % search_text
-        nb_files = self.num_files
-        if nb_files == 0:
-            text = _('String not found')
-        else:
-            text_matches = _('matches in')
-            text_files = _('file')
-            if nb_files > 1:
-                text_files += 's'
-            text = "%d %s %d %s" % (num_matches, text_matches,
-                                    nb_files, text_files)
-        self.set_title(title + text)
-
-        file_item = self.files[filename]
-        line = self.truncate_result(line, colno, match_end)
-        item = LineMatchItem(file_item, lineno, colno, line, self.text_color)
-        self.data[id(item)] = (filename, lineno, colno)
+    def set_max_results(self, value):
+        """Set maximum amount of results to add."""
+        self.max_results = value
 
 
 class FileProgressBar(QWidget):
-    """Simple progress spinner with a label"""
+    """Simple progress spinner with a label."""
 
     def __init__(self, parent):
         QWidget.__init__(self, parent)
@@ -932,7 +1065,8 @@ def set_label_path(self, path, folder=False):
         if not folder:
             status_str = _(u' Scanning: {0}').format(text)
         else:
-            status_str = _(u' Searching for files in folder: {0}').format(text)
+            status_str = _(u' Searching for files in folder:'
+                           ' {0}').format(text)
         self.status_text.setText(status_str)
 
     def reset(self):
@@ -954,43 +1088,51 @@ class FindInFilesWidget(QWidget):
     Find in files widget
     """
     sig_finished = Signal()
+    sig_max_results_reached = Signal()
 
-    def __init__(self, parent,
+    def __init__(self,
+                 parent,
                  search_text="",
                  search_text_regexp=False,
                  exclude=EXCLUDE_PATTERNS[0],
                  exclude_idx=None,
                  exclude_regexp=False,
                  supported_encodings=("utf-8", "iso-8859-1", "cp1252"),
-                 more_options=True,
+                 more_options=False,
                  case_sensitive=False,
                  external_path_history=[],
                  search_in_index=0,
                  options_button=None,
-                 text_color=None):
+                 text_color=None,
+                 max_results=1000):
         QWidget.__init__(self, parent)
 
-        self.setWindowTitle(_('Find in files'))
-
         self.search_thread = None
+        self.text_color = text_color
+
+        # Widgets
         self.status_bar = FileProgressBar(self)
+        self.find_options = FindOptions(
+            self,
+            search_text,
+            search_text_regexp,
+            exclude,
+            exclude_idx,
+            exclude_regexp,
+            supported_encodings,
+            more_options,
+            case_sensitive,
+            external_path_history,
+            search_in_index,
+            options_button=options_button)
+
+        self.result_browser = ResultsBrowser(self, text_color=text_color,
+                                             max_results=max_results)
+        # Widget setup
+        self.setWindowTitle(_('Find in files'))
         self.status_bar.hide()
 
-        self.find_options = FindOptions(self, search_text,
-                                        search_text_regexp,
-                                        exclude, exclude_idx,
-                                        exclude_regexp,
-                                        supported_encodings,
-                                        more_options,
-                                        case_sensitive,
-                                        external_path_history,
-                                        search_in_index,
-                                        options_button=options_button)
-        self.find_options.find.connect(self.find)
-        self.find_options.stop.connect(self.stop_and_reset_thread)
-
-        self.result_browser = ResultsBrowser(self, text_color=text_color)
-
+        # Layout
         hlayout = QHBoxLayout()
         hlayout.addWidget(self.result_browser)
 
@@ -1002,17 +1144,26 @@ def __init__(self, parent,
         layout.addWidget(self.status_bar)
         self.setLayout(layout)
 
+        # Signals
+        self.find_options.find.connect(self.find)
+        self.find_options.stop.connect(self.stop_and_reset_thread)
+        self.result_browser.sig_max_results_reached.connect(
+            self.stop_and_reset_thread)
+        self.result_browser.sig_max_results_reached.connect(
+            self.sig_max_results_reached)
+
     def set_search_text(self, text):
         """Set search pattern"""
         self.find_options.set_search_text(text)
 
     def find(self):
         """Call the find function"""
+        search_text = self.find_options.search_text.currentText()
         options = self.find_options.get_options()
         if options is None:
             return
         self.stop_and_reset_thread(ignore_results=True)
-        self.search_thread = SearchThread(self)
+        self.search_thread = SearchThread(self, search_text, self.text_color)
         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)
@@ -1021,22 +1172,26 @@ def find(self):
             lambda x: self.status_bar.set_label_path(x, folder=True)
         )
         self.search_thread.sig_file_match.connect(
+            self.result_browser.append_file_result
+        )
+        self.search_thread.sig_line_match.connect(
             self.result_browser.append_result
         )
         self.search_thread.sig_out_print.connect(
             lambda x: sys.stdout.write(str(x) + "\n")
         )
         self.status_bar.reset()
-        self.result_browser.clear_title(
-            self.find_options.search_text.currentText())
+        self.result_browser.clear_title(search_text)
         self.search_thread.initialize(*options)
+
+        self.find_options.refresh_buttons(start=True)
         self.search_thread.start()
-        self.find_options.ok_button.setEnabled(False)
-        self.find_options.stop_button.setEnabled(True)
+
         self.status_bar.show()
 
     def stop_and_reset_thread(self, ignore_results=False):
         """Stop current search thread and clean-up"""
+        self.find_options.refresh_buttons(start=False)
         if self.search_thread is not None:
             if self.search_thread.isRunning():
                 if ignore_results:
@@ -1055,7 +1210,6 @@ def search_complete(self, completed):
         """Current search thread has finished"""
         self.result_browser.set_sorting(ON)
         self.find_options.ok_button.setEnabled(True)
-        self.find_options.stop_button.setEnabled(False)
         self.status_bar.hide()
         self.result_browser.expandAll()
         if self.search_thread is None:
@@ -1067,6 +1221,10 @@ def search_complete(self, completed):
             results, pathlist, nb, error_flag = found
             self.result_browser.show()
 
+    def set_max_results(self, value):
+        """Set maximum amount of results to add to result browser."""
+        self.result_browser.set_max_results(value)
+
 
 def test():
     """Run Find in Files widget test"""


Test patch:
diff --git a/spyder/plugins/findinfiles/tests/test_widgets.py b/spyder/plugins/findinfiles/tests/test_widgets.py
--- a/spyder/plugins/findinfiles/tests/test_widgets.py
+++ b/spyder/plugins/findinfiles/tests/test_widgets.py
@@ -22,7 +22,7 @@
 from spyder.plugins.findinfiles.widgets import (FindInFilesWidget, SearchInComboBox,
                                                 EXTERNAL_PATHS, SELECT_OTHER, CWD,
                                                 CLEAR_LIST, PROJECT, FILE_PATH,
-                                                QMessageBox)
+                                                QMessageBox, SearchThread)
 from spyder.py3compat import PY2
 
 LOCATION = osp.realpath(osp.join(os.getcwd(), osp.dirname(__file__)))
@@ -230,9 +230,9 @@ def test_truncate_result_with_different_input(findinfiles, qtbot, line_input):
         line_input_expected[slice_end:])
 
     # when
-    truncated_line = findinfiles.result_browser.truncate_result(
-        line_input, slice_start, slice_end)
-
+    thread = SearchThread(None, '')
+    truncated_line = thread.truncate_result(line_input, slice_start,
+                                            slice_end)
     # then
     assert truncated_line == expected_result
 
@@ -565,5 +565,23 @@ def test_max_history(searchin_combobox, mocker):
     assert searchin_combobox.get_external_paths() == expected_results
 
 
+def test_max_results(findinfiles, qtbot):
+    """Test max results correspond to expected results."""
+    value = 2
+    findinfiles.set_max_results(value)
+    findinfiles.set_search_text("spam")
+    findinfiles.find_options.set_directory(osp.join(LOCATION, "data"))
+
+    findinfiles.find()
+    blocker = qtbot.waitSignal(findinfiles.sig_max_results_reached)
+    blocker.wait()
+
+    print(len(findinfiles.result_browser.data), value)
+    assert len(findinfiles.result_browser.data) == value
+
+    # Restore defaults
+    findinfiles.set_max_results(1000)
+
+
 if __name__ == "__main__":
     pytest.main(['-x', osp.basename(__file__), '-v', '-rw'])