src/search/SearchResultsView.js
/*
* Copyright (c) 2014 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*
* Panel showing search results for a Find/Replace in Files operation.
*/
define(function (require, exports, module) {
"use strict";
var CommandManager = require("command/CommandManager"),
EventDispatcher = require("utils/EventDispatcher"),
Commands = require("command/Commands"),
DocumentManager = require("document/DocumentManager"),
EditorManager = require("editor/EditorManager"),
ProjectManager = require("project/ProjectManager"),
FileViewController = require("project/FileViewController"),
FileUtils = require("file/FileUtils"),
FindUtils = require("search/FindUtils"),
WorkspaceManager = require("view/WorkspaceManager"),
StringUtils = require("utils/StringUtils"),
Strings = require("strings"),
HealthLogger = require("utils/HealthLogger"),
_ = require("thirdparty/lodash"),
Mustache = require("thirdparty/mustache/mustache"),
searchPanelTemplate = require("text!htmlContent/search-panel.html"),
searchResultsTemplate = require("text!htmlContent/search-results.html"),
searchSummaryTemplate = require("text!htmlContent/search-summary.html");
/**
* @const
* The maximum results to show per page.
* @type {number}
*/
var RESULTS_PER_PAGE = 100;
/**
* @const
* Debounce time for document changes updating the search results view.
* @type {number}
*/
var UPDATE_TIMEOUT = 400;
/**
* @constructor
* Handles the search results panel.
* Dispatches the following events:
* replaceBatch - when the "Replace" button is clicked.
* close - when the panel is closed.
*
* @param {SearchModel} model The model that this view is showing.
* @param {string} panelID The CSS ID to use for the panel.
* @param {string} panelName The name to use for the panel, as passed to WorkspaceManager.createBottomPanel().
* @param {string} type type to identify if it is reference search or string match serach
*/
function SearchResultsView(model, panelID, panelName, type) {
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;
this._searchResultsType = type;
}
EventDispatcher.makeEventDispatcher(SearchResultsView.prototype);
/** @type {SearchModel} The search results model we're viewing. */
SearchResultsView.prototype._model = null;
/**
* Array with content used in the Results Panel
* @type {Array.<{fileIndex: number, filename: string, fullPath: string, items: Array.<Object>}>}
*/
SearchResultsView.prototype._searchList = [];
/** @type {Panel} Bottom panel holding the search results */
SearchResultsView.prototype._panel = null;
/** @type {?string} The full path of the file that was open in the main editor on the initial search */
SearchResultsView.prototype._initialFilePath = null;
/** @type {number} The index of the first result that is displayed */
SearchResultsView.prototype._currentStart = 0;
/** @type {boolean} Used to remake the replace all summary after it is changed */
SearchResultsView.prototype._allChecked = false;
/** @type {$.Element} The currently selected row */
SearchResultsView.prototype._$selectedRow = null;
/** @type {$.Element} The element where the title is placed */
SearchResultsView.prototype._$summary = null;
/** @type {$.Element} The table that holds the results */
SearchResultsView.prototype._$table = null;
/** @type {number} The ID we use for timeouts when handling model changes. */
SearchResultsView.prototype._timeoutID = null;
/** @type {string} The Id we use to check if it is reference search or match search */
SearchResultsView.prototype._searchResultsType = null;
/**
* @private
* Handles when model changes. Updates the view, buffering changes if necessary so as not to churn too much.
*/
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
* Adds the listeners for close, prev, next, first, last and check all
*/
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
* Shows the Results Summary
*/
SearchResultsView.prototype._showSummary = function () {
var count = this._model.countFilesMatches(),
lastIndex = this._getLastIndex(count.matches),
typeStr = (count.matches > 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH,
filesStr,
summary;
if(this._searchResultsType === "reference") {
typeStr = (count.matches > 1) ? Strings.REFERENCES_IN_FILES : Strings.REFERENCE_IN_FILES;
}
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),
typeStr,
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 ? " " + FindUtils.labelForScope(this._model.scope) + " " : "",
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
* Shows the current set of results.
*/
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 ? "—" : ""
);
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
};
/**
* 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");
}
}
};
/**
* @private
* Returns one past the last result index displayed for the current page.
* @param {number} numMatches
* @return {number}
*/
SearchResultsView.prototype._getLastIndex = function (numMatches) {
return Math.min(this._currentStart + RESULTS_PER_PAGE, numMatches);
};
/**
* Shows the next page of the resultrs view if possible
*/
SearchResultsView.prototype.showNextPage = function () {
this._currentStart += RESULTS_PER_PAGE;
this._render();
};
/**
* Shows the last page of the results view.
*/
SearchResultsView.prototype.showLastPage = function () {
this._currentStart = this._getLastCurrentStart();
this._render();
};
/**
* @private
* Returns the last possible current start based on the given number of matches
* @param {number=} numMatches
* @return {number}
*/
SearchResultsView.prototype._getLastCurrentStart = function (numMatches) {
numMatches = numMatches || this._model.countFilesMatches().matches;
return Math.floor((numMatches - 1) / RESULTS_PER_PAGE) * RESULTS_PER_PAGE;
};
/**
* 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));
};
/**
* 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;
});