diff --git a/src/htmlContent/search-panel.html b/src/htmlContent/search-panel.html index e46d823f469..0929275fd4b 100644 --- a/src/htmlContent/search-panel.html +++ b/src/htmlContent/search-panel.html @@ -1,6 +1,5 @@
-
{{SEARCH_RESULTS}}
×
diff --git a/src/htmlContent/search-summary.html b/src/htmlContent/search-summary.html new file mode 100644 index 00000000000..4a9577b5229 --- /dev/null +++ b/src/htmlContent/search-summary.html @@ -0,0 +1,8 @@ +{{{summary}}} +{{#hasPages}} + + + {{{results}}} + + +{{/hasPages}} \ No newline at end of file diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 42f71670b1f..0783c15fb59 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -125,15 +125,17 @@ define({ "NO_UPDATE_TITLE" : "You're up to date!", "NO_UPDATE_MESSAGE" : "You are running the latest version of {APP_NAME}.", - "FIND_IN_FILES_TITLE" : "for \"{4}\" {5} - {0} {1} in {2} {3}", + "FIND_IN_FILES_TITLE" : "\"{4}\" found {5} — {0} {1} in {2} {3}", "FIND_IN_FILES_SCOPED" : "in {0}", "FIND_IN_FILES_NO_SCOPE" : "in project", "FIND_IN_FILES_FILE" : "file", "FIND_IN_FILES_FILES" : "files", "FIND_IN_FILES_MATCH" : "match", "FIND_IN_FILES_MATCHES" : "matches", - "FIND_IN_FILES_MORE_THAN" : "More than ", - "FIND_IN_FILES_MAX" : " (showing the first {0} matches)", + "FIND_IN_FILES_MORE_THAN" : "Over ", + "FIND_IN_FILES_PAGING" : "{0}—{1}", + "FIND_IN_FILES_LESS" : " Less", + "FIND_IN_FILES_MORE" : " More", "FIND_IN_FILES_FILE_PATH" : "File: {0}", "FIND_IN_FILES_LINE" : "line: {0}", @@ -275,7 +277,6 @@ define({ // Strings for main-view.html "EXPERIMENTAL_BUILD" : "experimental build", "DEVELOPMENT_BUILD" : "development build", - "SEARCH_RESULTS" : "Search Results", "OK" : "OK", "DONT_SAVE" : "Don't Save", "SAVE" : "Save", diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js index 4aa671c95b6..e8155d5fa73 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -25,7 +25,7 @@ /*global define, $, window, Mustache */ /* - * Adds a "find in files" command to allow the user to find all occurances of a string in all files in + * Adds a "find in files" command to allow the user to find all occurrences of a string in all files in * the project. * * The keyboard shortcut is Cmd(Ctrl)-Shift-F. @@ -34,7 +34,7 @@ * - Proper UI for both dialog and results * - Refactor dialog class and share with Quick File Open * - Search files in working set that are *not* in the project - * - Handle matches that span mulitple lines + * - Handle matches that span multiple lines * - Refactor UI from functionality to enable unit testing */ @@ -53,34 +53,58 @@ define(function (require, exports, module) { EditorManager = require("editor/EditorManager"), PanelManager = require("view/PanelManager"), FileIndexManager = require("project/FileIndexManager"), + FileViewController = require("project/FileViewController"), NativeFileSystem = require("file/NativeFileSystem").NativeFileSystem, FileUtils = require("file/FileUtils"), KeyEvent = require("utils/KeyEvent"), AppInit = require("utils/AppInit"), + CollectionUtils = require("utils/CollectionUtils"), StatusBar = require("widgets/StatusBar"), ModalBar = require("widgets/ModalBar").ModalBar; var searchDialogTemplate = require("text!htmlContent/search-dialog.html"), searchPanelTemplate = require("text!htmlContent/search-panel.html"), + searchSummaryTemplate = require("text!htmlContent/search-summary.html"), searchResultsTemplate = require("text!htmlContent/search-results.html"); + /** @cost 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; + + /** + * 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 {string} The current search query */ + var currentQuery = ""; + + /** @type {Array.} An array of the files where it should look or null/empty to search the entire project */ + var currentScope = null; + + /** @type {boolean} True if the matches in a file reached FIND_IN_FILE_MAX */ + var maxHitsFoundInFile = false; + /** @type {$.Element} jQuery elements used in the search results */ var $searchResults, $searchSummary, $searchContent, $selectedRow; - var searchResults = []; - - var FIND_IN_FILES_MAX = 100, - maxHitsFoundInFile = false, - currentQuery = "", - currentScope; - - // Bottom panel holding the search results. Initialized in htmlReady(). - var searchResultsPanel; - + /** + * @private + * Returns a regular expression from the given query and shows an error in the modal-bar if it was invalid + * @param {!string} query - The query from the modal-bar input + * @return {RegExp} + */ function _getQueryRegExp(query) { // Clear any pending RegEx error message $(".modal-bar .message").css("display", "inline-block"); @@ -113,6 +137,7 @@ define(function (require, exports, module) { } /** + * @private * Returns label text to indicate the search scope. Already HTML-escaped. * @param {?Entry} scope */ @@ -160,37 +185,38 @@ define(function (require, exports, module) { /** * Shows the search dialog * @param {?string} initialString Default text to prepopulate the search field with - * @param {?Entry} scope Search scope, or null to search whole proj + * @param {?Entry} scope Search scope, or null to search whole project * @returns {$.Promise} that is resolved with the string to search for */ FindInFilesDialog.prototype.showDialog = function (initialString, scope) { // Note the prefix label is a simple "Find:" - the "in ..." part comes after the text field var templateVars = { - value: initialString || "", - label: _labelForScope(scope) - }; - var dialogHTML = Mustache.render(searchDialogTemplate, $.extend(templateVars, Strings)); + value: initialString || "", + label: _labelForScope(scope) + }, + dialogHTML = Mustache.render(searchDialogTemplate, $.extend(templateVars, Strings)), + that = this; - this.result = new $.Deferred(); - this.modalBar = new ModalBar(dialogHTML, false); + this.result = new $.Deferred(); + this.modalBar = new ModalBar(dialogHTML, false); var $searchField = $("input#searchInput"); - var that = this; $searchField.get(0).select(); - $searchField.bind("keydown", function (event) { - if (event.keyCode === KeyEvent.DOM_VK_RETURN || event.keyCode === KeyEvent.DOM_VK_ESCAPE) { // Enter/Return key or Esc key - event.stopPropagation(); - event.preventDefault(); - - var query = $searchField.val(); - - if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { - query = null; + $searchField + .bind("keydown", function (event) { + if (event.keyCode === KeyEvent.DOM_VK_RETURN || event.keyCode === KeyEvent.DOM_VK_ESCAPE) { // Enter/Return key or Esc key + event.stopPropagation(); + event.preventDefault(); + + var query = $searchField.val(); + + if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { + query = null; + } + + that._close(query); } - - that._close(query); - } - }) + }) .bind("input", function (event) { // Check the query expression on every input event. This way the user is alerted // to any RegEx syntax errors immediately. @@ -205,6 +231,10 @@ define(function (require, exports, module) { }; + /** + * @private + * Hides the Search Results Panel + */ function _hideSearchResults() { if (searchResultsPanel.isVisible()) { searchResultsPanel.hide(); @@ -214,7 +244,7 @@ define(function (require, exports, module) { /** * @private - * Searches throught the contents an returns an array of matches + * 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}>} @@ -225,18 +255,15 @@ define(function (require, exports, module) { return null; } - var trimmedContents = contents; - var startPos = 0; - var matchStart; - var matches = []; + var match, lineNum, line, ch, matchLength, + lines = StringUtils.getLines(contents), + matches = []; - var match; - var lines = StringUtils.getLines(contents); while ((match = queryExpr.exec(contents)) !== null) { - var lineNum = StringUtils.offsetToLineNum(lines, match.index); - var line = lines[lineNum]; - var ch = match.index - contents.lastIndexOf("\n", match.index) - 1; // 0-based index - var matchLength = match[0].length; + 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)); @@ -249,7 +276,7 @@ define(function (require, exports, module) { // 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_FILES_MAX) { + if (matches.length >= FIND_IN_FILE_MAX) { queryExpr.lastIndex = 0; maxHitsFoundInFile = true; break; @@ -258,13 +285,37 @@ define(function (require, exports, module) { 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 + */ + function _addSearchMatches(fullPath, contents, queryExpr) { + var matches = _getSearchMatches(contents, queryExpr); - function _showSearchResults(searchResults, query, scope) { - if (searchResults && searchResults.length) { + if (matches && matches.length) { + searchResults[fullPath] = { + matches: matches, + collapsed: false + }; + } + } + + + /** + * @private + * Shows the results in a table and adds the necessary event listeners + */ + function _showSearchResults() { + if (!$.isEmptyObject(searchResults)) { // Count the total number of matches - var numMatches = 0; - searchResults.forEach(function (item) { + var numFiles = 0, numMatches = 0; + CollectionUtils.forEach(searchResults, function (item) { + numFiles++; numMatches += item.matches.length; }); @@ -280,34 +331,66 @@ define(function (require, exports, module) { Strings.FIND_IN_FILES_TITLE, numMatchesStr, (numMatches > 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, - searchResults.length, - (searchResults.length > 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE), - StringUtils.htmlEscape(query), - scope ? _labelForScope(scope) : "" + numFiles, + (numFiles > 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE), + StringUtils.htmlEscape(currentQuery), + currentScope ? _labelForScope(currentScope) : "" ); + // The last result index displayed + var last = currentStart + RESULTS_PER_PAGE > numMatches ? numMatches : currentStart + RESULTS_PER_PAGE; + // Insert the search summary - $searchSummary - .html(summary + - (numMatches > FIND_IN_FILES_MAX ? StringUtils.format(Strings.FIND_IN_FILES_MAX, FIND_IN_FILES_MAX) : "")) - .prepend(" "); // putting a normal space before the "-" is not enough + $searchSummary.html(Mustache.render(searchSummaryTemplate, { + summary: summary, + hasPages: numMatches > RESULTS_PER_PAGE, + results: StringUtils.format(Strings.FIND_IN_FILES_PAGING, currentStart + 1, last), + hasPrev: currentStart > 0, + hasNext: last < numMatches + })); // Create the results template search list - var searchList = []; - var resultsDisplayed = 0, i; - var searchItems, match; + var searchItems, match, i, + searchList = [], + matchesCounter = 0, + showMatches = false; - searchResults.forEach(function (item) { - if (item && resultsDisplayed < FIND_IN_FILES_MAX) { + CollectionUtils.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 = []; - while (i < item.matches.length && resultsDisplayed < FIND_IN_FILES_MAX) { + + // 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: i, + item: searchItems.length, line: StringUtils.format(Strings.FIND_IN_FILES_LINE, (match.start.line + 1)), pre: match.line.substr(0, match.start.ch), highlight: match.line.substring(match.start.ch, match.end.ch), @@ -315,40 +398,60 @@ define(function (require, exports, module) { start: match.start, end: match.end }); - resultsDisplayed++; + matchesCounter++; i++; } // Add a row for each file var displayFileName = StringUtils.format( Strings.FIND_IN_FILES_FILE_PATH, - StringUtils.breakableUrl(item.fullPath) + StringUtils.breakableUrl(fullPath) ); searchList.push({ file: searchList.length, filename: displayFileName, - fullPath: item.fullPath, + fullPath: fullPath, items: searchItems }); - } }); - // Insert the search results - $searchContent - .empty() - .append(Mustache.render(searchResultsTemplate, {searchList: searchList})) - .scrollTop(0); // otherwise scroll pos from previous contents is remembered - - $searchResults.find(".close") - .one("click", function () { + // 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 = Math.floor(numMatches / RESULTS_PER_PAGE) * RESULTS_PER_PAGE; + _showSearchResults(); }); - // Add the click event listener directly on the table parent + // Insert the search results $searchContent + .empty() + .append(Mustache.render(searchResultsTemplate, {searchList: searchList})) + .scrollTop(0) // Otherwise scroll pos from previous contents is remembered .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"); @@ -359,8 +462,8 @@ define(function (require, exports, module) { $row.addClass("selected"); $selectedRow = $row; - var searchItem = searchList[$row.data("file")]; - var fullPath = searchItem.fullPath; + var searchItem = searchList[$row.data("file")], + fullPath = searchItem.fullPath; // This is a file title row, expand/collapse on click if ($row.hasClass("file-section")) { @@ -369,6 +472,8 @@ define(function (require, exports, module) { var $triangle = $(".disclosure-triangle", $row); $triangle.toggleClass("expanded").toggleClass("collapsed"); + + searchResults[fullPath].collapsed = !searchResults[fullPath].collapsed; // This is a file row, show the result on click } else { @@ -382,20 +487,40 @@ define(function (require, exports, module) { }); } } + }) + // 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(); + } else { _hideSearchResults(); } } /** + * @private * @param {!FileInfo} fileInfo File in question * @param {?Entry} scope Search scope, or null if whole project * @return {boolean} */ - function inScope(fileInfo, scope) { + function _inScope(fileInfo, scope) { if (scope) { if (scope.isDirectory) { // Dirs always have trailing slash, so we don't have to worry about being @@ -409,12 +534,12 @@ define(function (require, exports, module) { } /** + * @private * Displays a non-modal embedded dialog above the code mirror editor that allows the user to do * a find operation across all files in the project. * @param {?Entry} scope Project file/subfolder to search within; else searches whole project. */ - function doFindInFiles(scope) { - + function _doFindInFiles(scope) { if (scope instanceof NativeFileSystem.InaccessibleFileEntry) { CommandManager.execute(Commands.FILE_OPEN, { fullPath: scope.fullPath }).done(function () { CommandManager.execute(Commands.EDIT_FIND); @@ -422,15 +547,14 @@ define(function (require, exports, module) { return; } - var dialog = new FindInFilesDialog(); - // Default to searching for the current selection - var currentEditor = EditorManager.getActiveEditor(); - var initialString = currentEditor && currentEditor.getSelectedText(); + var currentEditor = EditorManager.getActiveEditor(), + initialString = currentEditor && currentEditor.getSelectedText(), + dialog = new FindInFilesDialog(); - currentQuery = ""; - currentScope = scope; - searchResults = []; + searchResults = {}; + currentQuery = ""; + currentScope = scope; maxHitsFoundInFile = false; dialog.showDialog(initialString, scope) @@ -447,20 +571,13 @@ define(function (require, exports, module) { Async.doInParallel(fileListResult, function (fileInfo) { var result = new $.Deferred(); - if (!inScope(fileInfo, scope)) { + if (!_inScope(fileInfo, scope)) { result.resolve(); } else { // Search one file DocumentManager.getDocumentForPath(fileInfo.fullPath) .done(function (doc) { - var matches = _getSearchMatches(doc.getText(), queryExpr); - - if (matches && matches.length) { - searchResults.push({ - fullPath: fileInfo.fullPath, - matches: matches - }); - } + _addSearchMatches(fileInfo.fullPath, doc.getText(), queryExpr); result.resolve(); }) .fail(function (error) { @@ -473,7 +590,7 @@ define(function (require, exports, module) { }) .done(function () { // Done searching all files: show results - _showSearchResults(searchResults, query, scope); + _showSearchResults(); StatusBar.hideBusyIndicator(); }) .fail(function () { @@ -485,31 +602,82 @@ define(function (require, exports, module) { }); } - /** Search within the file/subtree defined by the sidebar selection */ - function doFindInSubtree() { + /** + * @private + * Search within the file/subtree defined by the sidebar selection + */ + function _doFindInSubtree() { var selectedEntry = ProjectManager.getSelectedItem(); - doFindInFiles(selectedEntry); + _doFindInFiles(selectedEntry); + } + + + /** + * @private + * Shows the search results and tries to restore the previous scroll and selection + */ + function _restoreSearchResults() { + var scrollTop = $searchContent.scrollTop(), + index = $selectedRow ? $selectedRow.index() : null; + + _showSearchResults(); + + $searchContent.scrollTop(scrollTop); + if ($selectedRow) { + $selectedRow = $searchContent.find("tr:eq(" + index + ")"); + $selectedRow.addClass("selected"); + } } + /** + * @private + * Moves the search results from the previous path to the new one and updates the results list, if required + * @param {$.Event} event + * @param {string} oldName + * @param {string} newName + */ function _fileNameChangeHandler(event, oldName, newName) { + var resultsChanged = false; + if (searchResultsPanel.isVisible()) { // Update the search results - searchResults.forEach(function (item) { - item.fullPath = item.fullPath.replace(oldName, newName); + CollectionUtils.forEach(searchResults, function (item, fullPath) { + if (fullPath.match(oldName)) { + searchResults[fullPath.replace(oldName, newName)] = item; + delete searchResults[fullPath]; + resultsChanged = true; + } }); - _showSearchResults(searchResults, currentQuery, currentScope); + + // Restore the results if needed + if (resultsChanged) { + _restoreSearchResults(); + } } } - + + /** + * @private + * Deletes the results from the deleted file and updates the results list, if required + * @param {$.Event} event + * @param {string} path + */ function _pathDeletedHandler(event, path) { + var resultsChanged = false; + if (searchResultsPanel.isVisible()) { // Update the search results - searchResults.forEach(function (item, idx) { - if (FileUtils.isAffectedWhenRenaming(item.fullPath, path)) { - searchResults.splice(idx, 1); + CollectionUtils.forEach(searchResults, function (item, fullPath) { + if (FileUtils.isAffectedWhenRenaming(fullPath, path)) { + delete searchResults[fullPath]; + resultsChanged = true; } }); - _showSearchResults(searchResults, currentQuery, currentScope); + + // Restore the results if needed + if (resultsChanged) { + _restoreSearchResults(); + } } } @@ -530,6 +698,6 @@ define(function (require, exports, module) { $(ProjectManager).on("beforeProjectClose", _hideSearchResults); // Initialize: command handlers - CommandManager.register(Strings.CMD_FIND_IN_FILES, Commands.EDIT_FIND_IN_FILES, doFindInFiles); - CommandManager.register(Strings.CMD_FIND_IN_SUBTREE, Commands.EDIT_FIND_IN_SUBTREE, doFindInSubtree); + CommandManager.register(Strings.CMD_FIND_IN_FILES, Commands.EDIT_FIND_IN_FILES, _doFindInFiles); + CommandManager.register(Strings.CMD_FIND_IN_SUBTREE, Commands.EDIT_FIND_IN_SUBTREE, _doFindInSubtree); }); diff --git a/src/styles/brackets.less b/src/styles/brackets.less index b08f7cb6211..b4663b81142 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -723,6 +723,32 @@ a, img { /* Find in Files results panel - temporary UI, to be replaced with a richer search feature later */ +#search-result-summary { + .first-page, + .prev-page, + .next-page, + .last-page { + .jstree-sprite; + width: 6px; + display: inline-block; + margin: 0 2px; + background-position: 0px 4px; + } + .first-page { + background-position: 0px -80px; + margin-left: 10px; + } + .prev-page { + background-position: 0px -24px; + } + .last-page { + background-position: 0px -52px; + } + .disabled { + opacity: 0.3; + } +} + #search-results .disclosure-triangle { .jstree-sprite; display: inline-block; diff --git a/src/styles/images/jsTreeSprites.svg b/src/styles/images/jsTreeSprites.svg index 215abedf14d..afaddb4a16a 100644 --- a/src/styles/images/jsTreeSprites.svg +++ b/src/styles/images/jsTreeSprites.svg @@ -1,14 +1,52 @@ - - Slice 1 + + jsTreeSprites Created with Sketch (http://www.bohemiancoding.com/sketch) - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file