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 456b1108e9a..1f80aa2a615 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -37,8 +37,6 @@ * - Handle matches that span multiple lines * - Refactor UI from functionality to enable unit testing */ - - define(function (require, exports, module) { "use strict"; @@ -56,49 +54,29 @@ 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; 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; /** @const @type {!Object} Token used to indicate a specific reason for zero search results */ var ZERO_FILES_TO_SEARCH = {}; - /** - * Map of all the last search results - * @type {Object., collapsed: boolean}>} - */ - var searchResults = {}; - - /** @type {Array.} Keeps a copy of the searched files sorted by name and with the selected file first */ - var searchFiles = []; - - /** @type {Panel} Bottom panel holding the search results. Initialized in htmlReady() */ - var searchResultsPanel; - - /** @type {Entry} the File selected on the initial search */ - var selectedEntry; - - /** @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 = ""; @@ -115,30 +93,10 @@ define(function (require, exports, module) { /** @type {boolean} True if the matches in a file reached FIND_IN_FILE_MAX */ var maxHitsFoundInFile = false; - /** @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; - - /** - * Updates search results in response to FileSystem "change" event. (Declared here to appease JSLint) - * @type {Function} - **/ - var _fileSystemChangeHandler; - - /** - * Updates the search results in response to (unsaved) text edits. (Declared here to appease JSLint) - * @type {Function} - **/ - var _documentChangeHandler; + /** * @private * Returns a regular expression from the given query and shows an error in the modal-bar if it was invalid @@ -191,524 +149,6 @@ define(function (require, exports, module) { } } - /** Remove listeners that were tracking potential search result changes */ - function _removeListeners() { - $(DocumentModule).off(".findInFiles"); - FileSystem.off("change", _fileSystemChangeHandler); - } - - /** Add listeners to track events that might change the search result set */ - function _addListeners() { - // Avoid adding duplicate listeners - e.g. if a 2nd search is run without closing the old results panel first - _removeListeners(); - - $(DocumentModule).on("documentChange.findInFiles", _documentChangeHandler); - FileSystem.on("change", _fileSystemChangeHandler); - } - - /** - * @private - * Hides the Search Results Panel - */ - function _hideSearchResults() { - if (searchResultsPanel.isVisible()) { - searchResultsPanel.hide(); - } - _removeListeners(); - } - - /** - * @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 the 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 - * Sorts the file keys to show the results from the selected file first and the rest sorted by path - */ - function _sortResultFiles() { - searchFiles = Object.keys(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; - }); - } - - /** - * @private - * Counts 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 - * @param {?Object} zeroFilesToken The 'ZERO_FILES_TO_SEARCH' token, if no results found for this reason - */ - function _showSearchResults(zeroFilesToken) { - 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, item, - searchList = [], - matchesCounter = 0, - showMatches = false; - - // 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 = 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 < 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(); - } - - } else { - _hideSearchResults(); - - if (dialog) { - dialog.getDialogTextField() - .addClass("no-results") - .removeAttr("disabled") - .get(0).select(); - if (zeroFilesToken === ZERO_FILES_TO_SEARCH) { - $(".modal-bar .error") - .show() - .html(StringUtils.format(Strings.FIND_IN_FILES_ZERO_FILES, _labelForScope(currentScope))); - } else { - $(".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 {Array.<{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}>} changeList - * An array of changes as described in the Document constructor - * @return {boolean} True when the search results changed from a file change - */ - function _updateSearchResults(doc, changeList) { - var i, diff, matches, - resultsChanged = false, - fullPath = doc.file.fullPath, - lines, start, howMany; - - changeList.forEach(function (change) { - 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; - } - } - }); - - return resultsChanged; - } - /** * Checks that the file matches the given subtree scope. To fully check whether the file * should be in the search set, use _inSearchScope() instead - a supserset of this. @@ -772,61 +212,39 @@ define(function (require, exports, module) { var inWorkingSet = DocumentManager.getWorkingSet().some(function (wsFile) { return wsFile.fullPath === file.fullPath; }); - if (!inWorkingSet) { - return false; - } - } - } - - if (!_isReadableText(file.fullPath)) { - return false; - } - - // Replicate the filtering filterFileList() does - return FileFilters.filterPath(currentFilter, file.fullPath); - } - - /** - * @private - * Updates the search results in response to (unsaved) text edits - * @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 - */ - _documentChangeHandler = function (event, document, change) { - // Re-check the filtering that the initial search applied - if (_inSearchScope(document.file)) { - var updateResults = _updateSearchResults(document, change, false); - - if (timeoutID) { - window.clearTimeout(timeoutID); - updateResults = true; - } - if (updateResults) { - timeoutID = window.setTimeout(function () { - _sortResultFiles(); - _restoreSearchResults(); - timeoutID = null; - }, UPDATE_TIMEOUT); + if (!inWorkingSet) { + return false; + } } } - }; + + if (!_isReadableText(file.fullPath)) { + return false; + } + + // Replicate the filtering filterFileList() does + return FileFilters.filterPath(currentFilter, file.fullPath); + } + /** + * @private * Finds search results in the given file and adds them to 'searchResults.' Resolves with * true if any matches found, false if none found. Errors reading the file are treated the * same as if no results found. * * Does not perform any filtering - assumes caller has already vetted this file as a search * candidate. + * + * @param {!File} file + * @return {$.Promise} */ function _doSearchInOneFile(file) { var result = new $.Deferred(); DocumentManager.getDocumentText(file) .done(function (text) { - var foundMatches = _addSearchMatches(file.fullPath, text, currentQueryExpr); + var foundMatches = findInFilesResults._addSearchMatches(file.fullPath, text, currentQueryExpr); result.resolve(foundMatches); }) .fail(function () { @@ -870,17 +288,14 @@ define(function (require, exports, module) { }) .done(function (zeroFilesToken) { // Done searching all files: show results - _sortResultFiles(); - _showSearchResults(zeroFilesToken); + findInFilesResults.showResults(zeroFilesToken); StatusBar.hideBusyIndicator(); PerfUtils.addMeasurement(perfTimer); // Listen for FS & Document changes to keep results up to date - if (!$.isEmptyObject(searchResults)) { - _addListeners(); - } + findInFilesResults.addListeners(); - exports._searchResults = searchResults; // for unit tests + exports._searchResults = findInFilesResults._searchResults; // for unit tests }) .fail(function (err) { console.log("find in files failed: ", err); @@ -1057,21 +472,14 @@ define(function (require, exports, module) { dialog._close(true); } - // Save the currently selected file's fullpath if there is one selected and if it is a file - var selectedItem = ProjectManager.getSelectedItem(); - if (selectedItem && !selectedItem.isDirectory) { - selectedEntry = selectedItem.fullPath; - } - dialog = new FindInFilesDialog(); - searchResults = {}; - currentStart = 0; currentQuery = ""; currentQueryExpr = null; currentScope = scope; maxHitsFoundInFile = false; + exports._searchResults = null; // for unit tests - + findInFilesResults.initializeResults(); dialog.showDialog(initialString, scope); } @@ -1085,6 +493,301 @@ 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._timeoutID = null; + + this.createPanel("find-in-files-results", "find-in-files.results"); + } + + FindInFilesResults.prototype = Object.create(SearchResults.prototype); + FindInFilesResults.prototype.constructor = FindInFilesResults; + FindInFilesResults.prototype.parentClass = SearchResults.prototype; + + /** @type {string} The setTimeout id, used to clear it if required */ + FindInFilesResults.prototype._timeoutID = null; + + + /** + * Hides the Search Results Panel + */ + FindInFilesResults.prototype.hideResults = function () { + if (this._panel.isVisible()) { + this._panel.hide(); + } + this.removeListeners(); + }; + + /** + * @private + * Shows the results in a table and adds the necessary event listeners + * @param {?Object} zeroFilesToken The 'ZERO_FILES_TO_SEARCH' token, if no results found for this reason + */ + FindInFilesResults.prototype.showResults = function (zeroFilesToken) { + 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(); + } + + } else { + this.hideResults(); + + if (dialog) { + dialog.getDialogTextField() + .addClass("no-results") + .removeAttr("disabled") + .get(0).select(); + + if (zeroFilesToken === ZERO_FILES_TO_SEARCH) { + $(".modal-bar .error") + .show() + .html(StringUtils.format(Strings.FIND_IN_FILES_ZERO_FILES, _labelForScope(currentScope))); + } else { + $(".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; + }; + + + /** + * Remove the listeners that were tracking potential search result changes + */ + FindInFilesResults.prototype.removeListeners = function () { + $(DocumentModule).off(".findInFiles"); + FileSystem.off("change", this._fileSystemChangeHandler.bind(this)); + }; + + /** + * Add listeners to track events that might change the search result set + */ + FindInFilesResults.prototype.addListeners = function () { + if (!$.isEmptyObject(this._searchResults)) { + // Avoid adding duplicate listeners - e.g. if a 2nd search is run without closing the old results panel first + this.removeListeners(); + + $(DocumentModule).on("documentChange.findInFiles", this._documentChangeHandler.bind(this)); + FileSystem.on("change", this._fileSystemChangeHandler.bind(this)); + } + }; + + /** + * @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, updateResults; + if (_inSearchScope(document.file)) { + updateResults = this._updateResults(document, change, false); + + if (this._timeoutID) { + window.clearTimeout(this._timeoutID); + updateResults = true; + } + if (updateResults) { + this._timeoutID = window.setTimeout(function () { + self.restoreResults(); + self._timeoutID = null; + }, UPDATE_TIMEOUT); + } + } + }; + + /** + * @private + * Update the search results using the given list of changes for the given document + * @param {Document} doc The Document that changed, should be the current one + * @param {Array.<{from: {line:number,ch:number}, to: {line:number,ch:number}, text: string, next: change}>} changeList + * An array of changes as described in the Document constructor + * @return {boolean} True when the search results changed from a file change + */ + FindInFilesResults.prototype._updateResults = function (doc, changeList) { + var i, diff, matches, lines, start, howMany, + resultsChanged = false, + fullPath = doc.file.fullPath, + self = this; + + changeList.forEach(function (change) { + 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) { + self._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 (self._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 + self._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) { + self._searchResults[fullPath].matches.splice(start, howMany); + } + resultsChanged = true; + } + + // Searches only over the lines that changed + matches = self._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 (self._searchResults[fullPath]) { + Array.prototype.splice.apply(self._searchResults[fullPath].matches, [start, 0].concat(matches)); + // If not, add the matches to a new file index + } else { + self._searchResults[fullPath] = { + matches: matches, + collapsed: false + }; + } + resultsChanged = true; + } + + // All the matches where deleted, remove the file from the results + if (self._searchResults[fullPath] && !self._searchResults[fullPath].matches.length) { + delete self._searchResults[fullPath]; + resultsChanged = true; + } + } + }); + + return resultsChanged; + }; + + /** * @private * Moves the search results from the previous path to the new one and updates the results list, if required @@ -1092,26 +795,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) { - _sortResultFiles(); - _restoreSearchResults(); + this.restoreResults(); } } - } + }; /** * @private @@ -1121,17 +824,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)} 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; } }); @@ -1208,8 +912,7 @@ define(function (require, exports, module) { addPromise.always(function () { // Restore the results if needed if (resultsChanged) { - _sortResultFiles(); - _restoreSearchResults(); + self.restoreResults(); } }); }; @@ -1217,17 +920,12 @@ define(function (require, exports, module) { // 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 710f69288e9..3272c4b3915 100644 --- a/src/search/FindReplace.js +++ b/src/search/FindReplace.js @@ -39,12 +39,14 @@ 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"), @@ -54,9 +56,8 @@ define(function (require, exports, module) { _ = require("thirdparty/lodash"), CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"); - 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; @@ -65,21 +66,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; @@ -679,17 +673,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 @@ -700,107 +683,10 @@ define(function (require, exports, module) { if (modalBar) { modalBar.close(); } - _closeReplaceAllPanel(); + replaceAllResults.hideResults(); } - - /** - * @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 - } - + + /** 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 @@ -837,7 +723,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()); } }); @@ -884,30 +770,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("replace-all-results", "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._panel.$panel + .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..f92099e5fc6 --- /dev/null +++ b/src/search/SearchResults.js @@ -0,0 +1,529 @@ +/* + * 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 = {}; + + /** + * Array with content used in the Results Panel + * @type {Array.<{file: number, filename: string, fullPath: string, items: Array.}>} + */ + SearchResults.prototype._searchList = []; + + /** @type {Panel} Bottom panel holding the search results */ + SearchResults.prototype._panel = null; + + /** @type {string} The tempalte used for the summary */ + SearchResults.prototype._summaryTemplate = ""; + + /** @type {?Entry} The File selected on the initial search */ + SearchResults.prototype._selectedEntry = null; + + /** @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 {boolean} Used to remake the replace all summary after it is changed */ + SearchResults.prototype._allChecked = false; + + /** @type {$.Element} The currently selected row */ + SearchResults.prototype._$selectedRow = null; + + /** @type {$.Element} The element where the title is placed */ + SearchResults.prototype._$summary = null; + + /** @type {$.Element} The table that holds the results */ + SearchResults.prototype._$table = 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._$summary = this._panel.$panel.find(".title"); + this._$table = this._panel.$panel.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._panel.$panel + .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 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")]; + FileViewController.addToWorkingSetAndSelect(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")], + 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 = self._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 Click handlers for Checkboxes if required + if (this._hasCheckboxes) { + this._panel.$panel + .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; + }) + .on("click.searchResults", ".check-one", function (e) { + var $row = $(e.target).closest("tr"), + item = self._searchList[$row.data("file")]; + + self._searchResults[item.fullPath].matches[$row.data("index")].isChecked = $(this).is(":checked"); + e.stopPropagation(); + }); + } + }; + + + /** + * Initializes the Search Results + */ + SearchResults.prototype.initializeResults = function () { + this._searchResults = {}; + this._currentStart = 0; + this._$selectedRow = null; + this._allChecked = true; + + // Save the currently selected file's fullpath if there is one selected and if it is a file + var selectedItem = ProjectManager.getSelectedItem(); + if (selectedItem && !selectedItem.isDirectory) { + this._selectedEntry = selectedItem.fullPath; + } else { + this._selectedEntry = null; + } + }; + + /** + * 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), + matchesCounter = 0, + showMatches = false, + self = this; + + 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._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: self._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 ? "—" : "" + ); + + self._searchList.push({ + file: self._searchList.length, + filename: displayFileName, + fullPath: fullPath, + items: searchItems + }); + } + }); + + + // Insert the search results + this._$table + .empty() + .append(Mustache.render(searchResultsTemplate, { + hasCheckboxes: this._hasCheckboxes, + searchList: this._searchList, + Strings: Strings + })) + // Restore the collapsed files + .find(".file-section").each(function () { + var fullPath = self._searchList[$(this).data("file")].fullPath; + + if (self._searchResults[fullPath].collapsed) { + self._searchResults[fullPath].collapsed = false; + $(this).trigger("click"); + } + }); + + 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 searchFiles = Object.keys(this._searchResults), + self = this; + + searchFiles.sort(function (key1, key2) { + if (self._selectedEntry === key1) { + return -1; + } else if (self._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 8c23792f120..f388e88ac33 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -966,7 +966,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%; @@ -983,7 +983,7 @@ a, img { .flex-item(0, 0); } .pagination-col { - .flex-item(1, 0); + .flex-item(0, 0); min-width: 100px; word-spacing: 0; } @@ -1032,7 +1032,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 { diff --git a/test/spec/FindReplace-test.js b/test/spec/FindReplace-test.js index d0ad8d501e9..d05e3d07832 100644 --- a/test/spec/FindReplace-test.js +++ b/test/spec/FindReplace-test.js @@ -1762,7 +1762,7 @@ define(function (require, exports, module) { runs(function () { var fileResults = FindInFiles._searchResults[filePath]; expect(fileResults).toBeTruthy(); - expect($("#search-results").is(":visible")).toBeTruthy(); + expect($("#find-in-files-results").is(":visible")).toBeTruthy(); expect($(".modal-bar").length).toBe(0); }); }); @@ -1791,7 +1791,7 @@ define(function (require, exports, module) { } expect(resultFound).toBe(false); - expect($("#search-results").is(":visible")).toBeFalsy(); + expect($("#find-in-files-results").is(":visible")).toBeFalsy(); expect($(".modal-bar").length).toBe(1); // Close search bar @@ -1815,7 +1815,7 @@ define(function (require, exports, module) { expect(editor).toBeFalsy(); // Get panel - var $searchResults = $("#search-results"); + var $searchResults = $("#find-in-files-results"); expect($searchResults.is(":visible")).toBeTruthy(); // Get list in panel @@ -1854,7 +1854,7 @@ define(function (require, exports, module) { expect(DocumentManager.findInWorkingSet(filePath)).toBe(-1); // Get list in panel - var $panelResults = $("#search-results table.bottom-panel-table tr"); + var $panelResults = $("#find-in-files-results table.bottom-panel-table tr"); expect($panelResults.length).toBe(5); // 4 hits + 1 file section // Double-click second item which is first hit @@ -1884,7 +1884,7 @@ define(function (require, exports, module) { expect(DocumentManager.findInWorkingSet(filePath)).toBe(-1); // Get list in panel - $panelResults = $("#search-results table.bottom-panel-table tr"); + $panelResults = $("#find-in-files-results table.bottom-panel-table tr"); expect($panelResults.length).toBe(panelListLen); // Double-click second item which is first hit @@ -1904,7 +1904,7 @@ define(function (require, exports, module) { // Panel is updated asynchronously waitsFor(function () { - $panelResults = $("#search-results table.bottom-panel-table tr"); + $panelResults = $("#find-in-files-results table.bottom-panel-table tr"); return ($panelResults.length < panelListLen); }, "Results panel updated");