Modules (188)

SearchResultsView

Description

Panel showing search results for a Find/Replace in Files operation.

Dependencies

Variables

RESULTS_PER_PAGE Constant

Type
The maximum results to show per page.
Type
number
    var RESULTS_PER_PAGE = 100;

UPDATE_TIMEOUT Constant

Type
Debounce time for document changes updating the search results view.
Type
number
    var UPDATE_TIMEOUT   = 400;

Classes

Constructor

SearchResultsView

model SearchModel
The model that this view is showing.
panelID string
The CSS ID to use for the panel.
panelName string
The name to use for the panel, as passed to WorkspaceManager.createBottomPanel().
    function SearchResultsView(model, panelID, panelName) {
        var panelHtml  = Mustache.render(searchPanelTemplate, {panelID: panelID});

        this._panel    = WorkspaceManager.createBottomPanel(panelName, $(panelHtml), 100);
        this._$summary = this._panel.$panel.find(".title");
        this._$table   = this._panel.$panel.find(".table-container");
        this._model    = model;
    }
    EventDispatcher.makeEventDispatcher(SearchResultsView.prototype);

Properties

Private

_$selectedRow

Type
$.Element
    SearchResultsView.prototype._$selectedRow = null;
Private

_$summary

Type
$.Element
    SearchResultsView.prototype._$summary = null;
Private

_$table

Type
$.Element
    SearchResultsView.prototype._$table = null;
Private

_allChecked

Type
boolean
    SearchResultsView.prototype._allChecked = false;
Private

_currentStart

Type
number
    SearchResultsView.prototype._currentStart = 0;
Private

_initialFilePath

Type
?string
    SearchResultsView.prototype._initialFilePath = null;
Private

_model

Type
SearchModel
    SearchResultsView.prototype._model = null;
Private

_panel

Type
Panel
    SearchResultsView.prototype._panel = null;
Private

_searchList

Array with content used in the Results Panel

Type
Array.<{fileIndex: number, filename: string, fullPath: string, items: Array.<Object>}>
    SearchResultsView.prototype._searchList = [];
Private

_timeoutID

Type
number
    SearchResultsView.prototype._timeoutID = null;

Methods

Private

