diff --git a/src/htmlContent/search-panel.html b/src/htmlContent/search-panel.html index 69efe7c01de..33f939fb330 100644 --- a/src/htmlContent/search-panel.html +++ b/src/htmlContent/search-panel.html @@ -1,4 +1,4 @@ -
+
× diff --git a/src/htmlContent/search-replace-panel.html b/src/htmlContent/search-replace-panel.html deleted file mode 100644 index c199e36de3b..00000000000 --- a/src/htmlContent/search-replace-panel.html +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
-
{{FIND_REPLACE_TITLE_PART1}}
-
-
{{FIND_REPLACE_TITLE_PART2}}
-
-
-
- -
-
- × -
-
-
\ No newline at end of file diff --git a/src/htmlContent/search-replace-results.html b/src/htmlContent/search-replace-results.html deleted file mode 100644 index d82142b8b3b..00000000000 --- a/src/htmlContent/search-replace-results.html +++ /dev/null @@ -1,11 +0,0 @@ - - - {{#searchResults}} - - - - - - {{/searchResults}} - -
{{line}}{{pre}}{{highlight}}{{post}}
diff --git a/src/htmlContent/search-results.html b/src/htmlContent/search-results.html index 56cb3fd8122..cacabcaca43 100644 --- a/src/htmlContent/search-results.html +++ b/src/htmlContent/search-results.html @@ -2,13 +2,14 @@ {{#searchList}} - + {{{filename}}} {{#items}} - + + {{#hasCheckboxes}}{{/hasCheckboxes}} {{line}} {{pre}}{{highlight}}{{post}} diff --git a/src/htmlContent/search-summary-find.html b/src/htmlContent/search-summary-find.html new file mode 100644 index 00000000000..a9e4ce24e13 --- /dev/null +++ b/src/htmlContent/search-summary-find.html @@ -0,0 +1,6 @@ +
{{Strings.FIND_IN_FILES_TITLE_PART1}}
+
{{query}}
+
{{Strings.FIND_IN_FILES_TITLE_PART2}}
+
{{{scope}}}
+
{{{summary}}}
+{{>paging}} diff --git a/src/htmlContent/search-summary.html b/src/htmlContent/search-summary-paging.html similarity index 55% rename from src/htmlContent/search-summary.html rename to src/htmlContent/search-summary-paging.html index e74b562853a..5d4a88d9f6d 100644 --- a/src/htmlContent/search-summary.html +++ b/src/htmlContent/search-summary-paging.html @@ -1,8 +1,3 @@ -
{{Strings.FIND_IN_FILES_TITLE_PART1}}
-
{{query}}
-
{{Strings.FIND_IN_FILES_TITLE_PART2}}
-
{{{scope}}}
-
{{{summary}}}
{{#hasPages}}
@@ -11,4 +6,4 @@
-{{/hasPages}} \ No newline at end of file +{{/hasPages}} diff --git a/src/htmlContent/search-summary-replace.html b/src/htmlContent/search-summary-replace.html new file mode 100644 index 00000000000..e44c7091435 --- /dev/null +++ b/src/htmlContent/search-summary-replace.html @@ -0,0 +1,10 @@ +
+
{{Strings.FIND_REPLACE_TITLE_PART1}}
+
{{replaceWhat}}
+
{{Strings.FIND_REPLACE_TITLE_PART2}}
+
{{replaceWith}}
+
{{{summary}}}
+{{>paging}} +
+ +
diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js index ce8ec3a9b03..e235536124f 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -37,13 +37,9 @@ * - Handle matches that span multiple lines * - Refactor UI from functionality to enable unit testing */ - - define(function (require, exports, module) { "use strict"; - var _ = require("thirdparty/lodash"); - var Async = require("utils/Async"), Resizer = require("utils/Resizer"), CommandManager = require("command/CommandManager"), @@ -55,40 +51,27 @@ define(function (require, exports, module) { DocumentManager = require("document/DocumentManager"), EditorManager = require("editor/EditorManager"), FileSystem = require("filesystem/FileSystem"), - FileUtils = require("file/FileUtils"), - FileViewController = require("project/FileViewController"), LanguageManager = require("language/LanguageManager"), FindReplace = require("search/FindReplace"), + SearchResults = require("search/SearchResults").SearchResults, PerfUtils = require("utils/PerfUtils"), InMemoryFile = require("document/InMemoryFile"), - PanelManager = require("view/PanelManager"), KeyEvent = require("utils/KeyEvent"), AppInit = require("utils/AppInit"), StatusBar = require("widgets/StatusBar"), - ModalBar = require("widgets/ModalBar").ModalBar; + ModalBar = require("widgets/ModalBar").ModalBar, + _ = require("thirdparty/lodash"); var searchDialogTemplate = require("text!htmlContent/findinfiles-bar.html"), - searchPanelTemplate = require("text!htmlContent/search-panel.html"), - searchSummaryTemplate = require("text!htmlContent/search-summary.html"), - searchResultsTemplate = require("text!htmlContent/search-results.html"); + searchSummaryTemplate = require("text!htmlContent/search-summary-find.html"); + /** @const Constants used to define the maximum results show per page and found in a single file */ - - var RESULTS_PER_PAGE = 100, - FIND_IN_FILE_MAX = 300, + var FIND_IN_FILE_MAX = 300, UPDATE_TIMEOUT = 400; - /** - * Map of all the last search results - * @type {Object., collapsed: boolean}>} - */ - var searchResults = {}; - - /** @type {Panel} Bottom panel holding the search results. Initialized in htmlReady() */ - var searchResultsPanel; - - /** @type {number} The index of the first result that is displayed */ - var currentStart = 0; + /** @type {FindInFilesResults} The find in files results. Initialized in htmlReady() */ + var findInFilesResults; /** @type {string} The current search query */ var currentQuery = ""; @@ -105,23 +88,11 @@ define(function (require, exports, module) { /** @type {string} The setTimeout id, used to clear it if required */ var timeoutID = null; - /** @type {$.Element} jQuery elements used in the search results */ - var $searchResults, - $searchSummary, - $searchContent, - $selectedRow; - /** @type {FindInFilesDialog} dialog having the modalbar for search */ var dialog = null; - /** - * FileSystem change event handler. Updates the search results based on a changed - * entry and optionally sets of added and removed child entries. - * - * @type {function(FileSystemEntry, Array.=, Array.=)} - **/ - var _fileSystemChangeHandler; - + + /** * @private * Returns a regular expression from the given query and shows an error in the modal-bar if it was invalid @@ -158,6 +129,7 @@ define(function (require, exports, module) { * @private * Returns label text to indicate the search scope. Already HTML-escaped. * @param {?Entry} scope + * @return {string} */ function _labelForScope(scope) { var projName = ProjectManager.getProjectRoot().name; @@ -173,461 +145,7 @@ define(function (require, exports, module) { } } - /** - * @private - * Hides the Search Results Panel - */ - function _hideSearchResults() { - if (searchResultsPanel.isVisible()) { - searchResultsPanel.hide(); - $(DocumentModule).off(".findInFiles"); - } - - FileSystem.off("change", _fileSystemChangeHandler); - } - /** - * @private - * Searches through the contents an returns an array of matches - * @param {string} contents - * @param {RegExp} queryExpr - * @return {Array.<{start: {line:number,ch:number}, end: {line:number,ch:number}, line: string}>} - */ - function _getSearchMatches(contents, queryExpr) { - // Quick exit if not found - if (contents.search(queryExpr) === -1) { - return null; - } - - var match, lineNum, line, ch, matchLength, - lines = StringUtils.getLines(contents), - matches = []; - - while ((match = queryExpr.exec(contents)) !== null) { - lineNum = StringUtils.offsetToLineNum(lines, match.index); - line = lines[lineNum]; - ch = match.index - contents.lastIndexOf("\n", match.index) - 1; // 0-based index - matchLength = match[0].length; - - // Don't store more than 200 chars per line - line = line.substr(0, Math.min(200, line.length)); - - matches.push({ - start: {line: lineNum, ch: ch}, - end: {line: lineNum, ch: ch + matchLength}, - line: line - }); - - // We have the max hits in just this 1 file. Stop searching this file. - // This fixed issue #1829 where code hangs on too many hits. - if (matches.length >= FIND_IN_FILE_MAX) { - queryExpr.lastIndex = 0; - maxHitsFoundInFile = true; - break; - } - } - - return matches; - } - - /** - * @private - * Searches and stores the match results for the given file, if there are matches - * @param {string} fullPath - * @param {string} contents - * @param {RegExp} queryExpr - * @return {boolean} True iff matches were added to the search results - */ - function _addSearchMatches(fullPath, contents, queryExpr) { - var matches = _getSearchMatches(contents, queryExpr); - - if (matches && matches.length) { - searchResults[fullPath] = { - matches: matches, - collapsed: false - }; - return true; - } - return false; - } - - /** - * @private - * Count the total number of matches and files - * @return {{files: number, matches: number}} - */ - function _countFilesMatches() { - var numFiles = 0, numMatches = 0; - _.forEach(searchResults, function (item) { - numFiles++; - numMatches += item.matches.length; - }); - - return {files: numFiles, matches: numMatches}; - } - - /** - * @private - * Returns the last possible current start based on the given number of matches - * @param {number} numMatches - * @return {number} - */ - function _getLastCurrentStart(numMatches) { - return Math.floor((numMatches - 1) / RESULTS_PER_PAGE) * RESULTS_PER_PAGE; - } - - /** - * @private - * Shows the results in a table and adds the necessary event listeners - */ - function _showSearchResults() { - if (!$.isEmptyObject(searchResults)) { - var count = _countFilesMatches(); - - // Show result summary in header - var numMatchesStr = ""; - if (maxHitsFoundInFile) { - numMatchesStr = Strings.FIND_IN_FILES_MORE_THAN; - } - - // This text contains some formatting, so all the strings are assumed to be already escaped - var summary = StringUtils.format( - Strings.FIND_IN_FILES_TITLE_PART3, - numMatchesStr, - String(count.matches), - (count.matches > 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, - count.files, - (count.files > 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE) - ); - - // The last result index displayed - var last = Math.min(currentStart + RESULTS_PER_PAGE, count.matches); - - // Insert the search summary - $searchSummary.html(Mustache.render(searchSummaryTemplate, { - query: currentQuery, - scope: currentScope ? " " + _labelForScope(currentScope) + " " : "", - summary: summary, - hasPages: count.matches > RESULTS_PER_PAGE, - results: StringUtils.format(Strings.FIND_IN_FILES_PAGING, currentStart + 1, last), - hasPrev: currentStart > 0, - hasNext: last < count.matches, - Strings: Strings - })); - - // Create the results template search list - var searchItems, match, i, - searchList = [], - matchesCounter = 0, - showMatches = false; - - _.some(searchResults, function (item, fullPath) { - showMatches = true; - - // 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 < 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 < currentStart) { - i = currentStart - matchesCounter; - matchesCounter = 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 < last) { - 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 = []; - - // Add matches until we get to the last match of this item, or filling the page - while (i < item.matches.length && matchesCounter < last) { - match = item.matches[i]; - searchItems.push({ - file: searchList.length, - item: searchItems.length, - line: match.start.line + 1, - pre: match.line.substr(0, match.start.ch), - highlight: match.line.substring(match.start.ch, match.end.ch), - post: match.line.substr(match.end.ch), - start: match.start, - end: match.end - }); - 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 ? "—" : "" - ); - - searchList.push({ - file: searchList.length, - filename: displayFileName, - fullPath: fullPath, - items: searchItems - }); - } - }); - - // Add the listeners for close, prev and next - $searchResults - .off(".searchList") // Remove the old events - .one("click.searchList", ".close", function () { - _hideSearchResults(); - }) - // The link to go the first page - .one("click.searchList", ".first-page:not(.disabled)", function () { - currentStart = 0; - _showSearchResults(); - }) - // The link to go the previous page - .one("click.searchList", ".prev-page:not(.disabled)", function () { - currentStart -= RESULTS_PER_PAGE; - _showSearchResults(); - }) - // The link to go to the next page - .one("click.searchList", ".next-page:not(.disabled)", function () { - currentStart += RESULTS_PER_PAGE; - _showSearchResults(); - }) - // The link to go to the last page - .one("click.searchList", ".last-page:not(.disabled)", function () { - currentStart = _getLastCurrentStart(count.matches); - _showSearchResults(); - }); - - // Insert the search results - $searchContent - .empty() - .append(Mustache.render(searchResultsTemplate, {searchList: searchList, Strings: Strings})) - .off(".searchList") // Remove the old events - - // Add the click event listener directly on the table parent - .on("click.searchList", function (e) { - var $row = $(e.target).closest("tr"); - - if ($row.length) { - if ($selectedRow) { - $selectedRow.removeClass("selected"); - } - $row.addClass("selected"); - $selectedRow = $row; - - var searchItem = searchList[$row.data("file")], - fullPath = searchItem.fullPath; - - // This is a file title row, expand/collapse on click - if ($row.hasClass("file-section")) { - var $titleRows, - collapsed = !searchResults[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 = searchList[$(this).data("file")].fullPath; - searchItem = searchResults[fullPath]; - - if (searchItem.collapsed !== collapsed) { - searchItem.collapsed = collapsed; - $(this).nextUntil(".file-section").toggle(); - $(this).find(".disclosure-triangle").toggleClass("expanded").toggleClass("collapsed"); - } - }); - - //In Expand/Collapse all, reset all search results 'collapsed' flag to same value(true/false). - if (e.metaKey || e.ctrlKey) { - _.forEach(searchResults, 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")]; - - 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); - }); - } - } - }) - // Add the file to the working set on double click - .on("dblclick.searchList", "tr:not(.file-section)", function (e) { - var item = searchList[$(this).data("file")]; - FileViewController.addToWorkingSetAndSelect(item.fullPath); - }) - // Restore the collapsed files - .find(".file-section").each(function () { - var fullPath = searchList[$(this).data("file")].fullPath; - - if (searchResults[fullPath].collapsed) { - searchResults[fullPath].collapsed = false; - $(this).trigger("click"); - } - }); - - if ($selectedRow) { - $selectedRow.removeClass("selected"); - $selectedRow = null; - } - searchResultsPanel.show(); - $searchContent.scrollTop(0); // Otherwise scroll pos from previous contents is remembered - - if (dialog) { - dialog._close(); - } - - FileSystem.on("change", _fileSystemChangeHandler); - } else { - - _hideSearchResults(); - - if (dialog) { - dialog.getDialogTextField().addClass("no-results") - .removeAttr("disabled") - .get(0).select(); - $(".modal-bar .no-results-message").show(); - } - } - } - - /** - * @private - * Shows the search results and tries to restore the previous scroll and selection - */ - function _restoreSearchResults() { - if (searchResultsPanel.isVisible()) { - var scrollTop = $searchContent.scrollTop(), - index = $selectedRow ? $selectedRow.index() : null, - numMatches = _countFilesMatches().matches; - - if (currentStart > numMatches) { - currentStart = _getLastCurrentStart(numMatches); - } - _showSearchResults(); - - $searchContent.scrollTop(scrollTop); - if (index) { - $selectedRow = $searchContent.find("tr:eq(" + index + ")"); - $selectedRow.addClass("selected"); - } - } - } - - /** - * @private - * Update the search results using the given list of changes fr the given document - * @param {Document} doc The Document that changed, should be the current one - * @param {{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}} change - * A linked list as described in the Document constructor - * @param {boolean} resultsChanged True when the search results changed from a file change - */ - function _updateSearchResults(doc, change, resultsChanged) { - var i, diff, matches, - fullPath = doc.file.fullPath, - lines = [], - start = 0, - howMany = 0; - - // There is no from or to positions, so the entire file changed, we must search all over again - if (!change.from || !change.to) { - _addSearchMatches(fullPath, doc.getText(), currentQueryExpr); - resultsChanged = true; - - } else { - // Get only the lines that changed - for (i = 0; i < change.text.length; i++) { - lines.push(doc.getLine(change.from.line + i)); - } - - // We need to know how many lines changed to update the rest of the lines - if (change.from.line !== change.to.line) { - diff = change.from.line - change.to.line; - } else { - diff = lines.length - 1; - } - - if (searchResults[fullPath]) { - // Search the last match before a replacement, the amount of matches deleted and update - // the lines values for all the matches after the change - searchResults[fullPath].matches.forEach(function (item) { - if (item.end.line < change.from.line) { - start++; - } else if (item.end.line <= change.to.line) { - howMany++; - } else { - item.start.line += diff; - item.end.line += diff; - } - }); - - // Delete the lines that where deleted or replaced - if (howMany > 0) { - searchResults[fullPath].matches.splice(start, howMany); - } - resultsChanged = true; - } - - // Searches only over the lines that changed - matches = _getSearchMatches(lines.join("\r\n"), currentQueryExpr); - if (matches && matches.length) { - // Updates the line numbers, since we only searched part of the file - matches.forEach(function (value, key) { - matches[key].start.line += change.from.line; - matches[key].end.line += change.from.line; - }); - - // If the file index exists, add the new matches to the file at the start index found before - if (searchResults[fullPath]) { - Array.prototype.splice.apply(searchResults[fullPath].matches, [start, 0].concat(matches)); - // If not, add the matches to a new file index - } else { - searchResults[fullPath] = { - matches: matches, - collapsed: false - }; - } - resultsChanged = true; - } - - // All the matches where deleted, remove the file from the results - if (searchResults[fullPath] && !searchResults[fullPath].matches.length) { - delete searchResults[fullPath]; - resultsChanged = true; - } - - // This is link to the next change object, so we need to keep searching - if (change.next) { - return _updateSearchResults(doc, change.next, resultsChanged); - } - } - return resultsChanged; - } /** * @private @@ -648,30 +166,6 @@ define(function (require, exports, module) { return true; } - /** - * @private - * Tries to update the search result on document changes - * @param {$.Event} event - * @param {Document} document - * @param {{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}} change - * A linked list as described in the Document constructor - */ - function _documentChangeHandler(event, document, change) { - if (searchResultsPanel.isVisible() && _inScope(document.file, currentScope)) { - var updateResults = _updateSearchResults(document, change, false); - - if (timeoutID) { - window.clearTimeout(timeoutID); - updateResults = true; - } - if (updateResults) { - timeoutID = window.setTimeout(function () { - _restoreSearchResults(); - timeoutID = null; - }, UPDATE_TIMEOUT); - } - } - } function _doSearchInOneFile(addMatches, file) { var result = new $.Deferred(); @@ -693,9 +187,9 @@ define(function (require, exports, module) { } /** + * @private * Used to filter out image files when building a list of file in which to * search. Ideally this would filter out ALL binary files. - * @private * @param {FileSystemEntry} entry The entry to test * @return {boolean} Whether or not the entry's contents should be searched */ @@ -724,15 +218,15 @@ define(function (require, exports, module) { ProjectManager.getAllFiles(_findInFilesFilter, true) .then(function (fileListResult) { - var doSearch = _doSearchInOneFile.bind(undefined, _addSearchMatches); + var doSearch = _doSearchInOneFile.bind(undefined, findInFilesResults._addSearchMatches.bind(findInFilesResults)); return Async.doInParallel(fileListResult, doSearch); }) .done(function () { // Done searching all files: show results - _showSearchResults(); + findInFilesResults.showResults(); StatusBar.hideBusyIndicator(); PerfUtils.addMeasurement(perfTimer); - $(DocumentModule).on("documentChange.findInFiles", _documentChangeHandler); + $(DocumentModule).on("documentChange.findInFiles", findInFilesResults._documentChangeHandler.bind(findInFilesResults)); }) .fail(function (err) { console.log("find in files failed: ", err); @@ -886,13 +380,12 @@ define(function (require, exports, module) { } dialog = new FindInFilesDialog(); - searchResults = {}; - currentStart = 0; currentQuery = ""; currentQueryExpr = null; currentScope = scope; maxHitsFoundInFile = false; - + + findInFilesResults.initializeResults(); dialog.showDialog(initialString, scope); } @@ -906,6 +399,268 @@ define(function (require, exports, module) { } + + + /** + * @private + * @constructor + * @extends {SearchResults} + * Handles the Find in Files Results and the Results Panel + */ + function FindInFilesResults() { + this.summaryTemplate = searchSummaryTemplate; + this.createPanel("findInFilesResults", "find-in-files.results"); + } + + FindInFilesResults.prototype = Object.create(SearchResults.prototype); + FindInFilesResults.prototype.constructor = FindInFilesResults; + FindInFilesResults.prototype.parentClass = SearchResults.prototype; + + /** + * Hides the Search Results Panel + */ + FindInFilesResults.prototype.hideResults = function () { + var self = this; + if (this.panel.isVisible()) { + this.panel.hide(); + $(DocumentModule).off(".findInFiles"); + } + + FileSystem.off("change", function () { self._fileSystemChangeHandler(); }); + }; + + /** + * @private + * Shows the results in a table and adds the necessary event listeners + */ + FindInFilesResults.prototype.showResults = function () { + if (!$.isEmptyObject(this.searchResults)) { + var count = this._countFilesMatches(), + self = this; + + // Show result summary in header + var numMatchesStr = ""; + if (maxHitsFoundInFile) { + numMatchesStr = Strings.FIND_IN_FILES_MORE_THAN; + } + + // This text contains some formatting, so all the strings are assumed to be already escaped + var summary = StringUtils.format( + Strings.FIND_IN_FILES_TITLE_PART3, + numMatchesStr, + String(count.matches), + (count.matches > 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, + count.files, + (count.files > 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE) + ); + + // Insert the search summary + this._showSummary({ + query: currentQuery, + scope: currentScope ? " " + _labelForScope(currentScope) + " " : "", + summary: summary + }); + + // Create the results template search list + this._showResultsList(); + + if (dialog) { + dialog._close(); + } + + FileSystem.on("change", function () { self._fileSystemChangeHandler(); }); + + } else { + this.hideResults(); + + if (dialog) { + dialog.getDialogTextField() + .addClass("no-results") + .removeAttr("disabled") + .get(0).select(); + $(".modal-bar .no-results-message").show(); + } + } + }; + + /** + * @private + * Searches through the contents an returns an array of matches + * @param {string} contents + * @param {RegExp} queryExpr + * @return {Array.<{start: {line:number,ch:number}, end: {line:number,ch:number}, line: string}>} + */ + FindInFilesResults.prototype._getSearchMatches = function (contents, queryExpr) { + // Quick exit if not found + if (contents.search(queryExpr) === -1) { + return null; + } + + var match, lineNum, line, ch, matchLength, + lines = StringUtils.getLines(contents), + matches = []; + + while ((match = queryExpr.exec(contents)) !== null) { + lineNum = StringUtils.offsetToLineNum(lines, match.index); + line = lines[lineNum]; + ch = match.index - contents.lastIndexOf("\n", match.index) - 1; // 0-based index + matchLength = match[0].length; + + // Don't store more than 200 chars per line + line = line.substr(0, Math.min(200, line.length)); + + matches.push({ + start: {line: lineNum, ch: ch}, + end: {line: lineNum, ch: ch + matchLength}, + line: line + }); + + // We have the max hits in just this 1 file. Stop searching this file. + // This fixed issue #1829 where code hangs on too many hits. + if (matches.length >= FIND_IN_FILE_MAX) { + queryExpr.lastIndex = 0; + maxHitsFoundInFile = true; + break; + } + } + + return matches; + }; + + /** + * @private + * Searches and stores the match results for the given file, if there are matches + * @param {string} fullPath + * @param {string} contents + * @param {RegExp} queryExpr + * @return {boolean} True iff the matches were added to the search results + */ + FindInFilesResults.prototype._addSearchMatches = function (fullPath, contents, queryExpr) { + var matches = this._getSearchMatches(contents, queryExpr); + + if (matches && matches.length) { + this.addResultMatches(fullPath, matches); + return true; + } + return false; + }; + + + + /** + * @private + * Tries to update the search result on document changes + * @param {$.Event} event + * @param {Document} document + * @param {{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}} change + * A linked list as described in the Document constructor + */ + FindInFilesResults.prototype._documentChangeHandler = function (event, document, change) { + var self = this; + if (this.panel.isVisible() && _inScope(document.file, currentScope)) { + var updateResults = this._updateResults(document, change, false); + + if (timeoutID) { + window.clearTimeout(timeoutID); + updateResults = true; + } + if (updateResults) { + timeoutID = window.setTimeout(function () { + self.restoreResults(); + timeoutID = null; + }, UPDATE_TIMEOUT); + } + } + }; + + /** + * @private + * Update the search results using the given list of changes fr the given document + * @param {Document} doc The Document that changed, should be the current one + * @param {{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}} change + * A linked list as described in the Document constructor + * @param {boolean} resultsChanged True when the search results changed from a file change + */ + FindInFilesResults.prototype._updateResults = function (doc, change, resultsChanged) { + var i, diff, matches, + fullPath = doc.file.fullPath, + lines = [], + start = 0, + howMany = 0; + + // There is no from or to positions, so the entire file changed, we must search all over again + if (!change.from || !change.to) { + this._addSearchMatches(fullPath, doc.getText(), currentQueryExpr); + resultsChanged = true; + + } else { + // Get only the lines that changed + for (i = 0; i < change.text.length; i++) { + lines.push(doc.getLine(change.from.line + i)); + } + + // We need to know how many lines changed to update the rest of the lines + if (change.from.line !== change.to.line) { + diff = change.from.line - change.to.line; + } else { + diff = lines.length - 1; + } + + if (this.searchResults[fullPath]) { + // Search the last match before a replacement, the amount of matches deleted and update + // the lines values for all the matches after the change + this.searchResults[fullPath].matches.forEach(function (item) { + if (item.end.line < change.from.line) { + start++; + } else if (item.end.line <= change.to.line) { + howMany++; + } else { + item.start.line += diff; + item.end.line += diff; + } + }); + + // Delete the lines that where deleted or replaced + if (howMany > 0) { + this.searchResults[fullPath].matches.splice(start, howMany); + } + resultsChanged = true; + } + + // Searches only over the lines that changed + matches = this._getSearchMatches(lines.join("\r\n"), currentQueryExpr); + if (matches && matches.length) { + // Updates the line numbers, since we only searched part of the file + matches.forEach(function (value, key) { + matches[key].start.line += change.from.line; + matches[key].end.line += change.from.line; + }); + + // If the file index exists, add the new matches to the file at the start index found before + if (this.searchResults[fullPath]) { + Array.prototype.splice.apply(this.searchResults[fullPath].matches, [start, 0].concat(matches)); + // If not, add the matches to a new file index + } else { + this.addResultMatches(fullPath, matches); + } + resultsChanged = true; + } + + // All the matches where deleted, remove the file from the results + if (this.searchResults[fullPath] && !this.searchResults[fullPath].matches.length) { + delete this.searchResults[fullPath]; + resultsChanged = true; + } + + // This is link to the next change object, so we need to keep searching + if (change.next) { + return this._updateResults(doc, change.next, resultsChanged); + } + } + return resultsChanged; + }; + + /** * @private * Moves the search results from the previous path to the new one and updates the results list, if required @@ -913,25 +668,26 @@ define(function (require, exports, module) { * @param {string} oldName * @param {string} newName */ - function _fileNameChangeHandler(event, oldName, newName) { - var resultsChanged = false; + FindInFilesResults.prototype._fileNameChangeHandler = function (event, oldName, newName) { + var resultsChanged = false, + self = this; - if (searchResultsPanel.isVisible()) { + if (this.panel.isVisible()) { // Update the search results - _.forEach(searchResults, function (item, fullPath) { + _.forEach(this.searchResults, function (item, fullPath) { if (fullPath.match(oldName)) { - searchResults[fullPath.replace(oldName, newName)] = item; - delete searchResults[fullPath]; + self.searchResults[fullPath.replace(oldName, newName)] = item; + delete self.searchResults[fullPath]; resultsChanged = true; } }); // Restore the results if needed if (resultsChanged) { - _restoreSearchResults(); + this.restoreResults(); } } - } + }; /** * @private @@ -941,17 +697,18 @@ define(function (require, exports, module) { * @param {Array.=} added Added children * @param {Array.=} removed Removed children */ - _fileSystemChangeHandler = function (event, entry, added, removed) { - var resultsChanged = false; + FindInFilesResults.prototype._fileSystemChangeHandler = function (event, entry, added, removed) { + var resultsChanged = false, + self = this; /* * Remove existing search results that match the given entry's path - * @param {File|Directory} + * @param {(File|Directory)} entry */ function _removeSearchResultsForEntry(entry) { - Object.keys(searchResults).forEach(function (fullPath) { + Object.keys(self.searchResults).forEach(function (fullPath) { if (fullPath.indexOf(entry.fullPath) === 0) { - delete searchResults[fullPath]; + delete self.searchResults[fullPath]; resultsChanged = true; } }); @@ -959,15 +716,15 @@ define(function (require, exports, module) { /* * Add new search results for this entry and all of its children - * @param {File|Directory} - * @param {jQuery.Promise} Resolves when the results have been added + * @param {(File|Directory)} entry + * @return {jQuery.Promise} Resolves when the results have been added */ function _addSearchResultsForEntry(entry) { var addedFiles = [], deferred = new $.Deferred(); var doSearch = _doSearchInOneFile.bind(undefined, function () { - if (_addSearchMatches.apply(undefined, arguments)) { + if (self._addSearchMatches(entry)) { resultsChanged = true; } }); @@ -1025,25 +782,22 @@ define(function (require, exports, module) { addPromise.always(function () { // Restore the results if needed if (resultsChanged) { - _restoreSearchResults(); + self.restoreResults(); } }); - }; + + + // Initialize items dependent on HTML DOM AppInit.htmlReady(function () { - var panelHtml = Mustache.render(searchPanelTemplate, Strings); - searchResultsPanel = PanelManager.createBottomPanel("find-in-files.results", $(panelHtml), 100); - - $searchResults = $("#search-results"); - $searchSummary = $searchResults.find(".title"); - $searchContent = $("#search-results .table-container"); + findInFilesResults = new FindInFilesResults(); }); // Initialize: register listeners - $(DocumentManager).on("fileNameChange", _fileNameChangeHandler); - $(ProjectManager).on("beforeProjectClose", _hideSearchResults); + $(DocumentManager).on("fileNameChange", function () { findInFilesResults._fileNameChangeHandler(); }); + $(ProjectManager).on("beforeProjectClose", function () { findInFilesResults.hideResults(); }); FindReplace._registerFindInFilesCloser(function () { if (dialog) { diff --git a/src/search/FindReplace.js b/src/search/FindReplace.js index 4cfb8cca485..22d4f537263 100644 --- a/src/search/FindReplace.js +++ b/src/search/FindReplace.js @@ -39,22 +39,24 @@ define(function (require, exports, module) { AppInit = require("utils/AppInit"), Commands = require("command/Commands"), DocumentManager = require("document/DocumentManager"), + ProjectManager = require("project/ProjectManager"), Strings = require("strings"), StringUtils = require("utils/StringUtils"), Editor = require("editor/Editor"), EditorManager = require("editor/EditorManager"), ModalBar = require("widgets/ModalBar").ModalBar, KeyEvent = require("utils/KeyEvent"), + SearchResults = require("search/SearchResults").SearchResults, ScrollTrackMarkers = require("search/ScrollTrackMarkers"), PanelManager = require("view/PanelManager"), Resizer = require("utils/Resizer"), StatusBar = require("widgets/StatusBar"), PreferencesManager = require("preferences/PreferencesManager"), - ViewUtils = require("utils/ViewUtils"); + ViewUtils = require("utils/ViewUtils"), + _ = require("thirdparty/lodash"); - var searchBarTemplate = require("text!htmlContent/findreplace-bar.html"), - searchReplacePanelTemplate = require("text!htmlContent/search-replace-panel.html"), - searchReplaceResultsTemplate = require("text!htmlContent/search-replace-results.html"); + var searchBarTemplate = require("text!htmlContent/findreplace-bar.html"), + replaceAllSummaryTemplate = require("text!htmlContent/search-summary-replace.html"); /** @const Maximum file size to search within (in chars) */ var FIND_MAX_FILE_SIZE = 500000; @@ -63,21 +65,14 @@ define(function (require, exports, module) { var FIND_HIGHLIGHT_MAX = 2000; /** @const Maximum number of matches to collect for Replace All; any additional matches are not listed in the panel & are not replaced */ - var REPLACE_ALL_MAX = 300; + var REPLACE_ALL_MAX = 10000; + + /** @type {ReplaceAllResults} The find in files results. Initialized in htmlReady() */ + var replaceAllResults; - /** @type {!Panel} Panel that shows results of replaceAll action */ - var replaceAllPanel = null; - /** @type {?Document} Instance of the currently opened document when replaceAllPanel is visible */ var currentDocument = null; - - /** @type {$.Element} jQuery elements used in the replaceAll panel */ - var $replaceAllContainer, - $replaceAllWhat, - $replaceAllWith, - $replaceAllSummary, - $replaceAllTable; - + /** @type {?ModalBar} Currently open Find or Find/Replace bar, if any */ var modalBar; @@ -462,17 +457,6 @@ define(function (require, exports, module) { openSearchBar(editor, {}); } - /** - * @private - * Closes a panel with search-replace results. - * Main purpose is to make sure that events are correctly detached from current document. - */ - function _closeReplaceAllPanel() { - if (replaceAllPanel !== null && replaceAllPanel.isVisible()) { - replaceAllPanel.hide(); - } - $(currentDocument).off("change.replaceAll"); - } /** * @private @@ -483,107 +467,10 @@ define(function (require, exports, module) { if (modalBar) { modalBar.close(); } - _closeReplaceAllPanel(); - } - - /** - * @private - * Shows a panel with search results and offers to replace them, - * user can use checkboxes to select which results he wishes to replace. - * @param {Editor} editor - Currently active editor that was used to invoke this action. - * @param {string|RegExp} replaceWhat - Query that will be passed into CodeMirror Cursor to search for results. - * @param {string} replaceWith - String that should be used to replace chosen results. - */ - function _showReplaceAllPanel(editor, replaceWhat, replaceWith) { - var results = [], - cm = editor._codeMirror, - cursor = getSearchCursor(cm, replaceWhat), - from, - to, - line, - multiLine, - matchResult = cursor.findNext(); - - // Collect all results from document - while (matchResult) { - from = cursor.from(); - to = cursor.to(); - line = editor.document.getLine(from.line); - multiLine = from.line !== to.line; - - results.push({ - index: results.length, // add indexes to array - from: from, - to: to, - line: from.line + 1, - pre: line.slice(0, from.ch), - highlight: line.slice(from.ch, multiLine ? undefined : to.ch), - post: multiLine ? "\u2026" : line.slice(to.ch), - result: matchResult - }); - - if (results.length >= REPLACE_ALL_MAX) { - break; - } - - matchResult = cursor.findNext(); - } - - // This text contains some formatting, so all the strings are assumed to be already escaped - var resultsLength = results.length, - summary = StringUtils.format( - Strings.FIND_REPLACE_TITLE_PART3, - resultsLength, - resultsLength > 1 ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, - resultsLength >= REPLACE_ALL_MAX ? Strings.FIND_IN_FILES_MORE_THAN : "" - ); - - // Insert the search summary - $replaceAllWhat.text(replaceWhat.toString()); - $replaceAllWith.text(replaceWith.toString()); - $replaceAllSummary.html(summary); - - // All checkboxes are checked by default - $replaceAllContainer.find(".check-all").prop("checked", true); - - // Attach event to replace button - $replaceAllContainer.find("button.replace-checked").off().on("click", function (e) { - $replaceAllTable.find(".check-one:checked") - .closest(".replace-row") - .toArray() - .reverse() - .forEach(function (checkedRow) { - var match = results[$(checkedRow).data("match")], - rw = typeof replaceWhat === "string" ? replaceWith : parseDollars(replaceWith, match.result); - editor.document.replaceRange(rw, match.from, match.to, "+replaceAll"); - }); - _closeReplaceAllPanel(); - }); - - // Insert the search results - $replaceAllTable - .empty() - .append(Mustache.render(searchReplaceResultsTemplate, {searchResults: results})) - .off() - .on("click", ".check-one", function (e) { - e.stopPropagation(); - }) - .on("click", ".replace-row", function (e) { - var match = results[$(e.currentTarget).data("match")]; - editor.setSelection(match.from, match.to, true); - }); - - // we can't safely replace after document has been modified - // this handler is only attached, when replaceAllPanel is visible - currentDocument = DocumentManager.getCurrentDocument(); - $(currentDocument).on("change.replaceAll", function () { - _closeReplaceAllPanel(); - }); - - replaceAllPanel.show(); - $replaceAllTable.scrollTop(0); // Otherwise scroll pos from previous contents is remembered + replaceAllResults.hideResults(); } - + + /** Shows the Find-Replace search bar at top */ function replace(editor) { // If Replace bar already open, treat the shortcut as a hotkey for the Replace button @@ -620,7 +507,7 @@ define(function (require, exports, module) { } else if (e.target.id === "replace-all") { modalBar.close(); - _showReplaceAllPanel(editor, state.query, getReplaceWith()); + replaceAllResults.showReplaceAll(editor, state.query, getReplaceWith()); } }); @@ -667,30 +554,142 @@ define(function (require, exports, module) { replace(editor); } } + + + + + /** + * @private + * @constructor + * @extends {SearchResults} + * Handles the Replace All Results and the Results Panel + */ + function ReplaceAllResults() { + this.summaryTemplate = replaceAllSummaryTemplate; + this.hasCheckboxes = true; + + this.createPanel("replaceAllResults", "replace-all.results"); + } + + ReplaceAllResults.prototype = Object.create(SearchResults.prototype); + ReplaceAllResults.prototype.constructor = ReplaceAllResults; + ReplaceAllResults.prototype.parentClass = SearchResults.prototype; + + /** + * Adds the listeners for close, prev, next, first, last, check all and replace checked + */ + ReplaceAllResults.prototype._addPanelListeners = function () { + var self = this; + this.parentClass._addPanelListeners.apply(this); + + // Attach event to replace button + this.$container + .off(".replaceAll") + .on("click.replaceAll", ".replace-checked", function (e) { + self.matches.reverse().forEach(function (match) { + if (match.isChecked) { + var rw = typeof self.replaceWhat === "string" ? self.replaceWith : parseDollars(self.replaceWith, match.result); + self.editor.document.replaceRange(rw, match.start, match.end, "+replaceAll"); + } + }); + self.hideResults(); + }); + }; + + + /** + * Searches through the file to find all the matches and the shows the results in a panel to select which to replace + * @param {Editor} editor Currently active editor that was used to invoke this action. + * @param {(string|RegExp)} replaceWhat Query that will be passed into CodeMirror Cursor to search for results. + * @param {string} replaceWith String that should be used to replace chosen results. + */ + ReplaceAllResults.prototype.showReplaceAll = function (editor, replaceWhat, replaceWith) { + var cm = editor._codeMirror, + cursor = getSearchCursor(cm, replaceWhat), + matchResult = cursor.findNext(), + matches = [], + self = this, + from; + + this.initializeResults(); + + // Collect all results from document + while (matchResult) { + from = cursor.from(); + matches.push({ + start: from, + end: cursor.to(), + line: editor.document.getLine(from.line), + result: matchResult, + isChecked: true + }); + + if (matches.length >= REPLACE_ALL_MAX) { + break; + } + matchResult = cursor.findNext(); + } + + this.editor = editor; + this.matches = matches; + this.replaceWhat = replaceWhat; + this.replaceWith = replaceWith; + + this.addResultMatches(ProjectManager.getSelectedItem().fullPath, matches); + this.showResults(); + + // we can't safely replace after document has been modified + // this handler is only attached, when replaceAllPanel is visible + currentDocument = DocumentManager.getCurrentDocument(); + $(currentDocument).on("change.replaceAll", function () { + self.hideResults(); + }); + }; + + /** + * Shows a panel with search results and offers to replace them, + * user can use checkboxes to select which results he wishes to replace. + */ + ReplaceAllResults.prototype.showResults = function () { + var count = this._countFilesMatches(), + self = this, + + // This text contains some formatting, so all the strings are assumed to be already escaped + summary = StringUtils.format( + Strings.FIND_REPLACE_TITLE_PART3, + String(count.matches), + count.matches > 1 ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, + count.matches >= REPLACE_ALL_MAX ? Strings.FIND_IN_FILES_MORE_THAN : "" + ); + + // Insert the search summary + this._showSummary({ + replaceWhat: this.replaceWhat.toString(), + replaceWith: this.replaceWith.toString(), + summary: summary + }); + // Insert the search results + this._showResultsList(); + }; + + /** + * Hides the Search Results Panel + */ + ReplaceAllResults.prototype.hideResults = function () { + this.parentClass.hideResults.apply(this); + $(currentDocument).off("change.replaceAll"); + }; + + + PreferencesManager.stateManager.definePreference("caseSensitive", "boolean", false); PreferencesManager.stateManager.definePreference("regexp", "boolean", false); PreferencesManager.convertPreferences(module, {"caseSensitive": "user", "regexp": "user"}, true); // Initialize items dependent on HTML DOM AppInit.htmlReady(function () { - var panelHtml = Mustache.render(searchReplacePanelTemplate, Strings); - replaceAllPanel = PanelManager.createBottomPanel("findReplace-all.panel", $(panelHtml), 100); - $replaceAllContainer = replaceAllPanel.$panel; - $replaceAllWhat = $replaceAllContainer.find(".replace-what"); - $replaceAllWith = $replaceAllContainer.find(".replace-with"); - $replaceAllSummary = $replaceAllContainer.find(".replace-summary"); - $replaceAllTable = $replaceAllContainer.children(".table-container"); - - // Attach events to the panel - replaceAllPanel.$panel - .on("click", ".close", function () { - _closeReplaceAllPanel(); - }) - .on("click", ".check-all", function (e) { - var isChecked = $(this).is(":checked"); - replaceAllPanel.$panel.find(".check-one").prop("checked", isChecked); - }); + replaceAllResults = new ReplaceAllResults(); }); $(DocumentManager).on("currentDocumentChange", _handleDocumentChange); diff --git a/src/search/SearchResults.js b/src/search/SearchResults.js new file mode 100644 index 00000000000..5d5df8f1e2c --- /dev/null +++ b/src/search/SearchResults.js @@ -0,0 +1,500 @@ +/* + * Copyright (c) 2014 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. + * + */ + +/*global define, $, window, Mustache */ + +define(function (require, exports, module) { + "use strict"; + + var CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + EditorManager = require("editor/EditorManager"), + ProjectManager = require("project/ProjectManager"), + FileViewController = require("project/FileViewController"), + FileUtils = require("file/FileUtils"), + PanelManager = require("view/PanelManager"), + StringUtils = require("utils/StringUtils"), + Strings = require("strings"), + _ = require("thirdparty/lodash"), + + searchPanelTemplate = require("text!htmlContent/search-panel.html"), + searchResultsTemplate = require("text!htmlContent/search-results.html"), + searchPagingTemplate = require("text!htmlContent/search-summary-paging.html"); + + + /** @const Constants used to define the maximum results show per page and found in a single file */ + var RESULTS_PER_PAGE = 100; + + + + /** + * @constructor + * Handles the Search Results and the Panel + */ + function SearchResults() { + return undefined; + } + + /** + * Map of all the last search results + * @type {Object., collapsed: boolean}>} + */ + SearchResults.prototype.searchResults = {}; + + /** @type {number} The index of the first result that is displayed */ + SearchResults.prototype.currentStart = 0; + + /** @type {boolean} Determines if it should use checkboxes in the results */ + SearchResults.prototype.hasCheckboxes = false; + + /** @type {$.Element} The currently selected row */ + SearchResults.prototype.$selectedRow = null; + + + /** + * Creates the Bottom Panel using the given name + * @param {string} panelID + * @param {string} panelName + */ + SearchResults.prototype.createPanel = function (panelID, panelName) { + var panelHtml = Mustache.render(searchPanelTemplate, {panelID: panelID}); + + this.panel = PanelManager.createBottomPanel(panelName, $(panelHtml), 100); + this.$container = this.panel.$panel; + this.$summary = this.$container.find(".title"); + this.$table = this.$container.find(".table-container"); + + this._addPanelListeners(); + }; + + /** + * @private + * Adds the listeners for close, prev, next, first, last and check all + */ + SearchResults.prototype._addPanelListeners = function () { + var self = this; + this.$container + .off(".searchResults") // Remove the old events + .on("click.searchResults", ".close", function () { + self.hideResults(); + }) + // The link to go the first page + .on("click.searchResults", ".first-page:not(.disabled)", function () { + self.currentStart = 0; + self.showResults(); + }) + // The link to go the previous page + .on("click.searchResults", ".prev-page:not(.disabled)", function () { + self.currentStart -= RESULTS_PER_PAGE; + self.showResults(); + }) + // The link to go to the next page + .on("click.searchResults", ".next-page:not(.disabled)", function () { + self.currentStart += RESULTS_PER_PAGE; + self.showResults(); + }) + // The link to go to the last page + .on("click.searchResults", ".last-page:not(.disabled)", function () { + self.currentStart = self._getLastCurrentStart(); + self.showResults(); + }); + + // Add the Click handlers for Checkboxes if required + if (this.hasCheckboxes) { + this.$container.on("click.searchResults", ".check-all", function (e) { + var isChecked = $(this).is(":checked"); + _.forEach(self.searchResults, function (results) { + results.matches.forEach(function (match) { + match.isChecked = isChecked; + }); + }); + self.$table.find(".check-one").prop("checked", isChecked); + self.allChecked = isChecked; + }); + } + }; + + + /** + * Initializes the Search Results + */ + SearchResults.prototype.initializeResults = function () { + this.searchResults = {}; + this.currentStart = 0; + this.$selectedRow = null; + this.allChecked = true; + }; + + /** + * Adds the given Result Matches to the search results + * @param {string} fullpath + * @param {Array.} matches + */ + SearchResults.prototype.addResultMatches = function (fullpath, matches) { + this.searchResults[fullpath] = { + matches: matches, + collapsed: false + }; + }; + + + /** + * Shows the Results Panel + */ + SearchResults.prototype.showResults = function () { + return undefined; + }; + + /** + * Hides the Search Results Panel + */ + SearchResults.prototype.hideResults = function () { + var self = this; + if (this.panel.isVisible()) { + this.panel.hide(); + } + }; + + + /** + * @private + * Shows the Results Summary + * @param {Object} sumaryData + */ + SearchResults.prototype._showSummary = function (sumaryData) { + var count = this._countFilesMatches(), + lastIndex = this._getLastIndex(count.matches); + + this.$summary.html(Mustache.render(this.summaryTemplate, $.extend({ + 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, + Strings: Strings + }, sumaryData), { paging: searchPagingTemplate })); + }; + + /** + * @private + * Shows the Results List + */ + SearchResults.prototype._showResultsList = function () { + var searchItems, match, i, item, multiLine, + count = this._countFilesMatches(), + searchFiles = this._getSortedFiles(), + lastIndex = this._getLastIndex(count.matches), + searchList = [], + matchesCounter = 0, + showMatches = false, + self = this; + + + // 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.searchResults[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 = []; + + // 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({ + file: searchList.length, + item: searchItems.length, + index: i, + line: match.start.line + 1, + pre: match.line.substr(0, match.start.ch), + highlight: match.line.substring(match.start.ch, multiLine ? undefined : match.end.ch), + post: multiLine ? "\u2026" : match.line.substr(match.end.ch), + start: match.start, + end: match.end, + isChecked: match.isChecked + }); + 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 ? "—" : "" + ); + + searchList.push({ + file: searchList.length, + filename: displayFileName, + fullPath: fullPath, + items: searchItems + }); + } + }); + + + // Insert the search results + this.$table + .empty() + .append(Mustache.render(searchResultsTemplate, { + hasCheckboxes: this.hasCheckboxes, + searchList: searchList, + Strings: Strings + })) + .off(".searchResults") // Remove the old events + + // Add the click event listener directly on the table parent + .on("click.searchResults", 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 = searchList[$row.data("file")], + fullPath = searchItem.fullPath; + + // This is a file title row, expand/collapse on click + if ($row.hasClass("file-section")) { + var $titleRows, + collapsed = !self.searchResults[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 = searchList[$(this).data("file")].fullPath; + searchItem = self.searchResults[fullPath]; + + if (searchItem.collapsed !== collapsed) { + searchItem.collapsed = collapsed; + $(this).nextUntil(".file-section").toggle(); + $(this).find(".disclosure-triangle").toggleClass("expanded").toggleClass("collapsed"); + } + }); + + //In Expand/Collapse all, reset all search results 'collapsed' flag to same value(true/false). + if (e.metaKey || e.ctrlKey) { + _.forEach(self.searchResults, 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")]; + + 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); + }); + } + } + }) + // Add the file to the working set on double click + .on("dblclick.searchResults", "tr:not(.file-section)", function (e) { + var item = searchList[$(this).data("file")]; + FileViewController.addToWorkingSetAndSelect(item.fullPath); + }) + // Restore the collapsed files + .find(".file-section").each(function () { + var fullPath = searchList[$(this).data("file")].fullPath; + + if (self.searchResults[fullPath].collapsed) { + self.searchResults[fullPath].collapsed = false; + $(this).trigger("click"); + } + }); + + // Add the Click handlers for Checkboxes if required + if (this.hasCheckboxes) { + this.$table.on("click.searchResults", ".check-one", function (e) { + var $row = $(e.target).closest("tr"), + item = searchList[$row.data("file")]; + + self.searchResults[item.fullPath].matches[$row.data("index")].isChecked = $(this).is(":checked"); + e.stopPropagation(); + }); + } + + 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 + }; + + /** + * Restores the state of the Results Panel + */ + SearchResults.prototype.restoreResults = function () { + if (this.panel.isVisible()) { + var scrollTop = this.$table.scrollTop(), + index = this.$selectedRow ? this.$selectedRow.index() : null, + numMatches = this._countFilesMatches().matches; + + if (this.currentStart > numMatches) { + this.currentStart = this._getLastCurrentStart(numMatches); + } + + this.showResults(); + + this.$table.scrollTop(scrollTop); + if (index) { + this.$selectedRow = this.$table.find("tr:eq(" + index + ")"); + this.$selectedRow.addClass("selected"); + } + } + }; + + + /** + * @private + * Sorts the file keys to show the results from the selected file first and the rest sorted by path + * @return {Array.} + */ + SearchResults.prototype._getSortedFiles = function () { + var selectedEntry = ProjectManager.getSelectedItem().fullPath, + searchFiles = Object.keys(this.searchResults); + + searchFiles.sort(function (key1, key2) { + if (selectedEntry === key1) { + return -1; + } else if (selectedEntry === key2) { + return 1; + } + + var entryName1, entryName2, + pathParts1 = key1.split("/"), + pathParts2 = key2.split("/"), + length = Math.min(pathParts1.length, pathParts2.length), + folders1 = pathParts1.length - 1, + folders2 = pathParts2.length - 1, + index = 0; + + while (index < length) { + entryName1 = pathParts1[index]; + entryName2 = pathParts2[index]; + + if (entryName1 !== entryName2) { + if (index < folders1 && index < folders2) { + return entryName1.toLocaleLowerCase().localeCompare(entryName2.toLocaleLowerCase()); + } else if (index >= folders1 && index >= folders2) { + return FileUtils.compareFilenames(entryName1, entryName2); + } + return (index >= folders1 && index < folders2) ? 1 : -1; + } + index++; + } + return 0; + }); + + return searchFiles; + }; + + + + /** + * @private + * Counts the total number of matches and files + * @return {{files: number, matches: number}} + */ + SearchResults.prototype._countFilesMatches = function () { + var numFiles = 0, numMatches = 0; + _.forEach(this.searchResults, function (item) { + numFiles++; + numMatches += item.matches.length; + }); + + return {files: numFiles, matches: numMatches}; + }; + + /** + * @private + * Returns the last result index displayed + * @param {number} numMatches + * @return {number} + */ + SearchResults.prototype._getLastIndex = function (numMatches) { + return Math.min(this.currentStart + RESULTS_PER_PAGE, numMatches); + }; + + /** + * @private + * Returns the last possible current start based on the given number of matches + * @param {number=} numMatches + * @return {number} + */ + SearchResults.prototype._getLastCurrentStart = function (numMatches) { + numMatches = numMatches || this._countFilesMatches().matches; + return Math.floor((numMatches - 1) / RESULTS_PER_PAGE) * RESULTS_PER_PAGE; + }; + + + + + // Public API + exports.SearchResults = SearchResults; +}); diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 2f6b1caf1c3..c4972da5312 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -945,7 +945,7 @@ a, img { /* Find in Files results panel - temporary UI, to be replaced with a richer search feature later */ -#search-results .title, #replace-all-results .title { +.search-results .title { .sane-box-model; padding-right: 20px; width: 100%; @@ -962,7 +962,7 @@ a, img { .flex-item(0, 0); } .pagination-col { - .flex-item(1, 0); + .flex-item(0, 0); min-width: 100px; } .replace-col { @@ -995,7 +995,8 @@ a, img { } } -#search-results .disclosure-triangle, #problems-panel .disclosure-triangle { +.search-results .disclosure-triangle, +#problems-panel .disclosure-triangle { .jstree-sprite; display: inline-block; &.expanded {