_addPanelListeners

    SearchResultsView.prototype._addPanelListeners = function () {
        var self = this;
        this._panel.$panel
            .off(".searchResults")  // Remove the old events
            .on("dblclick.searchResults", ".toolbar", function() {
                self.close();
            })
            .on("click.searchResults", ".close", function () {
                self.close();
            })
            // The link to go the first page
            .on("click.searchResults", ".first-page:not(.disabled)", function () {
                self._currentStart = 0;
                self._render();
                HealthLogger.searchDone(HealthLogger.SEARCH_FIRST_PAGE);
            })
            // The link to go the previous page
            .on("click.searchResults", ".prev-page:not(.disabled)", function () {
                self._currentStart -= RESULTS_PER_PAGE;
                self._render();
                HealthLogger.searchDone(HealthLogger.SEARCH_PREV_PAGE);
            })
            // The link to go to the next page
            .on("click.searchResults", ".next-page:not(.disabled)", function () {
                self.trigger('getNextPage');
                HealthLogger.searchDone(HealthLogger.SEARCH_NEXT_PAGE);
            })
            // The link to go to the last page
            .on("click.searchResults", ".last-page:not(.disabled)", function () {
                self.trigger('getLastPage');
                HealthLogger.searchDone(HealthLogger.SEARCH_LAST_PAGE);
            })

            // Add the file to the working set on double click
            .on("dblclick.searchResults", ".table-container tr:not(.file-section)", function (e) {
                var item = self._searchList[$(this).data("file-index")];
                FileViewController.openFileAndAddToWorkingSet(item.fullPath);
            })

            // Add the click event listener directly on the table parent
            .on("click.searchResults .table-container", function (e) {
                var $row = $(e.target).closest("tr");

                if ($row.length) {
                    if (self._$selectedRow) {
                        self._$selectedRow.removeClass("selected");
                    }
                    $row.addClass("selected");
                    self._$selectedRow = $row;

                    var searchItem = self._searchList[$row.data("file-index")],
                        fullPath   = searchItem.fullPath;

                    // This is a file title row, expand/collapse on click
                    if ($row.hasClass("file-section")) {
                        var $titleRows,
                            collapsed = !self._model.results[fullPath].collapsed;

                        if (e.metaKey || e.ctrlKey) { //Expand all / Collapse all
                            $titleRows = $(e.target).closest("table").find(".file-section");
                        } else {
                            // Clicking the file section header collapses/expands result rows for that file
                            $titleRows = $row;
                        }

                        $titleRows.each(function () {
                            fullPath   = self._searchList[$(this).data("file-index")].fullPath;
                            searchItem = self._model.results[fullPath];

                            if (searchItem.collapsed !== collapsed) {
                                searchItem.collapsed = collapsed;
                                $(this).nextUntil(".file-section").toggle();
                                $(this).find(".disclosure-triangle").toggleClass("expanded");
                            }
                        });

                        //In Expand/Collapse all, reset all search results 'collapsed' flag to same value(true/false).
                        if (e.metaKey || e.ctrlKey) {
                            FindUtils.setCollapseResults(collapsed);
                            _.forEach(self._model.results, function (item) {
                                item.collapsed = collapsed;
                            });
                        }

                    // This is a file row, show the result on click
                    } else {
                        // Grab the required item data
                        var item = searchItem.items[$row.data("item-index")];

                        CommandManager.execute(Commands.FILE_OPEN, {fullPath: fullPath})
                            .done(function (doc) {
                                // Opened document is now the current main editor
                                EditorManager.getCurrentFullEditor().setSelection(item.start, item.end, true);
                            });
                    }
                }
            });

        function updateHeaderCheckbox($checkAll) {
            var $allFileRows     = self._panel.$panel.find(".file-section"),
                $checkedFileRows = $allFileRows.filter(function (index) {
                    return $(this).find(".check-one-file").is(":checked");
                });
            if ($checkedFileRows.length === $allFileRows.length) {
                $checkAll.prop("checked", true);
            }
        }

        function updateFileAndHeaderCheckboxes($clickedRow, isChecked) {
            var $firstMatch = ($clickedRow.data("item-index") === 0) ? $clickedRow :
                    $clickedRow.prevUntil(".file-section").last(),
                $fileRow = $firstMatch.prev(),
                $siblingRows = $fileRow.nextUntil(".file-section"),
                $fileCheckbox = $fileRow.find(".check-one-file"),
                $checkAll = self._panel.$panel.find(".check-all");

            if (isChecked) {
                if (!$fileCheckbox.is(":checked")) {
                    var $checkedSibilings = $siblingRows.filter(function (index) {
                            return $(this).find(".check-one").is(":checked");
                        });
                    if ($checkedSibilings.length === $siblingRows.length) {
                        $fileCheckbox.prop("checked", true);
                        if (!$checkAll.is(":checked")) {
                            updateHeaderCheckbox($checkAll);
                        }
                    }
                }
            } else {
                if ($checkAll.is(":checked")) {
                    $checkAll.prop("checked", false);
                }
                if ($fileCheckbox.is(":checked")) {
                    $fileCheckbox.prop("checked", false);
                }
            }
        }

        // Add the Click handlers for replace functionality if required
        if (this._model.isReplace) {
            this._panel.$panel
                .on("click.searchResults", ".check-all", function (e) {
                    var isChecked = $(this).is(":checked");
                    _.forEach(self._model.results, function (results) {
                        results.matches.forEach(function (match) {
                            match.isChecked = isChecked;
                        });
                    });
                    self._$table.find(".check-one").prop("checked", isChecked);
                    self._$table.find(".check-one-file").prop("checked", isChecked);
                    self._allChecked = isChecked;
                })
                .on("click.searchResults", ".check-one-file", function (e) {
                    var isChecked = $(this).is(":checked"),
                        $row = $(e.target).closest("tr"),
                        item = self._searchList[$row.data("file-index")],
                        $matchRows = $row.nextUntil(".file-section"),
                        $checkAll = self._panel.$panel.find(".check-all");

                    if (item) {
                        self._model.results[item.fullPath].matches.forEach(function (match) {
                            match.isChecked = isChecked;
                        });
                    }
                    $matchRows.find(".check-one").prop("checked", isChecked);
                    if (!isChecked) {
                        if ($checkAll.is(":checked")) {
                            $checkAll.prop("checked", false);
                        }
                    } else if (!$checkAll.is(":checked")) {
                        updateHeaderCheckbox($checkAll);
                    }
                    e.stopPropagation();
                })
                .on("click.searchResults", ".check-one", function (e) {
                    var $row = $(e.target).closest("tr"),
                        item = self._searchList[$row.data("file-index")],
                        match = self._model.results[item.fullPath].matches[$row.data("match-index")];

                    match.isChecked = $(this).is(":checked");
                    updateFileAndHeaderCheckboxes($row, match.isChecked);
                    e.stopPropagation();
                })
                .on("click.searchResults", ".replace-checked", function (e) {
                    self.trigger("replaceBatch");
                });
        }
    };
Private

_getLastCurrentStart

numMatches optional number
Returns: number
    SearchResultsView.prototype._getLastCurrentStart = function (numMatches) {
        numMatches = numMatches || this._model.countFilesMatches().matches;
        return Math.floor((numMatches - 1) / RESULTS_PER_PAGE) * RESULTS_PER_PAGE;
    };
Private

_getLastIndex

numMatches number
Returns: number
    SearchResultsView.prototype._getLastIndex = function (numMatches) {
        return Math.min(this._currentStart + RESULTS_PER_PAGE, numMatches);
    };
Private

_handleModelChange

    SearchResultsView.prototype._handleModelChange = function (quickChange) {
        // If this is a replace, to avoid complications with updating, just close ourselves if we hear about
        // a results model change after we've already shown the results initially.
        // TODO: notify user, re-do search in file
        if (this._model.isReplace) {
            this.close();
            return;
        }

        var self = this;
        if (this._timeoutID) {
            window.clearTimeout(this._timeoutID);
        }
        if (quickChange) {
            this._timeoutID = window.setTimeout(function () {
                self._updateResults();
                self._timeoutID = null;
            }, UPDATE_TIMEOUT);
        } else {
            this._updateResults();
        }
    };
Private

_render

    SearchResultsView.prototype._render = function () {
        var searchItems, match, i, item, multiLine,
            count            = this._model.countFilesMatches(),
            searchFiles      = this._model.prioritizeOpenFile(this._initialFilePath),
            lastIndex        = this._getLastIndex(count.matches),
            matchesCounter   = 0,
            showMatches      = false,
            allInFileChecked = true,
            self             = this;

        this._showSummary();
        this._searchList = [];

        // Iterates throuh the files to display the results sorted by filenamess. The loop ends as soon as
        // we filled the results for one page
        searchFiles.some(function (fullPath) {
            showMatches = true;
            item = self._model.results[fullPath];

            // Since the amount of matches on this item plus the amount of matches we skipped until
            // now is still smaller than the first match that we want to display, skip these.
            if (matchesCounter + item.matches.length < self._currentStart) {
                matchesCounter += item.matches.length;
                showMatches = false;

            // If we still haven't skipped enough items to get to the first match, but adding the
            // item matches to the skipped ones is greater the the first match we want to display,
            // then we can display the matches from this item skipping the first ones
            } else if (matchesCounter < self._currentStart) {
                i = self._currentStart - matchesCounter;
                matchesCounter = self._currentStart;

            // If we already skipped enough matches to get to the first match to display, we can start
            // displaying from the first match of this item
            } else if (matchesCounter < lastIndex) {
                i = 0;

            // We can't display more items by now. Break the loop
            } else {
                return true;
            }

            if (showMatches && i < item.matches.length) {
                // Add a row for each match in the file
                searchItems = [];

                allInFileChecked = true;
                // Add matches until we get to the last match of this item, or filling the page
                while (i < item.matches.length && matchesCounter < lastIndex) {
                    match     = item.matches[i];
                    multiLine = match.start.line !== match.end.line;

                    searchItems.push({
                        fileIndex:   self._searchList.length,
                        itemIndex:   searchItems.length,
                        matchIndex:  i,
                        line:        match.start.line + 1,
                        pre:         match.line.substr(0, match.start.ch - match.highlightOffset),
                        highlight:   match.line.substring(match.start.ch - match.highlightOffset, multiLine ? undefined : match.end.ch - match.highlightOffset),
                        post:        multiLine ? "\u2026" : match.line.substr(match.end.ch - match.highlightOffset),
                        start:       match.start,
                        end:         match.end,
                        isChecked:   match.isChecked,
                        isCollapsed: item.collapsed
                    });
                    if (!match.isChecked) {
                        allInFileChecked = false;
                    }
                    matchesCounter++;
                    i++;
                }

                // Add a row for each file
                var relativePath    = FileUtils.getDirectoryPath(ProjectManager.makeProjectRelativeIfPossible(fullPath)),
                    directoryPath   = FileUtils.getDirectoryPath(relativePath),
                    displayFileName = StringUtils.format(
                        Strings.FIND_IN_FILES_FILE_PATH,
                        StringUtils.breakableUrl(FileUtils.getBaseName(fullPath)),
                        StringUtils.breakableUrl(directoryPath),
                        directoryPath ? "&mdash;" : ""
                    );

                self._searchList.push({
                    fileIndex:   self._searchList.length,
                    filename:    displayFileName,
                    fullPath:    fullPath,
                    isChecked:   allInFileChecked,
                    items:       searchItems,
                    isCollapsed: item.collapsed
                });
            }
        });


        // Insert the search results
        this._$table
            .empty()
            .append(Mustache.render(searchResultsTemplate, {
                replace:       this._model.isReplace,
                searchList:    this._searchList,
                Strings:       Strings
            }));

        if (this._$selectedRow) {
            this._$selectedRow.removeClass("selected");
            this._$selectedRow = null;
        }

        this._panel.show();
        this._$table.scrollTop(0); // Otherwise scroll pos from previous contents is remembered
    };
Private

_showSummary

    SearchResultsView.prototype._showSummary = function () {
        var count     = this._model.countFilesMatches(),
            lastIndex = this._getLastIndex(count.matches),
            filesStr,
            summary;

        filesStr = StringUtils.format(
            Strings.FIND_NUM_FILES,
            count.files,
            (count.files > 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE)
        );

        // This text contains some formatting, so all the strings are assumed to be already escaped
        summary = StringUtils.format(
            Strings.FIND_TITLE_SUMMARY,
            this._model.exceedsMaximum ? Strings.FIND_IN_FILES_MORE_THAN : "",
            String(count.matches),
            (count.matches > 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH,
            filesStr
        );

        this._$summary.html(Mustache.render(searchSummaryTemplate, {
            query:       (this._model.queryInfo && this._model.queryInfo.query && this._model.queryInfo.query.toString()) || "",
            replaceWith: this._model.replaceText,
            titleLabel:  this._model.isReplace ? Strings.FIND_REPLACE_TITLE_LABEL : Strings.FIND_TITLE_LABEL,
            scope:       this._model.scope ? "&nbsp;" + FindUtils.labelForScope(this._model.scope) + "&nbsp;" : "",
            summary:     summary,
            allChecked:  this._allChecked,
            hasPages:    count.matches > RESULTS_PER_PAGE,
            results:     StringUtils.format(Strings.FIND_IN_FILES_PAGING, this._currentStart + 1, lastIndex),
            hasPrev:     this._currentStart > 0,
            hasNext:     lastIndex < count.matches,
            replace:     this._model.isReplace,
            Strings:     Strings
        }));
    };
Private

_updateResults

Updates the results view after a model change, preserving scroll position and selection.

    SearchResultsView.prototype._updateResults = function () {
        // In general this shouldn't get called if the panel is closed, but in case some
        // asynchronous process kicks this (e.g. a debounced model change), we double-check.
        if (this._panel.isVisible()) {
            var scrollTop  = this._$table.scrollTop(),
                index      = this._$selectedRow ? this._$selectedRow.index() : null,
                numMatches = this._model.countFilesMatches().matches;

            if (this._currentStart > numMatches) {
                this._currentStart = this._getLastCurrentStart(numMatches);
            }

            this._render();

            this._$table.scrollTop(scrollTop);
            if (index) {
                this._$selectedRow = this._$table.find("tr:eq(" + index + ")");
                this._$selectedRow.addClass("selected");
            }
        }
    };

close

Hides the Search Results Panel and unregisters listeners.

    SearchResultsView.prototype.close = function () {
        if (this._panel && this._panel.isVisible()) {
            this._$table.empty();
            this._panel.hide();
            this._panel.$panel.off(".searchResults");
            this._model.off("change.SearchResultsView");
            this.trigger("close");
        }
    };

    // Public API
    exports.SearchResultsView = SearchResultsView;
});

open

Opens the results panel and displays the current set of results from the model.

    SearchResultsView.prototype.open = function () {
        // Clear out any paging/selection state.
        this._currentStart  = 0;
        this._$selectedRow  = null;
        this._allChecked    = true;

        // Save the currently open document's fullpath, if any, so we can sort it to the top of the result list.
        var currentDoc = DocumentManager.getCurrentDocument();
        this._initialFilePath = currentDoc ? currentDoc.file.fullPath : null;

        this._render();

        // Listen for user interaction events with the panel and change events from the model.
        this._addPanelListeners();
        this._model.on("change.SearchResultsView", this._handleModelChange.bind(this));
    };

showLastPage

Shows the last page of the results view.

    SearchResultsView.prototype.showLastPage = function () {
        this._currentStart = this._getLastCurrentStart();
        this._render();
    };

showNextPage

Shows the next page of the resultrs view if possible

    SearchResultsView.prototype.showNextPage = function () {
        this._currentStart += RESULTS_PER_PAGE;
        this._render();
    };