diff --git a/src/base-config/keyboard.json b/src/base-config/keyboard.json index d25448fec91..be41e05144a 100644 --- a/src/base-config/keyboard.json +++ b/src/base-config/keyboard.json @@ -151,6 +151,9 @@ "cmd.findInFiles": [ "Ctrl-Shift-F" ], + "cmd.replaceInFiles": [ + "Ctrl-Alt-Shift-F" + ], "cmd.findNext": [ { "key": "F3" diff --git a/src/brackets.js b/src/brackets.js index dfd95b53b4e..f82b186ed01 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -124,7 +124,7 @@ define(function (require, exports, module) { require("editor/EditorCommandHandlers"); require("editor/EditorOptionHandlers"); require("help/HelpCommandHandlers"); - require("search/FindInFiles"); + require("search/FindInFilesUI"); require("search/FindReplace"); require("extensibility/InstallExtensionDialog"); require("extensibility/ExtensionManagerDialog"); @@ -161,16 +161,20 @@ define(function (require, exports, module) { Dialogs : Dialogs, DocumentCommandHandlers : DocumentCommandHandlers, DocumentManager : DocumentManager, + DocumentModule : require("document/Document"), DOMAgent : require("LiveDevelopment/Agents/DOMAgent"), DragAndDrop : DragAndDrop, EditorManager : EditorManager, ExtensionLoader : ExtensionLoader, ExtensionUtils : ExtensionUtils, + File : require("filesystem/File"), FileFilters : require("search/FileFilters"), FileSyncManager : FileSyncManager, FileSystem : FileSystem, FileViewController : FileViewController, + FileUtils : require("file/FileUtils"), FindInFiles : require("search/FindInFiles"), + FindInFilesUI : require("search/FindInFilesUI"), HTMLInstrumentation : require("language/HTMLInstrumentation"), Inspector : require("LiveDevelopment/Inspector/Inspector"), InstallExtensionDialog : require("extensibility/InstallExtensionDialog"), diff --git a/src/command/Commands.js b/src/command/Commands.js index c35f0f5a30b..b1c1391e5d9 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -107,15 +107,18 @@ define(function (require, exports, module) { // FIND exports.CMD_FIND = "cmd.find"; // FindReplace.js _launchFind() - exports.CMD_FIND_IN_FILES = "cmd.findInFiles"; // FindInFiles.js _doFindInFiles() - exports.CMD_FIND_IN_SELECTED = "cmd.findInSelected"; // FindInFiles.js _doFindInSubtree() - exports.CMD_FIND_IN_SUBTREE = "cmd.findInSubtree"; // FindInFiles.js _doFindInSubtree() + exports.CMD_FIND_IN_FILES = "cmd.findInFiles"; // FindInFilesUI.js _showFindBar() + exports.CMD_FIND_IN_SELECTED = "cmd.findInSelected"; // FindInFilesUI.js _showFindBarForSubtree() + exports.CMD_FIND_IN_SUBTREE = "cmd.findInSubtree"; // FindInFilesUI.js _showFindBarForSubtree() exports.CMD_FIND_NEXT = "cmd.findNext"; // FindReplace.js _findNext() exports.CMD_FIND_PREVIOUS = "cmd.findPrevious"; // FindReplace.js _findPrevious() exports.CMD_FIND_ALL_AND_SELECT = "cmd.findAllAndSelect"; // FindReplace.js _findAllAndSelect() exports.CMD_ADD_NEXT_MATCH = "cmd.addNextMatch"; // FindReplace.js _expandAndAddNextToSelection() exports.CMD_SKIP_CURRENT_MATCH = "cmd.skipCurrentMatch"; // FindReplace.js _skipCurrentMatch() exports.CMD_REPLACE = "cmd.replace"; // FindReplace.js _replace() + exports.CMD_REPLACE_IN_FILES = "cmd.replaceInFiles"; // FindInFilesUI.js _showReplaceBar() + exports.CMD_REPLACE_IN_SELECTED = "cmd.replaceInSelected"; // FindInFilesUI.js _showReplaceBarForSubtree() + exports.CMD_REPLACE_IN_SUBTREE = "cmd.replaceInSubtree"; // FindInFilesUI.js _showReplaceBarForSubtree() // VIEW exports.VIEW_HIDE_SIDEBAR = "view.hideSidebar"; // SidebarView.js toggle() diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index 3c5b55c09ed..baafe4ebd8d 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -111,6 +111,8 @@ define(function (require, exports, module) { menu.addMenuItem(Commands.CMD_FIND_IN_SELECTED); menu.addMenuDivider(); menu.addMenuItem(Commands.CMD_REPLACE); + menu.addMenuItem(Commands.CMD_REPLACE_IN_FILES); + menu.addMenuItem(Commands.CMD_REPLACE_IN_SELECTED); /* * View menu @@ -205,6 +207,7 @@ define(function (require, exports, module) { project_cmenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_OS); project_cmenu.addMenuDivider(); project_cmenu.addMenuItem(Commands.CMD_FIND_IN_SUBTREE); + project_cmenu.addMenuItem(Commands.CMD_REPLACE_IN_SUBTREE); project_cmenu.addMenuDivider(); project_cmenu.addMenuItem(Commands.FILE_REFRESH); @@ -216,6 +219,7 @@ define(function (require, exports, module) { working_set_cmenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_OS); working_set_cmenu.addMenuDivider(); working_set_cmenu.addMenuItem(Commands.CMD_FIND_IN_SUBTREE); + working_set_cmenu.addMenuItem(Commands.CMD_REPLACE_IN_SUBTREE); working_set_cmenu.addMenuDivider(); working_set_cmenu.addMenuItem(Commands.FILE_CLOSE); diff --git a/src/document/Document.js b/src/document/Document.js index 5b3947b6724..022d88364d2 100644 --- a/src/document/Document.js +++ b/src/document/Document.js @@ -78,7 +78,7 @@ define(function (require, exports, module) { this.file = file; this._updateLanguage(); - this.refreshText(rawText, initialTimestamp); + this.refreshText(rawText, initialTimestamp, true); } /** @@ -281,8 +281,10 @@ define(function (require, exports, module) { * the text's line-ending style. CAN be called even if there is no backing editor. * @param {!string} text The text to replace the contents of the document with. * @param {!Date} newTimestamp Timestamp of file at the time we read its new contents from disk. + * @param {boolean} initial True if this is the initial load of the document. In that case, + * we don't send change events. */ - Document.prototype.refreshText = function (text, newTimestamp) { + Document.prototype.refreshText = function (text, newTimestamp, initial) { var perfTimerName = PerfUtils.markStart("refreshText:\t" + (!this.file || this.file.fullPath)); // If clean, don't transiently mark dirty during refresh @@ -294,12 +296,17 @@ define(function (require, exports, module) { // _handleEditorChange() triggers "change" event for us } else { this._text = text; - // We fake a change record here that looks like CodeMirror's text change records, but - // omits "from" and "to", by which we mean the entire text has changed. - // TODO: Dumb to split it here just to join it again in the change handler, but this is - // the CodeMirror change format. Should we document our change format to allow this to - // either be an array of lines or a single string? - $(this).triggerHandler("change", [this, [{text: text.split(/\r?\n/)}]]); + + if (!initial) { + // We fake a change record here that looks like CodeMirror's text change records, but + // omits "from" and "to", by which we mean the entire text has changed. + // TODO: Dumb to split it here just to join it again in the change handler, but this is + // the CodeMirror change format. Should we document our change format to allow this to + // either be an array of lines or a single string? + var fakeChangeList = [{text: text.split(/\r?\n/)}]; + $(this).triggerHandler("change", [this, fakeChangeList]); + $(exports).triggerHandler("documentChange", [this, fakeChangeList]); + } } this._updateTimestamp(newTimestamp); diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 440a08b25e1..23454200d69 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -666,10 +666,13 @@ define(function (require, exports, module) { * Reverts the Document to the current contents of its file on disk. Discards any unsaved changes * in the Document. * @param {Document} doc - * @return {$.Promise} a Promise that's resolved when done, or rejected with a FileSystemError if the - * file cannot be read (after showing an error dialog to the user). + * @param {boolean=} suppressError If true, then a failure to read the file will be ignored and the + * resulting promise will be resolved rather than rejected. + * @return {$.Promise} a Promise that's resolved when done, or (if suppressError is false) + * rejected with a FileSystemError if the file cannot be read (after showing an error + * dialog to the user). */ - function doRevert(doc) { + function doRevert(doc, suppressError) { var result = new $.Deferred(); FileUtils.readAsText(doc.file) @@ -678,10 +681,14 @@ define(function (require, exports, module) { result.resolve(); }) .fail(function (error) { - FileUtils.showFileOpenError(error, doc.file.fullPath) - .done(function () { - result.reject(error); - }); + if (suppressError) { + result.resolve(); + } else { + FileUtils.showFileOpenError(error, doc.file.fullPath) + .done(function () { + result.reject(error); + }); + } }); return result.promise(); @@ -1070,13 +1077,19 @@ define(function (require, exports, module) { // copy of whatever's on disk. doClose(file); - // Only reload from disk if we've executed the Close for real, - // *and* if at least one other view still exists - if (!promptOnly && DocumentManager.getOpenDocumentForPath(file.fullPath)) { - doRevert(doc) - .then(result.resolve, result.reject); - } else { + // Only reload from disk if we've executed the Close for real. + if (promptOnly) { result.resolve(); + } else { + // Even if there are no listeners attached to the document at this point, we want + // to do the revert anyway, because clients who are listening to the global documentChange + // event from the Document module (rather than attaching to the document directly), + // such as the Find in Files panel, should get a change event. However, in that case, + // we want to ignore errors during the revert, since we don't want a failed revert + // to throw a dialog if the document isn't actually open in the UI. + var suppressError = !DocumentManager.getOpenDocumentForPath(file.fullPath); + doRevert(doc, suppressError) + .then(result.resolve, result.reject); } } }); @@ -1096,8 +1109,9 @@ define(function (require, exports, module) { * @param {!Array.} list * @param {boolean} promptOnly * @param {boolean} clearCurrentDoc + * @param {boolean} _forceClose Whether to force all the documents to close even if they have unsaved changes. For unit testing only. */ - function _closeList(list, promptOnly, clearCurrentDoc) { + function _closeList(list, promptOnly, clearCurrentDoc, _forceClose) { var result = new $.Deferred(), unsavedDocs = []; @@ -1108,8 +1122,8 @@ define(function (require, exports, module) { } }); - if (unsavedDocs.length === 0) { - // No unsaved changes, so we can proceed without a prompt + if (unsavedDocs.length === 0 || _forceClose) { + // No unsaved changes or we want to ignore them, so we can proceed without a prompt result.resolve(); } else if (unsavedDocs.length === 1) { @@ -1125,17 +1139,7 @@ define(function (require, exports, module) { } else { // Multiple unsaved files: show a single bulk prompt listing all files - var message = Strings.SAVE_CLOSE_MULTI_MESSAGE; - - message += ""; + var message = Strings.SAVE_CLOSE_MULTI_MESSAGE + StringUtils.makeDialogFileList(_.map(unsavedDocs, _shortTitleForDocument)); Dialogs.showModalDialog( DefaultDialogs.DIALOG_ID_SAVE_CLOSE, @@ -1200,7 +1204,7 @@ define(function (require, exports, module) { */ function handleFileCloseAll(commandData) { return _closeList(DocumentManager.getWorkingSet(), - (commandData && commandData.promptOnly), true).done(function () { + (commandData && commandData.promptOnly), true, (commandData && commandData._forceClose)).done(function () { if (!DocumentManager.getCurrentDocument()) { EditorManager._closeCustomViewer(); } diff --git a/src/document/DocumentManager.js b/src/document/DocumentManager.js index e81fefe1e65..7fcecd5b255 100644 --- a/src/document/DocumentManager.js +++ b/src/document/DocumentManager.js @@ -735,23 +735,31 @@ define(function (require, exports, module) { * Differs from plain FileUtils.readAsText() in two ways: (a) line endings are still normalized * as in Document.getText(); (b) unsaved changes are returned if there are any. * - * @param {!File} file - * @return {!string} + * @param {!File} file The file to get the text for. + * @param {boolean=} checkLineEndings Whether to return line ending information. Default false (slightly more efficient). + * @return {$.Promise} + * A promise that is resolved with three parameters: + * contents - string: the document's text + * timestamp - Date: the last time the document was changed on disk (might not be the same as the last time it was changed in memory) + * lineEndings - string: the original line endings of the file, one of the FileUtils.LINE_ENDINGS_* constants; + * will be null if checkLineEndings was false. + * or rejected with a filesystem error. */ - function getDocumentText(file) { + function getDocumentText(file, checkLineEndings) { var result = new $.Deferred(), doc = getOpenDocumentForPath(file.fullPath); if (doc) { - result.resolve(doc.getText()); + result.resolve(doc.getText(), doc.diskTimestamp, checkLineEndings ? doc._lineEndings : null); } else { - file.read(function (err, contents) { + file.read(function (err, contents, stat) { if (err) { result.reject(err); } else { // Normalize line endings the same way Document would, but don't actually // new up a Document (which entails a bunch of object churn). + var originalLineEndings = checkLineEndings ? FileUtils.sniffLineEndings(contents) : null; contents = DocumentModule.Document.normalizeText(contents); - result.resolve(contents); + result.resolve(contents, stat.mtime, originalLineEndings); } }); } diff --git a/src/file/FileUtils.js b/src/file/FileUtils.js index 50e22e28614..1241c44ab8c 100644 --- a/src/file/FileUtils.js +++ b/src/file/FileUtils.js @@ -439,7 +439,39 @@ define(function (require, exports, module) { return extFirst ? (cmpExt || cmpNames) : (cmpNames || cmpExt); } + + /** + * Compares two paths. Useful for sorting. + * @param {string} filename1 + * @param {string} filename2 + * @param {boolean} extFirst If true it compares the extensions first and then the file names. + * @return {number} The result of the local compare function + */ + function comparePaths(path1, path2) { + var entryName1, entryName2, + pathParts1 = path1.split("/"), + pathParts2 = path2.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 compareFilenames(entryName1, entryName2); + } + return (index >= folders1 && index < folders2) ? 1 : -1; + } + index++; + } + return 0; + } // Define public API exports.LINE_ENDINGS_CRLF = LINE_ENDINGS_CRLF; @@ -465,4 +497,5 @@ define(function (require, exports, module) { exports.getFileExtension = getFileExtension; exports.getSmartFileExtension = getSmartFileExtension; exports.compareFilenames = compareFilenames; + exports.comparePaths = comparePaths; }); diff --git a/src/htmlContent/findreplace-bar.html b/src/htmlContent/findreplace-bar.html index 718cd666f5c..fcb7aaf716e 100644 --- a/src/htmlContent/findreplace-bar.html +++ b/src/htmlContent/findreplace-bar.html @@ -1,24 +1,31 @@ -
+
+
+ + {{#replace}} +
+ {{/replace}} +
+ +{{#scope}} +
{{Strings.FIND_NO_RESULTS}}
{{{scopeLabel}}}
-
-{{/replace}} +
+{{/scope}} 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..8f8126e633a 100644 --- a/src/htmlContent/search-results.html +++ b/src/htmlContent/search-results.html @@ -2,13 +2,14 @@ {{#searchList}} - + {{{filename}}} {{#items}} - + + {{#replace}}{{/replace}} {{line}} {{pre}}{{highlight}}{{post}} diff --git a/src/htmlContent/search-summary-paging.html b/src/htmlContent/search-summary-paging.html new file mode 100644 index 00000000000..5d4a88d9f6d --- /dev/null +++ b/src/htmlContent/search-summary-paging.html @@ -0,0 +1,9 @@ +{{#hasPages}} +
+ + + {{{results}}} + + +
+{{/hasPages}} diff --git a/src/htmlContent/search-summary.html b/src/htmlContent/search-summary.html index e74b562853a..cd22ad3d897 100644 --- a/src/htmlContent/search-summary.html +++ b/src/htmlContent/search-summary.html @@ -1,14 +1,21 @@ -
{{Strings.FIND_IN_FILES_TITLE_PART1}}
+{{#replace}} +
+{{/replace}} +
{{titleLabel}} "
{{query}}
-
{{Strings.FIND_IN_FILES_TITLE_PART2}}
+
"
+{{#replace}} +
{{Strings.FIND_REPLACE_TITLE_WITH}} "
+
{{replaceWith}}
+
"
+{{/replace}} +{{#scope}}
{{{scope}}}
+{{/scope}}
{{{summary}}}
-{{#hasPages}} -
- - - {{{results}}} - - +{{>paging}} +{{#replace}} +
+
-{{/hasPages}} \ No newline at end of file +{{/replace}} \ No newline at end of file diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 9b3ed86b7af..7014577138b 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -133,7 +133,8 @@ define({ "FIND_RESULT_COUNT_SINGLE" : "1 result", "FIND_NO_RESULTS" : "No results", "REPLACE_PLACEHOLDER" : "Replace with\u2026", - "BUTTON_REPLACE_ALL" : "All\u2026", + "BUTTON_REPLACE_ALL" : "Batch\u2026", + "BUTTON_REPLACE_ALL_IN_FILES" : "Replace\u2026", "BUTTON_REPLACE" : "Replace", "BUTTON_NEXT" : "\u25B6", "BUTTON_PREV" : "\u25C0", @@ -141,6 +142,9 @@ define({ "BUTTON_PREV_HINT" : "Previous Match", "BUTTON_CASESENSITIVE_HINT" : "Match Case", "BUTTON_REGEXP_HINT" : "Regular Expression", + "REPLACE_WITHOUT_UNDO_WARNING_TITLE": "Replace Without Undo", + "REPLACE_WITHOUT_UNDO_WARNING" : "Because more than {0} files need to be changed, {APP_NAME} will modify unopened files on disk.
You won't be able to undo replacements in those files.", + "BUTTON_REPLACE_WITHOUT_UNDO" : "Replace Without Undo", "OPEN_FILE" : "Open File", "SAVE_FILE_AS" : "Save File", @@ -150,15 +154,14 @@ define({ "NO_UPDATE_TITLE" : "You're up to date!", "NO_UPDATE_MESSAGE" : "You are running the latest version of {APP_NAME}.", - // Replace All (in single file) - "FIND_REPLACE_TITLE_PART1" : "Replace \"", - "FIND_REPLACE_TITLE_PART2" : "\" with \"", - "FIND_REPLACE_TITLE_PART3" : "\" — {2} {0} {1}", + // Find and Replace + "FIND_REPLACE_TITLE_LABEL" : "Replace", + "FIND_REPLACE_TITLE_WITH" : "with", + "FIND_TITLE_LABEL" : "Found", + "FIND_TITLE_SUMMARY" : " — {0} {1} {2} in {3}", // Find in Files - "FIND_IN_FILES_TITLE_PART1" : "\"", - "FIND_IN_FILES_TITLE_PART2" : "\" found", - "FIND_IN_FILES_TITLE_PART3" : "— {0} {1} {2} in {3} {4}", + "FIND_NUM_FILES" : "{0} {1}", "FIND_IN_FILES_SCOPED" : "in {0}", "FIND_IN_FILES_NO_SCOPE" : "in project", "FIND_IN_FILES_ZERO_FILES" : "Filter excludes all files {0}", @@ -170,6 +173,9 @@ define({ "FIND_IN_FILES_PAGING" : "{0}—{1}", "FIND_IN_FILES_FILE_PATH" : "{0} {2} {1}", // We should use normal dashes on Windows instead of em dash eventually "FIND_IN_FILES_EXPAND_COLLAPSE" : "Ctrl/Cmd click to expand/collapse all", + "REPLACE_IN_FILES_ERRORS_TITLE" : "Replace Errors", + "REPLACE_IN_FILES_ERRORS" : "The following files weren't modified because they changed after the search or couldn't be written.", + "ERROR_FETCHING_UPDATE_INFO_TITLE" : "Error getting update info", "ERROR_FETCHING_UPDATE_INFO_MSG" : "There was a problem getting the latest update information from the server. Please make sure you are connected to the internet and try again.", @@ -315,6 +321,9 @@ define({ "CMD_FIND_IN_SELECTED" : "Find in Selected File/Folder", "CMD_FIND_IN_SUBTREE" : "Find in\u2026", "CMD_REPLACE" : "Replace", + "CMD_REPLACE_IN_FILES" : "Replace in Files", + "CMD_REPLACE_IN_SELECTED" : "Replace in Selected File/Folder", + "CMD_REPLACE_IN_SUBTREE" : "Replace in\u2026", // View menu commands "VIEW_MENU" : "View", diff --git a/src/search/FindBar.js b/src/search/FindBar.js index 910b4e5e741..1ad77536b00 100644 --- a/src/search/FindBar.js +++ b/src/search/FindBar.js @@ -53,8 +53,10 @@ define(function (require, exports, module) { * * queryChange - when the user types in the input field or sets a query option. Use getQuery() * to get the current query state. - * doFind - when the user hits enter/shift-enter in the input field or clicks the Find Previous or Find Next button. - * Parameter is a boolean, false for Find Next, true for Find Previous. + * doFind - when the user hits enter/shift-enter in an input field or clicks the Find Previous or Find Next button. + * Parameters are: + * shiftKey - boolean, false for Find Next, true for Find Previous. + * replace - boolean, true if they hit enter in the Replace field * doReplace - when the user clicks on the Replace or Replace All button. Parameter is a boolean, * false for single Replace, true for Replace All. Use getReplaceText() to get the current * replacement text. @@ -63,7 +65,9 @@ define(function (require, exports, module) { * @param {{navigator: boolean, replace: boolean, queryPlaceholder: string, initialQuery: string}} options * Options for the Find bar. * navigator - true to show the Find Previous/Find Next buttons - default false - * replace - true to show the Replace field - default false + * replace - true to show the Replace controls - default false + * replaceAllOnly - true to show only a Replace All button (no Replace button) - default false + * scope - true to show the scope filter controls - default false * queryPlaceholder - label to show in the Find field - default empty string * initialQuery - query to populate in the Find field on open - default empty string * scopeLabel - label to show for the scope of the search - default empty string @@ -223,6 +227,7 @@ define(function (require, exports, module) { var templateVars = _.clone(this._options); templateVars.Strings = Strings; + templateVars.replaceAllLabel = (templateVars.replaceAllOnly ? Strings.BUTTON_REPLACE_ALL_IN_FILES : Strings.BUTTON_REPLACE_ALL); this._modalBar = new ModalBar(Mustache.render(_searchBarTemplate, templateVars), true); // 2nd arg = auto-close on Esc/blur @@ -249,11 +254,11 @@ define(function (require, exports, module) { self._updatePrefsFromSearchBar(); $(self).triggerHandler("queryChange"); }) - .on("keydown", function (e) { + .on("keydown", "#find-what, #replace-with", function (e) { if (e.keyCode === KeyEvent.DOM_VK_RETURN) { e.preventDefault(); e.stopPropagation(); - $(self).triggerHandler("doFind", e.shiftKey); + $(self).triggerHandler("doFind", [e.shiftKey, (e.target.id === "replace-with")]); } }); @@ -431,14 +436,30 @@ define(function (require, exports, module) { }; /** - * Sets focus to the query field and selects its text. + * @private + * Focus and select the contents of the given field. + * @param {string} selector The selector for the field. */ - FindBar.prototype.focusQuery = function () { - this.$("#find-what") + FindBar.prototype._focus = function (selector) { + this.$(selector) .focus() .get(0).select(); }; + /** + * Sets focus to the query field and selects its text. + */ + FindBar.prototype.focusQuery = function () { + this._focus("#find-what"); + }; + + /** + * Sets focus to the replace field and selects its text. + */ + FindBar.prototype.focusReplace = function () { + this._focus("#replace-with"); + }; + PreferencesManager.stateManager.definePreference("caseSensitive", "boolean", false); PreferencesManager.stateManager.definePreference("regexp", "boolean", false); PreferencesManager.convertPreferences(module, {"caseSensitive": "user", "regexp": "user"}, true); diff --git a/src/search/FindInFiles.js b/src/search/FindInFiles.js index 0dfa3d37129..cb9918aa006 100644 --- a/src/search/FindInFiles.js +++ b/src/search/FindInFiles.js @@ -25,193 +25,50 @@ /*global define, $, window, Mustache */ /* - * 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. - * - * FUTURE: - * - 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 multiple lines - * - Refactor UI from functionality to enable unit testing + * The core search functionality used by Find in Files and single-file Replace Batch. */ - - define(function (require, exports, module) { "use strict"; - var _ = require("thirdparty/lodash"), - FileFilters = require("search/FileFilters"), - Async = require("utils/Async"), - Resizer = require("utils/Resizer"), - CommandManager = require("command/CommandManager"), - Commands = require("command/Commands"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"), - ProjectManager = require("project/ProjectManager"), - DocumentModule = require("document/Document"), - DocumentManager = require("document/DocumentManager"), - EditorManager = require("editor/EditorManager"), - FileSystem = require("filesystem/FileSystem"), - FileUtils = require("file/FileUtils"), - FileViewController = require("project/FileViewController"), - LanguageManager = require("language/LanguageManager"), - PerfUtils = require("utils/PerfUtils"), - InMemoryFile = require("document/InMemoryFile"), - PanelManager = require("view/PanelManager"), - AppInit = require("utils/AppInit"), - StatusBar = require("widgets/StatusBar"), - FindBar = require("search/FindBar").FindBar; - - var searchPanelTemplate = require("text!htmlContent/search-panel.html"), - searchSummaryTemplate = require("text!htmlContent/search-summary.html"), - searchResultsTemplate = require("text!htmlContent/search-results.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, - UPDATE_TIMEOUT = 400; + var _ = require("thirdparty/lodash"), + FileFilters = require("search/FileFilters"), + Async = require("utils/Async"), + StringUtils = require("utils/StringUtils"), + ProjectManager = require("project/ProjectManager"), + DocumentModule = require("document/Document"), + DocumentManager = require("document/DocumentManager"), + FileSystem = require("filesystem/FileSystem"), + LanguageManager = require("language/LanguageManager"), + SearchModel = require("search/SearchModel").SearchModel, + PerfUtils = require("utils/PerfUtils"), + FindUtils = require("search/FindUtils"); /** @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 {{query: string, caseSensitive: boolean, isRegexp: boolean}} The current search query */ - var currentQuery = null; - - /** @type {RegExp} The current search query regular expression */ - var currentQueryExpr = null; - - /** @type {?FileSystemEntry} Root of subtree to search in, or single file to search in, or null to search entire project */ - var currentScope = null; - - /** @type {string} Compiled filter from FileFilters */ - var currentFilter = null; - - /** @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 {FindBar} Find bar containing the search UI. */ - var findBar = 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 - * @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo The query info from the find bar - * @return {RegExp} - */ - function _getQueryRegExp(queryInfo) { - if (findBar) { - findBar.showError(null); - } - - // TODO: only apparent difference between this one and the one in FindReplace is that this one returns - // null instead of "" for a bad query, and this always returns a regexp even for simple strings. Reconcile. - if (!queryInfo || !queryInfo.query) { - return null; - } - - // Is it a (non-blank) regex? - if (queryInfo.isRegexp) { - try { - return new RegExp(queryInfo.query, queryInfo.isCaseSensitive ? "g" : "gi"); - } catch (e) { - if (findBar) { - findBar.showError(e.message); - } - return null; - } - } else { - // Query is a plain string. Turn it into a regexp - return new RegExp(StringUtils.regexEscape(queryInfo.query), queryInfo.isCaseSensitive ? "g" : "gi"); - } - } + /** @type {SearchModel} The search query and results model. */ + var searchModel = new SearchModel(); - /** - * @private - * Returns label text to indicate the search scope. Already HTML-escaped. - * @param {?Entry} scope - * @return {string} - */ - function _labelForScope(scope) { - var projName = ProjectManager.getProjectRoot().name; - if (scope) { - return StringUtils.format( - Strings.FIND_IN_FILES_SCOPED, - StringUtils.breakableUrl( - ProjectManager.makeProjectRelativeIfPossible(scope.fullPath) - ) - ); - } else { - return Strings.FIND_IN_FILES_NO_SCOPE; - } - } + /** Forward declarations */ + var _documentChangeHandler, _fileSystemChangeHandler, _fileNameChangeHandler; - /** Remove listeners that were tracking potential search result changes */ + /** Remove the listeners that were tracking potential search result changes */ function _removeListeners() { - $(DocumentModule).off(".findInFiles"); + $(DocumentModule).off("documentChange", _documentChangeHandler); FileSystem.off("change", _fileSystemChangeHandler); + $(DocumentManager).off("fileNameChange", _fileNameChangeHandler); } /** 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(); + if (searchModel.hasResults()) { + // Avoid adding duplicate listeners - e.g. if a 2nd search is run without closing the old results panel first + _removeListeners(); + + $(DocumentModule).on("documentChange", _documentChangeHandler); + FileSystem.on("change", _fileSystemChangeHandler); + $(DocumentManager).on("fileNameChange", _fileNameChangeHandler); } - _removeListeners(); } /** @@ -222,8 +79,8 @@ define(function (require, exports, module) { * @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) { + // Quick exit if not found or if we hit the limit + if (searchModel.foundMaximum || contents.search(queryExpr) === -1) { return null; } @@ -241,16 +98,19 @@ define(function (require, exports, module) { line = line.substr(0, Math.min(200, line.length)); matches.push({ - start: {line: lineNum, ch: ch}, - end: {line: lineNum, ch: ch + matchLength}, - line: line + start: {line: lineNum, ch: ch}, + end: {line: lineNum, ch: ch + matchLength}, + startOffset: match.index, + endOffset: match.index + matchLength, + line: line, + result: match, + isChecked: true }); // 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) { + if (matches.length >= SearchModel.MAX_TOTAL_RESULTS) { queryExpr.lastIndex = 0; - maxHitsFoundInFile = true; break; } } @@ -266,366 +126,27 @@ define(function (require, exports, module) { * @param {RegExp} queryExpr * @return {boolean} True iff the matches were added to the search results */ - function _addSearchMatches(fullPath, contents, queryExpr) { + function _addSearchMatches(fullPath, contents, queryExpr, timestamp) { var matches = _getSearchMatches(contents, queryExpr); if (matches && matches.length) { - searchResults[fullPath] = { - matches: matches, - collapsed: false - }; + searchModel.addResultMatches(fullPath, matches, timestamp); 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 && currentQuery.query) || "", - 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 (findBar) { - findBar.close(); - } - - } else { - _hideSearchResults(); - - if (findBar) { - var showMessage = false; - findBar.enable(true); - findBar.focusQuery(); - if (zeroFilesToken === ZERO_FILES_TO_SEARCH) { - findBar.showError(StringUtils.format(Strings.FIND_IN_FILES_ZERO_FILES, _labelForScope(currentScope)), true); - } else { - showMessage = true; - } - findBar.showNoResults(true, showMessage); - } - } - } - - /** - * @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 + * 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 */ - function _updateSearchResults(doc, changeList) { - var i, diff, matches, + function _updateResults(doc, changeList) { + var i, diff, matches, lines, start, howMany, resultsChanged = false, - fullPath = doc.file.fullPath, - lines, start, howMany; + fullPath = doc.file.fullPath; changeList.forEach(function (change) { lines = []; @@ -634,7 +155,8 @@ define(function (require, exports, module) { // 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); + // TODO: add unit test exercising timestamp logic in this case + _addSearchMatches(fullPath, doc.getText(), searchModel.queryExpr, doc.diskTimestamp); resultsChanged = true; } else { @@ -650,10 +172,10 @@ define(function (require, exports, module) { diff = lines.length - 1; } - if (searchResults[fullPath]) { + if (searchModel.results[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) { + searchModel.results[fullPath].matches.forEach(function (item) { if (item.end.line < change.from.line) { start++; } else if (item.end.line <= change.to.line) { @@ -666,13 +188,13 @@ define(function (require, exports, module) { // Delete the lines that where deleted or replaced if (howMany > 0) { - searchResults[fullPath].matches.splice(start, howMany); + searchModel.results[fullPath].matches.splice(start, howMany); } resultsChanged = true; } // Searches only over the lines that changed - matches = _getSearchMatches(lines.join("\r\n"), currentQueryExpr); + matches = _getSearchMatches(lines.join("\r\n"), searchModel.queryExpr); if (matches && matches.length) { // Updates the line numbers, since we only searched part of the file matches.forEach(function (value, key) { @@ -681,29 +203,34 @@ define(function (require, exports, module) { }); // 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 (searchModel.results[fullPath]) { + Array.prototype.splice.apply(searchModel.results[fullPath].matches, [start, 0].concat(matches)); // If not, add the matches to a new file index } else { - searchResults[fullPath] = { + // TODO: add unit test exercising timestamp logic in self case + searchModel.results[fullPath] = { matches: matches, - collapsed: false + collapsed: false, + timestamp: doc.diskTimestamp }; } resultsChanged = true; } // All the matches where deleted, remove the file from the results - if (searchResults[fullPath] && !searchResults[fullPath].matches.length) { - delete searchResults[fullPath]; + if (searchModel.results[fullPath] && !searchModel.results[fullPath].matches.length) { + delete searchModel.results[fullPath]; resultsChanged = true; } } }); - return resultsChanged; + if (resultsChanged) { + searchModel.fireChanged(); + } } - + + /** * 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. @@ -735,15 +262,23 @@ define(function (require, exports, module) { } /** - * Finds all candidate files to search in currentScope's subtree that are not binary content. Does NOT apply - * currentFilter yet. + * Finds all candidate files to search in the given scope's subtree that are not binary content. Does NOT apply + * the current filter yet. */ - function getCandidateFiles() { + function getCandidateFiles(scope) { function filter(file) { - return _subtreeFilter(file, currentScope) && _isReadableText(file.fullPath); + return _subtreeFilter(file, scope) && _isReadableText(file.fullPath); } - return ProjectManager.getAllFiles(filter, true); + // If the scope is a single file, just check if the file passes the filter directly rather than + // trying to use ProjectManager.getAllFiles(), both for performance and because an individual + // in-memory file might be an untitled document or external file that doesn't show up in + // getAllFiles(). + if (scope && scope.isFile) { + return new $.Deferred().resolve(filter(scope) ? [scope] : []).promise(); + } else { + return ProjectManager.getAllFiles(filter, true); + } } /** @@ -756,8 +291,8 @@ define(function (require, exports, module) { */ function _inSearchScope(file) { // Replicate the checks getCandidateFiles() does - if (currentScope) { - if (!_subtreeFilter(file, currentScope)) { + if (searchModel && searchModel.scope) { + if (!_subtreeFilter(file, searchModel.scope)) { return false; } } else { @@ -778,50 +313,44 @@ define(function (require, exports, module) { } // Replicate the filtering filterFileList() does - return FileFilters.filterPath(currentFilter, file.fullPath); + return FileFilters.filterPath(searchModel.filter, file.fullPath); } + /** * @private - * Updates the search results in response to (unsaved) text edits + * 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 */ _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); - } + _updateResults(document, change); } }; /** + * @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); + .done(function (text, timestamp) { + // Note that we don't fire a model change here, since this is always called by some outer batch + // operation that will fire it once it's done. + var foundMatches = _addSearchMatches(file.fullPath, text, searchModel.queryExpr, timestamp); result.resolve(foundMatches); }) .fail(function () { @@ -835,29 +364,28 @@ define(function (require, exports, module) { /** * @private - * Executes the Find in Files search inside the 'currentScope' - * @param {string} query String to be searched + * Executes the Find in Files search inside the current scope. + * @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo Query info object * @param {!$.Promise} candidateFilesPromise Promise from getCandidateFiles(), which was called earlier + * @param {?string} filter A "compiled" filter as returned by FileFilters.compile(), or null for no filter + * @return {?$.Promise} A promise that's resolved with the search results (or ZERO_FILES_TO_SEARCH) or rejected when the find competes. + * Will be null if the query is invalid. */ - function _doSearch(queryInfo, candidateFilesPromise) { - currentQuery = queryInfo; - currentQueryExpr = _getQueryRegExp(queryInfo); - - if (!currentQueryExpr) { - StatusBar.hideBusyIndicator(); - if (findBar) { - findBar.close(); - } - return; + function _doSearch(queryInfo, candidateFilesPromise, filter) { + searchModel.filter = filter; + + var queryResult = searchModel.setQueryInfo(queryInfo); + if (!queryResult.valid) { + return null; } - var scopeName = currentScope ? currentScope.fullPath : ProjectManager.getProjectRoot().fullPath, + var scopeName = searchModel.scope ? searchModel.scope.fullPath : ProjectManager.getProjectRoot().fullPath, perfTimer = PerfUtils.markStart("FindIn: " + scopeName + " - " + queryInfo.query); - candidateFilesPromise + return candidateFilesPromise .then(function (fileListResult) { // Filter out files/folders that match user's current exclusion filter - fileListResult = FileFilters.filterFileList(currentFilter, fileListResult); + fileListResult = FileFilters.filterFileList(filter, fileListResult); if (fileListResult.length) { return Async.doInParallel(fileListResult, _doSearchInOneFile); @@ -865,152 +393,80 @@ define(function (require, exports, module) { return ZERO_FILES_TO_SEARCH; } }) - .done(function (zeroFilesToken) { - // Done searching all files: show results - _sortResultFiles(); - _showSearchResults(zeroFilesToken); - StatusBar.hideBusyIndicator(); + .then(function (zeroFilesToken) { + exports._searchDone = true; // for unit tests PerfUtils.addMeasurement(perfTimer); - + // Listen for FS & Document changes to keep results up to date - if (!$.isEmptyObject(searchResults)) { - _addListeners(); - } + _addListeners(); - exports._searchResults = searchResults; // for unit tests - }) - .fail(function (err) { + if (zeroFilesToken === ZERO_FILES_TO_SEARCH) { + return zeroFilesToken; + } else { + return searchModel.results; + } + }, function (err) { console.log("find in files failed: ", err); - StatusBar.hideBusyIndicator(); PerfUtils.finalizeMeasurement(perfTimer); + + // In jQuery promises, returning the error here propagates the rejection, + // unlike in Promises/A, where we would need to re-throw it to do so. + return err; }); } /** * @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. + * Clears any previous search information, removing update listeners and clearing the model. + * @param {?Entry} scope Project file/subfolder to search within; else searches whole project. */ - function _doFindInFiles(scope) { - // If the scope is a file with a custom viewer, then we - // don't show find in files dialog. - if (scope && EditorManager.getCustomViewerForPath(scope.fullPath)) { - return; - } - - if (scope instanceof InMemoryFile) { - CommandManager.execute(Commands.FILE_OPEN, { fullPath: scope.fullPath }).done(function () { - CommandManager.execute(Commands.CMD_FIND); - }); - return; - } - - // Default to searching for the current selection - var currentEditor = EditorManager.getActiveEditor(), - initialString = currentEditor && currentEditor.getSelectedText(); - - if (findBar && !findBar.isClosed()) { - // The modalBar was already up. When creating the new modalBar, copy the - // current query instead of using the passed-in selected text. - initialString = findBar.getQueryInfo().query; - } - - // 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; - } - - searchResults = {}; - currentStart = 0; - currentQuery = null; - currentQueryExpr = null; - currentScope = scope; - maxHitsFoundInFile = false; - exports._searchResults = null; // for unit tests - - // Close our previous find bar, if any. (The open() of the new findBar will - // take care of closing any other find bar instances.) - if (findBar) { - findBar.close(); - } - - findBar = new FindBar({ - navigator: false, - replace: false, - initialQuery: initialString, - queryPlaceholder: Strings.CMD_FIND_IN_SUBTREE, - scopeLabel: _labelForScope(scope) - }); - findBar.open(); - - // TODO Should push this state into ModalBar (via a FindBar API) instead of installing a callback like this. - // Custom closing behavior: if in the middle of executing search, blur shouldn't close ModalBar yet. And - // don't close bar when opening Edit Filter dialog either. - findBar._modalBar.isLockedOpen = function () { - // TODO: should have state for whether the search is executing instead of looking at find bar state - // TODO: should have API on filterPicker to figure out if dialog is open - return !findBar.isEnabled() || $(".modal.instance .exclusions-editor").length > 0; - }; - - var candidateFilesPromise = getCandidateFiles(), // used for eventual search, and in exclusions editor UI - filterPicker; - - function handleQueryChange() { - // Check the query expression on every input event. This way the user is alerted - // to any RegEx syntax errors immediately. - var queryInfo = findBar && findBar.getQueryInfo(), - query = _getQueryRegExp(queryInfo); - - // Indicate that there's an error if the query isn't blank and it's an invalid regexp. - findBar.showNoResults(queryInfo.query && query === null, false); - } - - $(findBar) - .on("doFind.FindInFiles", function (e) { - var queryInfo = findBar && findBar.getQueryInfo(); - if (queryInfo && queryInfo.query) { - findBar.enable(false); - StatusBar.showBusyIndicator(true); - - if (filterPicker) { - currentFilter = FileFilters.commitPicker(filterPicker); - } else { - // Single-file scope: don't use any file filters - currentFilter = null; - } - _doSearch(queryInfo, candidateFilesPromise); - } - }) - .on("queryChange.FindInFiles", handleQueryChange) - .on("close.FindInFiles", function (e) { - $(findBar).off(".FindInFiles"); - findBar = null; - }); - - // Show file-exclusion UI *unless* search scope is just a single file - if (!scope || scope.isDirectory) { - var exclusionsContext = { - label: _labelForScope(scope), - promise: candidateFilesPromise - }; - - filterPicker = FileFilters.createFilterPicker(exclusionsContext); - // TODO: include in FindBar? (and disable it when FindBar is disabled) - findBar._modalBar.getRoot().find("#find-group").append(filterPicker); + function clearSearch() { + _removeListeners(); + searchModel.clear(); + } + + /** + * Does a search in the given scope with the given filter. Used when you want to start a search + * programmatically. + * @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo Query info object + * @param {?Entry} scope Project file/subfolder to search within; else searches whole project. + * @param {?string} filter A "compiled" filter as returned by FileFilters.compile(), or null for no filter + * @param {?string} replaceText If this is a replacement, the text to replace matches with. + * @param {?$.Promise} candidateFilesPromise If specified, a promise that should resolve with the same set of files that + * getCandidateFiles(scope) would return. + * @return {$.Promise} A promise that's resolved with the search results or rejected when the find competes. + */ + function doSearchInScope(queryInfo, scope, filter, replaceText, candidateFilesPromise) { + clearSearch(); + searchModel.scope = scope; + if (replaceText !== undefined) { + searchModel.isReplace = true; + searchModel.replaceText = replaceText; } - - handleQueryChange(); + candidateFilesPromise = candidateFilesPromise || getCandidateFiles(scope); + return _doSearch(queryInfo, candidateFilesPromise, filter); } /** - * @private - * Search within the file/subtree defined by the sidebar selection + * Given a set of search results, replaces them with the given replaceText, either on disk or in memory. + * @param {Object., collapsed: boolean}>} results + * The list of results to replace, as returned from _doSearch.. + * @param {string} replaceText The text to replace each result with. + * @param {?Object} options An options object: + * forceFilesOpen: boolean - Whether to open all files in editors and do replacements there rather than doing the + * replacements on disk. Note that even if this is false, files that are already open in editors will have replacements + * done in memory. + * isRegexp: boolean - Whether the original query was a regexp. If true, $-substitution is performed on the replaceText. + * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an array of errors + * if there were one or more errors. Each individual item in the array will be a {item: string, error: string} object, + * where item is the full path to the file that could not be updated, and error is either a FileSystem error or one + * of the `FindInFiles.ERROR_*` constants. */ - function _doFindInSubtree() { - var selectedEntry = ProjectManager.getSelectedItem(); - _doFindInFiles(selectedEntry); + function doReplace(results, replaceText, options) { + return FindUtils.performReplacements(results, replaceText, options).always(function () { + // For UI integration testing only + exports._replaceDone = true; + }); } /** @@ -1020,26 +476,23 @@ define(function (require, exports, module) { * @param {string} oldName * @param {string} newName */ - function _fileNameChangeHandler(event, oldName, newName) { + _fileNameChangeHandler = function (event, oldName, newName) { var resultsChanged = false; - if (searchResultsPanel.isVisible()) { - // Update the search results - _.forEach(searchResults, function (item, fullPath) { - if (fullPath.match(oldName)) { - searchResults[fullPath.replace(oldName, newName)] = item; - delete searchResults[fullPath]; - resultsChanged = true; - } - }); - - // Restore the results if needed - if (resultsChanged) { - _sortResultFiles(); - _restoreSearchResults(); + // Update the search results + _.forEach(searchModel.results, function (item, fullPath) { + if (fullPath.match(oldName)) { + searchModel.results[fullPath.replace(oldName, newName)] = item; + delete searchModel.results[fullPath]; + resultsChanged = true; } + }); + + // Restore the results if needed + if (resultsChanged) { + searchModel.fireChanged(); } - } + }; /** * @private @@ -1057,9 +510,9 @@ define(function (require, exports, module) { * @param {(File|Directory)} entry */ function _removeSearchResultsForEntry(entry) { - Object.keys(searchResults).forEach(function (fullPath) { + Object.keys(searchModel.results).forEach(function (fullPath) { if (fullPath.indexOf(entry.fullPath) === 0) { - delete searchResults[fullPath]; + delete searchModel.results[fullPath]; resultsChanged = true; } }); @@ -1136,33 +589,16 @@ define(function (require, exports, module) { addPromise.always(function () { // Restore the results if needed if (resultsChanged) { - _sortResultFiles(); - _restoreSearchResults(); + searchModel.fireChanged(); } }); }; - - // 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"); - }); - - // Initialize: register listeners - $(DocumentManager).on("fileNameChange", _fileNameChangeHandler); - $(ProjectManager).on("beforeProjectClose", _hideSearchResults); - - // Initialize: command handlers - CommandManager.register(Strings.CMD_FIND_IN_FILES, Commands.CMD_FIND_IN_FILES, _doFindInFiles); - CommandManager.register(Strings.CMD_FIND_IN_SELECTED, Commands.CMD_FIND_IN_SELECTED, _doFindInSubtree); - CommandManager.register(Strings.CMD_FIND_IN_SUBTREE, Commands.CMD_FIND_IN_SUBTREE, _doFindInSubtree); - - // For unit testing - exports._doFindInFiles = _doFindInFiles; - exports._searchResults = null; + // Public exports + exports.searchModel = searchModel; + exports.doSearchInScope = doSearchInScope; + exports.doReplace = doReplace; + exports.getCandidateFiles = getCandidateFiles; + exports.clearSearch = clearSearch; + exports.ZERO_FILES_TO_SEARCH = ZERO_FILES_TO_SEARCH; }); diff --git a/src/search/FindInFilesUI.js b/src/search/FindInFilesUI.js new file mode 100644 index 00000000000..4a8da60714c --- /dev/null +++ b/src/search/FindInFilesUI.js @@ -0,0 +1,400 @@ +/* + * 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. + * + */ + +/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ +/*global define, $, window, Mustache */ + +/* + * 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. + * + * FUTURE: + * - Search files in working set that are *not* in the project + * - Handle matches that span multiple lines + */ +define(function (require, exports, module) { + "use strict"; + + var AppInit = require("utils/AppInit"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + Dialogs = require("widgets/Dialogs"), + DefaultDialogs = require("widgets/DefaultDialogs"), + EditorManager = require("editor/EditorManager"), + FileFilters = require("search/FileFilters"), + FindBar = require("search/FindBar").FindBar, + FindInFiles = require("search/FindInFiles"), + FindUtils = require("search/FindUtils"), + InMemoryFile = require("document/InMemoryFile"), + ProjectManager = require("project/ProjectManager"), + SearchResultsView = require("search/SearchResultsView").SearchResultsView, + StatusBar = require("widgets/StatusBar"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"), + _ = require("thirdparty/lodash"); + + + /** @const Maximum number of files to do replacements in-memory instead of on disk. */ + var MAX_IN_MEMORY = 20; + + /** @type {SearchResultsView} The results view. Initialized in htmlReady() */ + var _resultsView = null; + + /** @type {FindBar} Find bar containing the search UI. */ + var _findBar = null; + + /** + * Does a search in the given scope with the given filter. Shows the result list once the search is complete. + * @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo Query info object + * @param {?Entry} scope Project file/subfolder to search within; else searches whole project. + * @param {?string} filter A "compiled" filter as returned by FileFilters.compile(), or null for no filter + * @param {?string} replaceText If this is a replacement, the text to replace matches with. + * @param {?$.Promise} candidateFilesPromise If specified, a promise that should resolve with the same set of files that + * getCandidateFiles(scope) would return. + * @return {$.Promise} A promise that's resolved with the search results or rejected when the find competes. + */ + function searchAndShowResults(queryInfo, scope, filter, replaceText, candidateFilesPromise) { + FindInFiles.doSearchInScope(queryInfo, scope, filter, replaceText, candidateFilesPromise) + .done(function (zeroFilesToken) { + // Done searching all files: show results + if (FindInFiles.searchModel.hasResults()) { + _resultsView.open(); + + if (_findBar) { + _findBar.close(); + } + + } else { + _resultsView.close(); + + if (_findBar) { + var showMessage = false; + _findBar.enable(true); + _findBar.focusQuery(); + if (zeroFilesToken === FindInFiles.ZERO_FILES_TO_SEARCH) { + _findBar.showError(StringUtils.format(Strings.FIND_IN_FILES_ZERO_FILES, FindUtils.labelForScope(FindInFiles.searchModel.scope)), true); + } else { + showMessage = true; + } + _findBar.showNoResults(true, showMessage); + } + } + + StatusBar.hideBusyIndicator(); + }) + .fail(function (err) { + console.log("find in files failed: ", err); + StatusBar.hideBusyIndicator(); + }); + } + + /** + * @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. + * @param {boolean=} showReplace If true, show the Replace controls. + */ + function _showFindBar(scope, showReplace) { + // If the scope is a file with a custom viewer, then we + // don't show find in files dialog. + if (scope && EditorManager.getCustomViewerForPath(scope.fullPath)) { + return; + } + + if (scope instanceof InMemoryFile) { + CommandManager.execute(Commands.FILE_OPEN, { fullPath: scope.fullPath }).done(function () { + CommandManager.execute(Commands.CMD_FIND); + }); + return; + } + + // Default to searching for the current selection + var currentEditor = EditorManager.getActiveEditor(), + initialString = currentEditor && currentEditor.getSelectedText(); + + if (_findBar && !_findBar.isClosed()) { + // The modalBar was already up. When creating the new modalBar, copy the + // current query instead of using the passed-in selected text. + initialString = _findBar.getQueryInfo().query; + } + + FindInFiles.clearSearch(); + + // Close our previous find bar, if any. (The open() of the new _findBar will + // take care of closing any other find bar instances.) + if (_findBar) { + _findBar.close(); + } + + _findBar = new FindBar({ + navigator: false, + replace: showReplace, + replaceAllOnly: showReplace, + scope: true, + initialQuery: initialString, + queryPlaceholder: (showReplace ? Strings.CMD_REPLACE_IN_SUBTREE : Strings.CMD_FIND_IN_SUBTREE), + scopeLabel: FindUtils.labelForScope(scope) + }); + _findBar.open(); + + // TODO Should push this state into ModalBar (via a FindBar API) instead of installing a callback like this. + // Custom closing behavior: if in the middle of executing search, blur shouldn't close ModalBar yet. And + // don't close bar when opening Edit Filter dialog either. + _findBar._modalBar.isLockedOpen = function () { + // TODO: should have state for whether the search is executing instead of looking at find bar state + // TODO: should have API on filterPicker to figure out if dialog is open + return !_findBar.isEnabled() || $(".modal.instance .exclusions-editor").length > 0; + }; + + var candidateFilesPromise = FindInFiles.getCandidateFiles(scope), // used for eventual search, and in exclusions editor UI + filterPicker; + + function handleQueryChange() { + // Check the query expression on every input event. This way the user is alerted + // to any RegEx syntax errors immediately. + if (_findBar) { + var queryInfo = _findBar.getQueryInfo(), + queryResult = FindInFiles.searchModel.setQueryInfo(queryInfo); + + // Enable the replace button appropriately. + _findBar.enableReplace(queryResult.valid); + + if (queryResult.valid || queryResult.empty) { + _findBar.showNoResults(false); + _findBar.showError(null); + } else { + _findBar.showNoResults(true, false); + _findBar.showError(queryResult.error); + } + } + } + + function startSearch(replaceText) { + var queryInfo = _findBar && _findBar.getQueryInfo(); + if (queryInfo && queryInfo.query) { + _findBar.enable(false); + StatusBar.showBusyIndicator(true); + + var filter; + if (filterPicker) { + filter = FileFilters.commitPicker(filterPicker); + } else { + // Single-file scope: don't use any file filters + filter = null; + } + searchAndShowResults(queryInfo, scope, filter, replaceText, candidateFilesPromise); + } + return null; + } + + function startReplace() { + startSearch(_findBar.getReplaceText()); + } + + $(_findBar) + .on("doFind.FindInFiles", function (e, shiftKey, replace) { + // If in Replace mode, just set focus to the Replace field. + if (replace) { + startReplace(); + } else if (showReplace) { + _findBar.focusReplace(); + } else { + startSearch(); + } + }) + .on("queryChange.FindInFiles", handleQueryChange) + .on("close.FindInFiles", function (e) { + $(_findBar).off(".FindInFiles"); + _findBar = null; + }); + + if (showReplace) { + $(_findBar).on("doReplace.FindInFiles", function (e, all) { + startReplace(); + }); + } + + // Show file-exclusion UI *unless* search scope is just a single file + if (!scope || scope.isDirectory) { + var exclusionsContext = { + label: FindUtils.labelForScope(scope), + promise: candidateFilesPromise + }; + + filterPicker = FileFilters.createFilterPicker(exclusionsContext); + // TODO: include in FindBar? (and disable it when FindBar is disabled) + _findBar._modalBar.getRoot().find(".scope-group").append(filterPicker); + } + + handleQueryChange(); + } + + /** + * @private + * Finish a replace across files operation when the user clicks "Replace" on the results panel. + * @param {SearchModel} model The model for the search associated with ths replace. + */ + function _finishReplaceAll(model) { + var replaceText = model.replaceText; + if (replaceText === null) { + return; + } + + // Clone the search results so that they don't get updated in the middle of the replacement. + var resultsClone = _.cloneDeep(model.results), + replacedFiles = _.filter(Object.keys(resultsClone), function (path) { + return FindUtils.hasCheckedMatches(resultsClone[path]); + }), + isRegexp = model.queryInfo.isRegexp, + replacePromise; + + function processReplace(forceFilesOpen) { + StatusBar.showBusyIndicator(true); + FindInFiles.doReplace(resultsClone, replaceText, { forceFilesOpen: forceFilesOpen, isRegexp: isRegexp }) + .fail(function (errors) { + var message = Strings.REPLACE_IN_FILES_ERRORS + StringUtils.makeDialogFileList( + _.map(errors, function (errorInfo) { + return ProjectManager.makeProjectRelativeIfPossible(errorInfo.item); + }) + ); + + Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_ERROR, + Strings.REPLACE_IN_FILES_ERRORS_TITLE, + message, + [ + { + className : Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id : Dialogs.DIALOG_BTN_OK, + text : Strings.BUTTON_REPLACE_WITHOUT_UNDO + } + ] + ); + }) + .always(function () { + StatusBar.hideBusyIndicator(); + }); + } + + if (replacedFiles.length <= MAX_IN_MEMORY) { + // Just do the replacements in memory. + _resultsView.close(); + processReplace(true); + } else { + Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_INFO, + Strings.REPLACE_WITHOUT_UNDO_WARNING_TITLE, + StringUtils.format(Strings.REPLACE_WITHOUT_UNDO_WARNING, MAX_IN_MEMORY), + [ + { + className : Dialogs.DIALOG_BTN_CLASS_NORMAL, + id : Dialogs.DIALOG_BTN_CANCEL, + text : Strings.CANCEL + }, + { + className : Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id : Dialogs.DIALOG_BTN_OK, + text : Strings.BUTTON_REPLACE_WITHOUT_UNDO + } + ] + ).done(function (id) { + if (id === Dialogs.DIALOG_BTN_OK) { + _resultsView.close(); + processReplace(false); + } + }); + } + } + + // Command handlers + + /** + * @private + * Bring up the Find in Files UI with the replace options. + */ + function _showReplaceBar() { + _showFindBar(null, true); + } + + /** + * @private + * Search within the file/subtree defined by the sidebar selection + */ + function _showFindBarForSubtree() { + var selectedEntry = ProjectManager.getSelectedItem(); + _showFindBar(selectedEntry); + } + + /** + * @private + * Search within the file/subtree defined by the sidebar selection + */ + function _showReplaceBarForSubtree() { + var selectedEntry = ProjectManager.getSelectedItem(); + _showFindBar(selectedEntry, true); + } + + /** + * @private + * Close the open search bar, if any. For unit tests. + */ + function _closeFindBar() { + if (_findBar) { + _findBar.close(); + } + } + + // Initialize items dependent on HTML DOM + AppInit.htmlReady(function () { + var model = FindInFiles.searchModel; + _resultsView = new SearchResultsView(model, "find-in-files-results", "find-in-files.results"); + $(_resultsView) + .on("doReplaceAll", function () { + _finishReplaceAll(model); + }) + .on("close", function () { + FindInFiles.clearSearch(); + }); + }); + + // Initialize: register listeners + $(ProjectManager).on("beforeProjectClose", function () { _resultsView.close(); }); + + // Initialize: command handlers + CommandManager.register(Strings.CMD_FIND_IN_FILES, Commands.CMD_FIND_IN_FILES, _showFindBar); + CommandManager.register(Strings.CMD_REPLACE_IN_FILES, Commands.CMD_REPLACE_IN_FILES, _showReplaceBar); + CommandManager.register(Strings.CMD_FIND_IN_SELECTED, Commands.CMD_FIND_IN_SELECTED, _showFindBarForSubtree); + CommandManager.register(Strings.CMD_REPLACE_IN_SELECTED, Commands.CMD_REPLACE_IN_SELECTED, _showReplaceBarForSubtree); + CommandManager.register(Strings.CMD_FIND_IN_SUBTREE, Commands.CMD_FIND_IN_SUBTREE, _showFindBarForSubtree); + CommandManager.register(Strings.CMD_REPLACE_IN_SUBTREE, Commands.CMD_REPLACE_IN_SUBTREE, _showReplaceBarForSubtree); + + // Public exports + exports.searchAndShowResults = searchAndShowResults; + + // For unit testing + exports._showFindBar = _showFindBar; + exports._closeFindBar = _closeFindBar; +}); \ No newline at end of file diff --git a/src/search/FindReplace.js b/src/search/FindReplace.js index ababe7d6edd..e60fe393a7a 100644 --- a/src/search/FindReplace.js +++ b/src/search/FindReplace.js @@ -38,11 +38,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"), FindBar = require("search/FindBar").FindBar, + FindUtils = require("search/FindUtils"), + FindInFilesUI = require("search/FindInFilesUI"), ScrollTrackMarkers = require("search/ScrollTrackMarkers"), PanelManager = require("view/PanelManager"), Resizer = require("utils/Resizer"), @@ -51,9 +54,6 @@ define(function (require, exports, module) { _ = require("thirdparty/lodash"), CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"); - var searchReplacePanelTemplate = require("text!htmlContent/search-replace-panel.html"), - searchReplaceResultsTemplate = require("text!htmlContent/search-replace-results.html"); - /** @const Maximum file size to search within (in chars) */ var FIND_MAX_FILE_SIZE = 500000; @@ -61,27 +61,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 {!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 {?FindBar} Currently open Find or Find/Replace bar, if any */ var findBar; - /** @type {!function():void} API from FindInFiles for closing its conflicting search bar, if open */ - var closeFindInFilesBar; - function SearchState() { this.searchStartPos = null; this.queryInfo = null; @@ -142,24 +129,6 @@ define(function (require, exports, module) { } } - // NOTE: we can't just use the ordinary replace() function here because the string has been - // extracted from the original text and so might be missing some context that the regexp matched. - function parseDollars(replaceWith, match) { - replaceWith = replaceWith.replace(/(\$+)(\d{1,2}|&)/g, function (whole, dollars, index) { - var parsedIndex = parseInt(index, 10); - if (dollars.length % 2 === 1) { // check if dollar signs escape themselves (for example $$1, $$$$&) - if (index === "&") { // handle $& - return dollars.substr(1) + (match[0] || ""); - } else if (parsedIndex !== 0) { // handle $n or $nn, don't handle $0 or $00 - return dollars.substr(1) + (match[parsedIndex] || ""); - } - } - return whole; - }); - replaceWith = replaceWith.replace(/\$\$/g, "$"); // replace escaped dollar signs (for example $$) with single ones - return replaceWith; - } - /** * @private * Returns the next match for the current query (from the search state) before/after the given position. Wraps around @@ -637,17 +606,6 @@ define(function (require, exports, module) { openSearchBar(editor, false); } - /** - * @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 @@ -658,105 +616,6 @@ define(function (require, exports, module) { if (findBar) { findBar.close(); } - _closeReplaceAllPanel(); - } - - /** - * @private - * Shows a panel with search results and offers to replace them, - * user can use checkboxes to select which results he wishes to replace. - * @param {Editor} editor - Currently active editor that was used to invoke this action. - * @param {string|RegExp} replaceWhat - Query that will be passed into CodeMirror Cursor to search for results. - * @param {string} replaceWith - String that should be used to replace chosen results. - */ - function _showReplaceAllPanel(editor, replaceWhat, queryInfo, replaceWith) { - var results = [], - cm = editor._codeMirror, - cursor = getSearchCursor(cm, replaceWhat, queryInfo), - 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 } function doReplace(editor, all) { @@ -766,9 +625,10 @@ define(function (require, exports, module) { if (all) { findBar.close(); - _showReplaceAllPanel(editor, state.query, state.queryInfo, replaceText); + // Delegate to Replace in Files. + FindInFilesUI.searchAndShowResults(state.queryInfo, editor.document.file, null, replaceText); } else { - cm.replaceSelection(typeof state.query === "string" ? replaceText : parseDollars(replaceText, state.lastMatch)); + cm.replaceSelection(typeof state.query === "string" ? replaceText : FindUtils.parseDollars(replaceText, state.lastMatch)); updateResultSet(editor); // we updated the text, so result count & tickmarks must be refreshed @@ -823,28 +683,7 @@ define(function (require, exports, module) { replace(editor); } } - - // 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); - }); - }); - + $(DocumentManager).on("currentDocumentChange", _handleDocumentChange); CommandManager.register(Strings.CMD_FIND, Commands.CMD_FIND, _launchFind); diff --git a/src/search/FindUtils.js b/src/search/FindUtils.js new file mode 100644 index 00000000000..2f2431bef34 --- /dev/null +++ b/src/search/FindUtils.js @@ -0,0 +1,246 @@ +/* + * 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. + * + */ + +/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ +/*global define, $ */ + +define(function (require, exports, module) { + "use strict"; + + var Async = require("utils/Async"), + DocumentManager = require("document/DocumentManager"), + FileSystem = require("filesystem/FileSystem"), + FileUtils = require("file/FileUtils"), + ProjectManager = require("project/ProjectManager"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"), + CodeMirror = require("thirdparty/CodeMirror2/lib/codemirror"), + _ = require("thirdparty/lodash"); + + /** + * Given a replace string that contains $-expressions, replace them with data from the given + * regexp match info. + * NOTE: we can't just use the ordinary replace() function here because the string has been + * extracted from the original text and so might be missing some context that the regexp matched. + * @param {string} replaceWith The string containing the $-expressions. + * @param {Object} match The match data from the regexp. + * @return {string} The replace text with the $-expressions substituted. + */ + function parseDollars(replaceWith, match) { + replaceWith = replaceWith.replace(/(\$+)(\d{1,2}|&)/g, function (whole, dollars, index) { + var parsedIndex = parseInt(index, 10); + if (dollars.length % 2 === 1) { // check if dollar signs escape themselves (for example $$1, $$$$&) + if (index === "&") { // handle $& + return dollars.substr(1) + (match[0] || ""); + } else if (parsedIndex !== 0) { // handle $n or $nn, don't handle $0 or $00 + return dollars.substr(1) + (match[parsedIndex] || ""); + } + } + return whole; + }); + replaceWith = replaceWith.replace(/\$\$/g, "$"); // replace escaped dollar signs (for example $$) with single ones + return replaceWith; + } + + /** + * Does a set of replacements in a single document in memory. + * @param {!Document} doc The document to do the replacements in. + * @param {Object} matchInfo The match info for this file, as returned by `_addSearchMatches()`. Might be mutated. + * @param {string} replaceText The text to replace each result with. + * @param {boolean=} isRegexp Whether the original query was a regexp. + * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an error if there were one or more errors. + */ + function _doReplaceInDocument(doc, matchInfo, replaceText, isRegexp) { + // Double-check that the open document's timestamp matches the one we recorded. This + // should normally never go out of sync, because if it did we wouldn't start the + // replace in the first place, but we want to double-check. This will *not* handle + // cases where the document has been edited in memory since the matchInfo was generated. + if (doc.diskTimestamp.getTime() !== matchInfo.timestamp.getTime()) { + return new $.Deferred().reject(exports.ERROR_FILE_CHANGED).promise(); + } + + // Do the replacements in reverse document order so the offsets continue to be correct. + doc.batchOperation(function () { + matchInfo.matches.reverse().forEach(function (match) { + if (match.isChecked) { + doc.replaceRange(isRegexp ? parseDollars(replaceText, match.result) : replaceText, match.start, match.end); + } + }); + }); + + return new $.Deferred().resolve().promise(); + } + + /** + * Does a set of replacements in a single file on disk. + * @param {string} fullPath The full path to the file. + * @param {Object} matchInfo The match info for this file, as returned by `_addSearchMatches()`. + * @param {string} replaceText The text to replace each result with. + * @param {boolean=} isRegexp Whether the original query was a regexp. + * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an error if there were one or more errors. + */ + function _doReplaceOnDisk(fullPath, matchInfo, replaceText, isRegexp) { + var file = FileSystem.getFileForPath(fullPath); + return DocumentManager.getDocumentText(file, true).then(function (contents, timestamp, lineEndings) { + if (timestamp.getTime() !== matchInfo.timestamp.getTime()) { + // Return a promise that we'll reject immediately. (We can't just return the + // error since this is the success handler.) + return new $.Deferred().reject(exports.ERROR_FILE_CHANGED).promise(); + } + + // Note that this assumes that the matches are sorted. + // TODO: is there a more efficient way to do this in a large string? + var result = [], + lastIndex = 0; + matchInfo.matches.forEach(function (match) { + if (match.isChecked) { + result.push(contents.slice(lastIndex, match.startOffset)); + result.push(isRegexp ? parseDollars(replaceText, match.result) : replaceText); + lastIndex = match.endOffset; + } + }); + result.push(contents.slice(lastIndex)); + + var newContents = result.join(""); + // TODO: duplicated logic from Document - should refactor this? + if (lineEndings === FileUtils.LINE_ENDINGS_CRLF) { + newContents = newContents.replace(/\n/g, "\r\n"); + } + + return Async.promisify(file, "write", newContents); + }); + } + + /** + * Does a set of replacements in a single file. If the file is already open in a Document in memory, + * will do the replacement there, otherwise does it directly on disk. + * @param {string} fullPath The full path to the file. + * @param {Object} matchInfo The match info for this file, as returned by `_addSearchMatches()`. + * @param {string} replaceText The text to replace each result with. + * @param {Object=} options An options object: + * forceFilesOpen: boolean - Whether to open the file in an editor and do replacements there rather than doing the + * replacements on disk. Note that even if this is false, files that are already open in editors will have replacements + * done in memory. + * isRegexp: boolean - Whether the original query was a regexp. If true, $-substitution is performed on the replaceText. + * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an error if there were one or more errors. + */ + function _doReplaceInOneFile(fullPath, matchInfo, replaceText, options) { + var doc = DocumentManager.getOpenDocumentForPath(fullPath); + options = options || {}; + if (options.forceFilesOpen && !doc) { + return DocumentManager.getDocumentForPath(fullPath).then(function (newDoc) { + return _doReplaceInDocument(newDoc, matchInfo, replaceText, options.isRegexp); + }); + } else if (doc) { + return _doReplaceInDocument(doc, matchInfo, replaceText, options.isRegexp); + } else { + return _doReplaceOnDisk(fullPath, matchInfo, replaceText, options.isRegexp); + } + } + + /** + * @private + * Returns true if a search result has any checked matches. + */ + function hasCheckedMatches(result) { + return result.matches.some(function (match) { return match.isChecked; }); + } + + /** + * Given a set of search results, replaces them with the given replaceText, either on disk or in memory. + * Checks timestamps to ensure replacements are not performed in files that have changed on disk since + * the original search results were generated. However, does *not* check whether edits have been performed + * in in-memory documents since the search; it's up to the caller to guarantee this hasn't happened. + * + * Replacements in documents that are already open in memory at the start of the replacement are guaranteed to + * happen synchronously; replacements in files on disk will return an error if the on-disk file changes between + * the time performReplacements() is called and the time the replacement actually happens. + * + * @param {Object., collapsed: boolean}>} results + * The list of results to replace, as returned from _doSearch.. + * @param {string} replaceText The text to replace each result with. + * @param {?Object} options An options object: + * forceFilesOpen: boolean - Whether to open all files in editors and do replacements there rather than doing the + * replacements on disk. Note that even if this is false, files that are already open in editors will have replacements + * done in memory. + * isRegexp: boolean - Whether the original query was a regexp. If true, $-substitution is performed on the replaceText. + * @return {$.Promise} A promise that's resolved when the replacement is finished or rejected with an array of errors + * if there were one or more errors. Each individual item in the array will be a {item: string, error: string} object, + * where item is the full path to the file that could not be updated, and error is either a FileSystem error or one + * of the `FindUtils.ERROR_*` constants. + */ + function performReplacements(results, replaceText, options) { + return Async.doInParallel_aggregateErrors(Object.keys(results), function (fullPath) { + return _doReplaceInOneFile(fullPath, results[fullPath], replaceText, options); + }).done(function () { + if (options && options.forceFilesOpen) { + // If the currently selected document wasn't modified by the search, or there is no open document, + // then open the first modified document. + var doc = DocumentManager.getCurrentDocument(); + if (!doc || + !results[doc.file.fullPath] || + !hasCheckedMatches(results[doc.file.fullPath])) { + // Figure out the first modified document. This logic is slightly different from + // SearchResultsView._getSortedFiles() because it doesn't sort the currently open file to + // the top. But if the currently open file were in the search results, we wouldn't be + // doing this anyway. + var sortedPaths = Object.keys(results).sort(FileUtils.comparePaths), + firstPath = _.find(sortedPaths, function (path) { + return hasCheckedMatches(results[path]); + }); + + if (firstPath) { + var newDoc = DocumentManager.getOpenDocumentForPath(firstPath); + if (newDoc) { + DocumentManager.setCurrentDocument(newDoc); + } + } + } + } + }); + } + + /** + * Returns label text to indicate the search scope. Already HTML-escaped. + * @param {?Entry} scope + * @return {string} + */ + function labelForScope(scope) { + if (scope) { + return StringUtils.format( + Strings.FIND_IN_FILES_SCOPED, + StringUtils.breakableUrl( + ProjectManager.makeProjectRelativeIfPossible(scope.fullPath) + ) + ); + } else { + return Strings.FIND_IN_FILES_NO_SCOPE; + } + } + + exports.parseDollars = parseDollars; + exports.hasCheckedMatches = hasCheckedMatches; + exports.performReplacements = performReplacements; + exports.labelForScope = labelForScope; + exports.ERROR_FILE_CHANGED = "fileChanged"; +}); diff --git a/src/search/SearchModel.js b/src/search/SearchModel.js new file mode 100644 index 00000000000..e9aa42d6687 --- /dev/null +++ b/src/search/SearchModel.js @@ -0,0 +1,231 @@ +/* + * 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, $ */ + +define(function (require, exports, module) { + "use strict"; + + var _ = require("thirdparty/lodash"), + FileUtils = require("file/FileUtils"), + StringUtils = require("utils/StringUtils"); + + /** + * @constructor + * Manages a set of search query and result data. + * Dispatches these events: + * "change" - whenever the results have been updated. Note that it's up to people who + * edit the model to call fireChange() when necessary - it doesn't automatically fire. + */ + function SearchModel() { + this.clear(); + } + + /** @const Constant used to define the maximum results found. + * Note that this is a soft limit - we'll likely go slightly over it since + * we always add all the searches in a given file. + */ + SearchModel.MAX_TOTAL_RESULTS = 100000; + + /** + * The current set of results. + * @type {Object., collapsed: boolean, timestamp: Date}>} + */ + SearchModel.prototype.results = null; + + /** + * The query that generated these results. + * @type {{query: string, caseSensitive: boolean, isRegexp: boolean}} + */ + SearchModel.prototype.queryInfo = null; + + /** + * The compiled query, expressed as a regexp. + * @type {RegExp} + */ + SearchModel.prototype.queryExpr = null; + + /** + * Whether this is a find/replace query. + * @type {boolean} + */ + SearchModel.prototype.isReplace = false; + + /** + * The replacement text specified for this query, if any. + * @type {string} + */ + SearchModel.prototype.replaceText = null; + + /** + * The file/folder path representing the scope that this query was performed in. + * @type {string} + */ + SearchModel.prototype.scope = null; + + /** + * A file filter (as returned from FileFilters) to apply within the main scope. + * @type {string} + */ + SearchModel.prototype.filter = null; + + /** + * Whether or not we hit the maximum number of results for the type of search we did. + * @type {boolean} + */ + SearchModel.prototype.foundMaximum = false; + + /** + * Clears out the model to an empty state. + */ + SearchModel.prototype.clear = function () { + this.results = {}; + this.queryInfo = null; + this.queryExpr = null; + this.isReplace = false; + this.replaceText = null; + this.scope = null; + this.foundMaximum = false; + }; + + /** + * Sets the given query info and stores a compiled RegExp query in this.queryExpr. Returns info on whether the + * query was valid or not. If the query is invalid, then this.queryExpr will be null. + * @param {{query: string, caseSensitive: boolean, isRegexp: boolean}} queryInfo + * @return {{valid: boolean, empty: boolean, error: string}} + * valid - set to true if query is a nonempty string or a valid regexp. + * empty - set to true if query was empty. + * error - set to an error string if valid is false and query is nonempty. + */ + SearchModel.prototype.setQueryInfo = function (queryInfo) { + this.queryInfo = queryInfo; + this.queryExpr = null; + + // TODO: only apparent difference between this one and the one in FindReplace is that this one returns + // null instead of "" for a bad query, and this always returns a regexp even for simple strings. Reconcile. + if (!queryInfo || !queryInfo.query) { + return {empty: true}; + } + + // For now, treat all matches as multiline (i.e. ^/$ match on every line, not the whole + // document). This is consistent with how single-file find works. Eventually we should add + // an option for this. + var flags = "gm"; + if (!queryInfo.isCaseSensitive) { + flags += "i"; + } + + // Is it a (non-blank) regex? + if (queryInfo.isRegexp) { + try { + this.queryExpr = new RegExp(queryInfo.query, flags); + } catch (e) { + return {valid: false, error: e.message}; + } + } else { + // Query is a plain string. Turn it into a regexp + this.queryExpr = new RegExp(StringUtils.regexEscape(queryInfo.query), flags); + } + return {valid: true}; + }; + + /** + * Adds the given result matches to the search results + * @param {string} fullpath + * @param {Array.} matches + * @return true if at least some matches were added, false if we've hit the limit on how many can be added + */ + SearchModel.prototype.addResultMatches = function (fullpath, matches, timestamp) { + if (this.foundMaximum) { + return false; + } + + this.results[fullpath] = { + matches: matches, + collapsed: false, + timestamp: timestamp + }; + + var curNumMatches = this.countFilesMatches().matches; + if (curNumMatches >= SearchModel.MAX_TOTAL_RESULTS) { + this.foundMaximum = true; + } + + return true; + }; + + /** + * @return true if there are any results in this model. + */ + SearchModel.prototype.hasResults = function () { + return Object.keys(this.results).length > 0; + }; + + /** + * Counts the total number of matches and files + * @return {{files: number, matches: number}} + */ + SearchModel.prototype.countFilesMatches = function () { + var numFiles = 0, numMatches = 0; + _.forEach(this.results, function (item) { + numFiles++; + numMatches += item.matches.length; + }); + + return {files: numFiles, matches: numMatches}; + }; + + /** + * Sorts the file keys to show the results from the selected file first and the rest sorted by path + * @param {?string} firstFile If specified, the path to the file that should be sorted to the top. + * @return {Array.} + */ + SearchModel.prototype.getSortedFiles = function (firstFile) { + var searchFiles = Object.keys(this.results), + self = this; + + searchFiles.sort(function (key1, key2) { + if (firstFile === key1) { + return -1; + } else if (firstFile === key2) { + return 1; + } + return FileUtils.comparePaths(key1, key2); + }); + + return searchFiles; + }; + + /** + * Notifies listeners that the set of results has changed. Must be called after the + * model is changed. + * @param {boolean} quickChange Whether this type of change is one that might occur + * often, meaning that the view should buffer updates. + */ + SearchModel.prototype.fireChanged = function (quickChange) { + $(this).triggerHandler("change", quickChange); + }; + + // Public API + exports.SearchModel = SearchModel; +}); diff --git a/src/search/SearchResultsView.js b/src/search/SearchResultsView.js new file mode 100644 index 00000000000..e370a0fb93f --- /dev/null +++ b/src/search/SearchResultsView.js @@ -0,0 +1,499 @@ +/* + * 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"), + DocumentManager = require("document/DocumentManager"), + EditorManager = require("editor/EditorManager"), + ProjectManager = require("project/ProjectManager"), + FileViewController = require("project/FileViewController"), + FileUtils = require("file/FileUtils"), + FindUtils = require("search/FindUtils"), + 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"), + searchSummaryTemplate = require("text!htmlContent/search-summary.html"); + + + /** @const Constants used to define the maximum results show per page and found in a single file */ + var RESULTS_PER_PAGE = 100, + UPDATE_TIMEOUT = 400; + + /** + * @constructor + * Handles the search results panel. + * Dispatches the following events: + * replaceAll - when the "Replace" button is clicked. + * close - when the panel is closed. + * + * @param {SearchModel} model The model that this view is showing. + * @param {string} panelID The CSS ID to use for the panel. + * @param {string} panelName The name to use for the panel, as passed to PanelManager.createBottomPanel(). + */ + function SearchResultsView(model, 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._model = model; + } + + /** @type {SearchModel} The search results model we're viewing. */ + SearchResultsView.prototype._model = null; + + /** + * Array with content used in the Results Panel + * @type {Array.<{file: number, filename: string, fullPath: string, items: Array.}>} + */ + SearchResultsView.prototype._searchList = []; + + /** @type {Panel} Bottom panel holding the search results */ + SearchResultsView.prototype._panel = null; + + /** @type {?string} The full path of the file that was open in the main editor on the initial search */ + SearchResultsView.prototype._initialFilePath = null; + + /** @type {number} The index of the first result that is displayed */ + SearchResultsView.prototype._currentStart = 0; + + /** @type {boolean} Used to remake the replace all summary after it is changed */ + SearchResultsView.prototype._allChecked = false; + + /** @type {$.Element} The currently selected row */ + SearchResultsView.prototype._$selectedRow = null; + + /** @type {$.Element} The element where the title is placed */ + SearchResultsView.prototype._$summary = null; + + /** @type {$.Element} The table that holds the results */ + SearchResultsView.prototype._$table = null; + + /** @type {number} The ID we use for timeouts when handling model changes. */ + SearchResultsView.prototype._timeoutID = null; + + /** + * @private + * Handles when model changes. Updates the view, buffering changes if necessary so as not to churn too much. + */ + SearchResultsView.prototype._handleModelChange = function (quickChange) { + // If this is a replace, to avoid complications with updating, just close ourselves if we hear about + // a results model change after we've already shown the results initially. + // TODO: notify user, re-do search in file + if (this._model.isReplace) { + this.close(); + return; + } + + var self = this; + if (this._timeoutID) { + window.clearTimeout(this._timeoutID); + } + if (quickChange) { + this._timeoutID = window.setTimeout(function () { + self._updateResults(); + self._timeoutID = null; + }, UPDATE_TIMEOUT); + } else { + this._updateResults(); + } + }; + + /** + * @private + * Adds the listeners for close, prev, next, first, last and check all + */ + SearchResultsView.prototype._addPanelListeners = function () { + var self = this; + this._panel.$panel + .off(".searchResults") // Remove the old events + .on("click.searchResults", ".close", function () { + self.close(); + }) + // The link to go the first page + .on("click.searchResults", ".first-page:not(.disabled)", function () { + self._currentStart = 0; + self._render(); + }) + // The link to go the previous page + .on("click.searchResults", ".prev-page:not(.disabled)", function () { + self._currentStart -= RESULTS_PER_PAGE; + self._render(); + }) + // The link to go to the next page + .on("click.searchResults", ".next-page:not(.disabled)", function () { + self._currentStart += RESULTS_PER_PAGE; + self._render(); + }) + // The link to go to the last page + .on("click.searchResults", ".last-page:not(.disabled)", function () { + self._currentStart = self._getLastCurrentStart(); + self._render(); + }) + + // 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._model.results[fullPath].collapsed; + + if (e.metaKey || e.ctrlKey) { //Expand all / Collapse all + $titleRows = $(e.target).closest("table").find(".file-section"); + } else { + // Clicking the file section header collapses/expands result rows for that file + $titleRows = $row; + } + + $titleRows.each(function () { + fullPath = self._searchList[$(this).data("file")].fullPath; + searchItem = self._model.results[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._model.results, function (item) { + item.collapsed = collapsed; + }); + } + + // This is a file row, show the result on click + } else { + // Grab the required item data + var item = searchItem.items[$row.data("item")]; + + 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 replace functionality if required + if (this._model.isReplace) { + this._panel.$panel + .on("click.searchResults", ".check-all", function (e) { + var isChecked = $(this).is(":checked"); + _.forEach(self._model.results, function (results) { + results.matches.forEach(function (match) { + match.isChecked = isChecked; + }); + }); + self._$table.find(".check-one").prop("checked", isChecked); + self._allChecked = isChecked; + }) + .on("click.searchResults", ".check-one", function (e) { + var $row = $(e.target).closest("tr"), + item = self._searchList[$row.data("file")], + match = self._model.results[item.fullPath].matches[$row.data("index")], + $checkAll = self._panel.$panel.find(".check-all"); + + match.isChecked = $(this).is(":checked"); + if (!match.isChecked && $checkAll.is(":checked")) { + $checkAll.prop("checked", false); + } + e.stopPropagation(); + }) + .on("click.searchResults", ".replace-checked", function (e) { + $(self).triggerHandler("doReplaceAll"); + }); + } + }; + + + /** + * @private + * Shows the Results Summary + */ + SearchResultsView.prototype._showSummary = function () { + var count = this._model.countFilesMatches(), + lastIndex = this._getLastIndex(count.matches), + fileList = Object.keys(this._model.results), + filesStr, + summary; + + filesStr = StringUtils.format( + Strings.FIND_NUM_FILES, + count.files, + (count.files > 1 ? Strings.FIND_IN_FILES_FILES : Strings.FIND_IN_FILES_FILE) + ); + + // This text contains some formatting, so all the strings are assumed to be already escaped + summary = StringUtils.format( + Strings.FIND_TITLE_SUMMARY, + this._model.foundMaximum ? Strings.FIND_IN_FILES_MORE_THAN : "", + String(count.matches), + (count.matches > 1) ? Strings.FIND_IN_FILES_MATCHES : Strings.FIND_IN_FILES_MATCH, + filesStr + ); + + this._$summary.html(Mustache.render(searchSummaryTemplate, { + query: _.escape((this._model.queryInfo.query && this._model.queryInfo.query.toString()) || ""), + replaceWith: _.escape(this._model.replaceText), + titleLabel: this._model.isReplace ? Strings.FIND_REPLACE_TITLE_LABEL : Strings.FIND_TITLE_LABEL, + scope: this._model.scope ? " " + FindUtils.labelForScope(this._model.scope) + " " : "", + summary: summary, + allChecked: this._allChecked, + hasPages: count.matches > RESULTS_PER_PAGE, + results: StringUtils.format(Strings.FIND_IN_FILES_PAGING, this._currentStart + 1, lastIndex), + hasPrev: this._currentStart > 0, + hasNext: lastIndex < count.matches, + replace: this._model.isReplace, + Strings: Strings + }, { paging: searchPagingTemplate })); + }; + + /** + * @private + * Shows the current set of results. + */ + SearchResultsView.prototype._render = function () { + var searchItems, match, i, item, multiLine, + count = this._model.countFilesMatches(), + searchFiles = this._model.getSortedFiles(this._initialFilePath), + lastIndex = this._getLastIndex(count.matches), + matchesCounter = 0, + showMatches = false, + self = this; + + this._showSummary(); + this._searchList = []; + + // Iterates throuh the files to display the results sorted by filenamess. The loop ends as soon as + // we filled the results for one page + searchFiles.some(function (fullPath) { + showMatches = true; + item = self._model.results[fullPath]; + + // Since the amount of matches on this item plus the amount of matches we skipped until + // now is still smaller than the first match that we want to display, skip these. + if (matchesCounter + item.matches.length < self._currentStart) { + matchesCounter += item.matches.length; + showMatches = false; + + // If we still haven't skipped enough items to get to the first match, but adding the + // item matches to the skipped ones is greater the the first match we want to display, + // then we can display the matches from this item skipping the first ones + } else if (matchesCounter < self._currentStart) { + i = self._currentStart - matchesCounter; + matchesCounter = self._currentStart; + + // If we already skipped enough matches to get to the first match to display, we can start + // displaying from the first match of this item + } else if (matchesCounter < lastIndex) { + i = 0; + + // We can't display more items by now. Break the loop + } else { + return true; + } + + if (showMatches && i < item.matches.length) { + // Add a row for each match in the file + searchItems = []; + + // 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, { + replace: this._model.isReplace, + searchList: this._searchList, + Strings: Strings + })) + // Restore the collapsed files + .find(".file-section").each(function () { + var fullPath = self._searchList[$(this).data("file")].fullPath; + + if (self._model.results[fullPath].collapsed) { + self._model.results[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 + }; + + /** + * Updates the results view after a model change, preserving scroll position and selection. + */ + SearchResultsView.prototype._updateResults = function () { + if (this._panel.isVisible()) { + var scrollTop = this._$table.scrollTop(), + index = this._$selectedRow ? this._$selectedRow.index() : null, + numMatches = this._model.countFilesMatches().matches; + + if (this._currentStart > numMatches) { + this._currentStart = this._getLastCurrentStart(numMatches); + } + + this._render(); + + this._$table.scrollTop(scrollTop); + if (index) { + this._$selectedRow = this._$table.find("tr:eq(" + index + ")"); + this._$selectedRow.addClass("selected"); + } + } + }; + + /** + * @private + * Returns the last result index displayed + * @param {number} numMatches + * @return {number} + */ + SearchResultsView.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} + */ + SearchResultsView.prototype._getLastCurrentStart = function (numMatches) { + numMatches = numMatches || this._model.countFilesMatches().matches; + return Math.floor((numMatches - 1) / RESULTS_PER_PAGE) * RESULTS_PER_PAGE; + }; + + /** + * Opens the results panel and displays the current set of results from the model. + */ + SearchResultsView.prototype.open = function () { + // Clear out any paging/selection state. + this._currentStart = 0; + this._$selectedRow = null; + this._allChecked = true; + + // Save the currently open document's fullpath, if any, so we can sort it to the top of the result list. + var currentDoc = DocumentManager.getCurrentDocument(); + this._initialFilePath = currentDoc ? currentDoc.file.fullPath : null; + + this._render(); + + // Listen for user interaction events with the panel and change events from the model. + this._addPanelListeners(); + $(this._model).on("change.SearchResultsView", this._handleModelChange.bind(this)); + }; + + /** + * Hides the Search Results Panel and unregisters listeners. + */ + SearchResultsView.prototype.close = function () { + if (this._panel && this._panel.isVisible()) { + this._$table.empty(); + this._panel.hide(); + this._panel.$panel.off(".searchResults"); + $(this._model).off("change.SearchResultsView"); + $(this).triggerHandler("close"); + } + }; + + // Public API + exports.SearchResultsView = SearchResultsView; +}); diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 3b00a091abc..0b821ac6f27 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -967,7 +967,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%; @@ -984,7 +984,7 @@ a, img { .flex-item(0, 0); } .pagination-col { - .flex-item(1, 0); + .flex-item(0, 0); min-width: 100px; word-spacing: 0; } @@ -1033,7 +1033,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 { @@ -1110,6 +1111,7 @@ a, img { left: 5px; top: 24px; min-width: 291px + 2px; // to align with search field above it + z-index: 1; // to appear above any controls that wrap below background-color: @bc-error; color: @bc-white; @@ -1132,10 +1134,22 @@ a, img { } } + .find-input-group { + display: inline-block; + } #find-group, #replace-group { display: inline-block; white-space: nowrap; } + #replace-group.has-scope { + // If scope controls are showing, force the replace controls to a second line. + display: block; + } + + .scope-group { + display: inline-block; + margin-left: 10px; + } .message, .no-results-message { display: inline-block; @@ -1183,6 +1197,10 @@ a, img { border-bottom-left-radius: 0; margin-left: 0; } + #replace-all.solo { + border-left: none; + margin-left: 0px; + } // Make find field snug with options buttons // & replace snug with replace commands diff --git a/src/utils/Async.js b/src/utils/Async.js index 20a9fc73a91..d27003f133e 100644 --- a/src/utils/Async.js +++ b/src/utils/Async.js @@ -415,6 +415,53 @@ define(function (require, exports, module) { return deferred.promise(); } + + /** + * Utility for converting a method that takes an errback to one that returns a promise; useful + * for using FileSystem methods in a promise-oriented workflow. For example, instead of + * + * var deferred = new $.Deferred(); + * file.read(function (err, contents) { + * if (err) { + * deferred.reject(err); + * } else { + * // ...process the contents... + * deferred.resolve(); + * } + * } + * return deferred.promise(); + * + * you can just do + * + * return Async.promisify(file, "read").then(function (contents) { + * // ...process the contents... + * }); + * + * The object/method are passed as an object/string pair so that we can + * properly call the method without the caller having to deal with "bind" all the time. + * + * @param {Object} obj The object to call the method on. + * @param {string} method The name of the method. The method should expect the errback + * as its last parameter. + * @param {...Object} varargs The arguments you would have normally passed to the method + * (excluding the errback itself). + * @return {$.Promise} A promise that is resolved with the arguments that were passed to the + * errback (not including the err argument) if err is null, or rejected with the err if + * non-null. + */ + function promisify(obj, method) { + var result = new $.Deferred(), + args = Array.prototype.slice.call(arguments, 2); + args.push(function (err) { + if (err) { + result.reject(err); + } else { + result.resolve.apply(result, Array.prototype.slice.call(arguments, 1)); + } + }); + obj[method].apply(obj, args); + return result.promise(); + } /** * @constructor @@ -504,5 +551,6 @@ define(function (require, exports, module) { exports.waitForAll = waitForAll; exports.ERROR_TIMEOUT = ERROR_TIMEOUT; exports.chain = chain; + exports.promisify = promisify; exports.PromiseQueue = PromiseQueue; }); diff --git a/src/utils/StringUtils.js b/src/utils/StringUtils.js index 285cfbcafc8..13765de4140 100644 --- a/src/utils/StringUtils.js +++ b/src/utils/StringUtils.js @@ -197,7 +197,21 @@ define(function (require, exports, module) { return returnVal; } - + + /** + * Creates an HTML string for a list of files to be reported on, suitable for use in a dialog. + * @param {Array.} Array of filenames or paths to display. + */ + function makeDialogFileList(paths) { + var result = "
    "; + paths.forEach(function (path) { + result += "
  • "; + result += breakableUrl(path); + result += "
  • "; + }); + result += "
"; + return result; + } // Define public API exports.format = format; @@ -210,4 +224,5 @@ define(function (require, exports, module) { exports.breakableUrl = breakableUrl; exports.endsWith = endsWith; exports.prettyPrintBytes = prettyPrintBytes; + exports.makeDialogFileList = makeDialogFileList; }); diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 92e05550c02..89e8dedb012 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -48,6 +48,7 @@ define(function (require, exports, module) { require("spec/FileFilters-test"); require("spec/FileSystem-test"); require("spec/FileUtils-test"); + require("spec/FindInFiles-test"); require("spec/FindReplace-test"); require("spec/HTMLInstrumentation-test"); require("spec/HTMLSimpleDOM-test"); diff --git a/test/spec/Async-test.js b/test/spec/Async-test.js index e6cf88d3f53..e64d0f6556b 100644 --- a/test/spec/Async-test.js +++ b/test/spec/Async-test.js @@ -305,6 +305,36 @@ define(function (require, exports, module) { }); + describe("promisify", function () { + var testObj = { + someVal: 5, + succeeder: function (input, cb) { + cb(null, input, this.someVal); + }, + failer: function (input, cb) { + cb("this is an error"); + } + }; + + it("should resolve its returned promise when the errback is called with null err", function () { + Async.promisify(testObj, "succeeder", "myInput") + .then(function (input, someVal) { + expect(input).toBe("myInput"); + expect(someVal).toBe(testObj.someVal); + }, function (err) { + expect("should not have called fail callback").toBe(false); + }); + }); + + it("should reject its returned promise when the errback is called with an err", function () { + Async.promisify(testObj, "failer", "myInput") + .then(function (input, someVal) { + expect("should not have called success callback").toBe(false); + }, function (err) { + expect(err).toBe("this is an error"); + }); + }); + }); describe("Async PromiseQueue", function () { var queue, calledFns; diff --git a/test/spec/Document-test.js b/test/spec/Document-test.js index 6fe3db6faa6..08d01434860 100644 --- a/test/spec/Document-test.js +++ b/test/spec/Document-test.js @@ -32,6 +32,7 @@ define(function (require, exports, module) { var CommandManager, // loaded from brackets.test Commands, // loaded from brackets.test EditorManager, // loaded from brackets.test + DocumentModule, // loaded from brackets.test DocumentManager, // loaded from brackets.test SpecRunnerUtils = require("spec/SpecRunnerUtils"); @@ -226,6 +227,7 @@ define(function (require, exports, module) { CommandManager = testWindow.brackets.test.CommandManager; Commands = testWindow.brackets.test.Commands; EditorManager = testWindow.brackets.test.EditorManager; + DocumentModule = testWindow.brackets.test.DocumentModule; DocumentManager = testWindow.brackets.test.DocumentManager; SpecRunnerUtils.loadProjectInTestWindow(testPath); @@ -237,6 +239,7 @@ define(function (require, exports, module) { CommandManager = null; Commands = null; EditorManager = null; + DocumentModule = null; DocumentManager = null; SpecRunnerUtils.closeTestWindow(); }); @@ -246,6 +249,7 @@ define(function (require, exports, module) { runs(function () { expect(DocumentManager.getAllOpenDocuments().length).toBe(0); + $(DocumentModule).off(".docTest"); }); }); @@ -395,6 +399,75 @@ define(function (require, exports, module) { }); }); + describe("Refresh and change events", function () { + var promise, changeListener, docChangeListener, doc; + + beforeEach(function () { + changeListener = jasmine.createSpy(); + docChangeListener = jasmine.createSpy(); + }); + + afterEach(function () { + promise = null; + changeListener = null; + docChangeListener = null; + doc = null; + }); + + it("should fire both change and documentChange when text is refreshed if doc does not have masterEditor", function () { + runs(function () { + promise = DocumentManager.getDocumentForPath(JS_FILE) + .done(function (result) { doc = result; }); + waitsForDone(promise, "Create Document"); + }); + + runs(function () { + $(DocumentModule).on("documentChange.docTest", docChangeListener); + $(doc).on("change", changeListener); + + expect(doc._masterEditor).toBeFalsy(); + + doc.refreshText("New content", Date.now()); + + expect(doc._masterEditor).toBeFalsy(); + expect(docChangeListener.callCount).toBe(1); + expect(changeListener.callCount).toBe(1); + }); + }); + + it("should fire both change and documentChange when text is refreshed if doc has masterEditor", function () { + runs(function () { + promise = DocumentManager.getDocumentForPath(JS_FILE) + .done(function (result) { doc = result; }); + waitsForDone(promise, "Create Document"); + }); + + runs(function () { + expect(doc._masterEditor).toBeFalsy(); + doc.setText("first edit"); + expect(doc._masterEditor).toBeTruthy(); + + $(DocumentModule).on("documentChange.docTest", docChangeListener); + $(doc).on("change", changeListener); + + doc.refreshText("New content", Date.now()); + + expect(docChangeListener.callCount).toBe(1); + expect(changeListener.callCount).toBe(1); + }); + }); + + it("should *not* fire documentChange when a document is first created", function () { + runs(function () { + $(DocumentModule).on("documentChange.docTest", docChangeListener); + waitsForDone(DocumentManager.getDocumentForPath(JS_FILE)); + }); + + runs(function () { + expect(docChangeListener.callCount).toBe(0); + }); + }); + }); describe("Ref counting", function () { diff --git a/test/spec/FileFilters-test.js b/test/spec/FileFilters-test.js index 682e92e8624..4467c380bce 100644 --- a/test/spec/FileFilters-test.js +++ b/test/spec/FileFilters-test.js @@ -429,6 +429,7 @@ define(function (require, exports, module) { FileFilters, FileSystem, FindInFiles, + FindInFilesUI, CommandManager, $; @@ -441,6 +442,7 @@ define(function (require, exports, module) { FileFilters = testWindow.brackets.test.FileFilters; FileSystem = testWindow.brackets.test.FileSystem; FindInFiles = testWindow.brackets.test.FindInFiles; + FindInFilesUI = testWindow.brackets.test.FindInFilesUI; CommandManager = testWindow.brackets.test.CommandManager; $ = testWindow.$; @@ -453,6 +455,7 @@ define(function (require, exports, module) { FileSystem = null; FileFilters = null; FindInFiles = null; + FindInFilesUI = null; CommandManager = null; $ = null; SpecRunnerUtils.closeTestWindow(); @@ -466,16 +469,17 @@ define(function (require, exports, module) { }, "search bar close"); }); runs(function () { - FindInFiles._doFindInFiles(scope); + FindInFilesUI._showFindBar(scope); }); } function executeSearch(searchString) { + FindInFiles._searchDone = false; var $searchField = $(".modal-bar #find-group input"); $searchField.val(searchString).trigger("input"); SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_RETURN, "keydown", $searchField[0]); waitsFor(function () { - return FindInFiles._searchResults; + return FindInFiles._searchDone; }, "Find in Files done"); } @@ -485,8 +489,8 @@ define(function (require, exports, module) { executeSearch("{1}"); }); runs(function () { - expect(FindInFiles._searchResults[testPath + "/test1.css"]).toBeTruthy(); - expect(FindInFiles._searchResults[testPath + "/test1.html"]).toBeTruthy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeTruthy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy(); }); }); @@ -510,8 +514,8 @@ define(function (require, exports, module) { }); runs(function () { // *.css should have been excluded this time - expect(FindInFiles._searchResults[testPath + "/test1.css"]).toBeFalsy(); - expect(FindInFiles._searchResults[testPath + "/test1.html"]).toBeTruthy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeFalsy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy(); }); }); @@ -526,8 +530,8 @@ define(function (require, exports, module) { }); runs(function () { // *.css should have been excluded this time - expect(FindInFiles._searchResults[testPath + "/test1.css"]).toBeFalsy(); - expect(FindInFiles._searchResults[testPath + "/test1.html"]).toBeTruthy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeFalsy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy(); }); }); @@ -544,7 +548,7 @@ define(function (require, exports, module) { }); runs(function () { // ignore *.css exclusion since we're explicitly searching this file - expect(FindInFiles._searchResults[testPath + "/test1.css"]).toBeTruthy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeTruthy(); }); }); @@ -571,7 +575,7 @@ define(function (require, exports, module) { expect($modalBar.find("#find-group div.error").is(":visible")).toBeTruthy(); // Search panel not showing - expect($("#search-results").is(":visible")).toBeFalsy(); + expect($("#find-in-files-results").is(":visible")).toBeFalsy(); // Close search bar var $searchField = $modalBar.find("#find-group input"); @@ -600,8 +604,8 @@ define(function (require, exports, module) { waits(800); // ensure _documentChangeHandler()'s timeout has time to run }); runs(function () { - expect(FindInFiles._searchResults[testPath + "/test1.css"]).toBeFalsy(); // *.css should still be excluded - expect(FindInFiles._searchResults[testPath + "/test1.html"]).toBeTruthy(); + expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeFalsy(); // *.css should still be excluded + expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy(); }); }); }); diff --git a/test/spec/FindInFiles-test.js b/test/spec/FindInFiles-test.js new file mode 100644 index 00000000000..20bce224329 --- /dev/null +++ b/test/spec/FindInFiles-test.js @@ -0,0 +1,1801 @@ +/* + * 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. + * + */ + +/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, regexp: true */ +/*global define, describe, it, expect, beforeFirst, afterLast, beforeEach, afterEach, waits, waitsFor, waitsForDone, runs, window, jasmine, spyOn */ + +define(function (require, exports, module) { + "use strict"; + + var Commands = require("command/Commands"), + KeyEvent = require("utils/KeyEvent"), + SpecRunnerUtils = require("spec/SpecRunnerUtils"), + FileSystem = require("filesystem/FileSystem"), + FileSystemError = require("filesystem/FileSystemError"), + FileUtils = require("file/FileUtils"), + FindUtils = require("search/FindUtils"), + Async = require("utils/Async"), + LanguageManager = require("language/LanguageManager"), + StringUtils = require("utils/StringUtils"), + Strings = require("strings"), + _ = require("thirdparty/lodash"); + + var promisify = Async.promisify; // for convenience + + describe("FindInFiles", function () { + + this.category = "integration"; + + var defaultSourcePath = SpecRunnerUtils.getTestPath("/spec/FindReplace-test-files"), + testPath, + nextFolderIndex = 1, + CommandManager, + DocumentManager, + EditorManager, + FileSystem, + File, + FindInFiles, + FindInFilesUI, + ProjectManager, + testWindow, + $; + + beforeFirst(function () { + SpecRunnerUtils.createTempDirectory(); + + // Create a new window that will be shared by ALL tests in this spec. + SpecRunnerUtils.createTestWindowAndRun(this, function (w) { + testWindow = w; + + // Load module instances from brackets.test + CommandManager = testWindow.brackets.test.CommandManager; + DocumentManager = testWindow.brackets.test.DocumentManager; + EditorManager = testWindow.brackets.test.EditorManager; + FileSystem = testWindow.brackets.test.FileSystem; + File = testWindow.brackets.test.File; + FindInFiles = testWindow.brackets.test.FindInFiles; + FindInFilesUI = testWindow.brackets.test.FindInFilesUI; + ProjectManager = testWindow.brackets.test.ProjectManager; + $ = testWindow.$; + }); + }); + + afterLast(function () { + CommandManager = null; + DocumentManager = null; + EditorManager = null; + FileSystem = null; + File = null; + FindInFiles = null; + FindInFilesUI = null; + ProjectManager = null; + $ = null; + testWindow = null; + SpecRunnerUtils.closeTestWindow(); + SpecRunnerUtils.removeTempDirectory(); + }); + + function openProject(sourcePath) { + testPath = sourcePath; + SpecRunnerUtils.loadProjectInTestWindow(testPath); + } + + function waitForSearchBarClose() { + // Make sure search bar from previous test has animated out fully + waitsFor(function () { + return $(".modal-bar").length === 0; + }, "search bar close"); + } + + function openSearchBar(scope, showReplace) { + waitForSearchBarClose(); + runs(function () { + FindInFiles._searchDone = false; + FindInFilesUI._showFindBar(scope, showReplace); + }); + waitsFor(function () { + return $(".modal-bar").length === 1; + }, "search bar open"); + runs(function () { + // Reset the regexp and case-sensitivity toggles. + ["#find-regexp", "#find-case-sensitive"].forEach(function (button) { + if ($(button).is(".active")) { + $(button).click(); + expect($(button).is(".active")).toBe(false); + } + }); + }); + } + + function closeSearchBar() { + runs(function () { + FindInFilesUI._closeFindBar(); + }); + waitForSearchBarClose(); + } + + function executeSearch(searchString) { + runs(function () { + var $searchField = $("#find-what"); + $searchField.val(searchString).trigger("input"); + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_RETURN, "keydown", $searchField[0]); + }); + waitsFor(function () { + return FindInFiles._searchDone; + }, "Find in Files done"); + } + + describe("Find", function () { + beforeEach(function () { + openProject(defaultSourcePath); + }); + + it("should find all occurences in project", function () { + openSearchBar(); + executeSearch("foo"); + + runs(function () { + var fileResults = FindInFiles.searchModel.results[testPath + "/bar.txt"]; + expect(fileResults).toBeFalsy(); + + fileResults = FindInFiles.searchModel.results[testPath + "/foo.html"]; + expect(fileResults).toBeTruthy(); + expect(fileResults.matches.length).toBe(7); + + fileResults = FindInFiles.searchModel.results[testPath + "/foo.js"]; + expect(fileResults).toBeTruthy(); + expect(fileResults.matches.length).toBe(4); + + fileResults = FindInFiles.searchModel.results[testPath + "/css/foo.css"]; + expect(fileResults).toBeTruthy(); + expect(fileResults.matches.length).toBe(3); + }); + }); + + it("should ignore binary files", function () { + var $dlg, actualMessage, expectedMessage, + exists = false, + done = false, + imageDirPath = testPath + "/images"; + + runs(function () { + // Set project to have only images + SpecRunnerUtils.loadProjectInTestWindow(imageDirPath); + + // Verify an image exists in folder + var file = FileSystem.getFileForPath(testPath + "/images/icon_twitter.png"); + + file.exists(function (fileError, fileExists) { + exists = fileExists; + done = true; + }); + }); + + waitsFor(function () { + return done; + }, "file.exists"); + + runs(function () { + expect(exists).toBe(true); + openSearchBar(); + }); + + runs(function () { + // Launch filter editor + $(".filter-picker button").click(); + + // Dialog should state there are 0 files in project + $dlg = $(".modal"); + expectedMessage = StringUtils.format(Strings.FILTER_FILE_COUNT_ALL, 0, Strings.FIND_IN_FILES_NO_SCOPE); + }); + + // Message loads asynchronously, but dialog should evetually state: "Allows all 0 files in project" + waitsFor(function () { + actualMessage = $dlg.find(".exclusions-filecount").text(); + return (actualMessage === expectedMessage); + }, "display file count"); + + runs(function () { + // Dismiss filter dialog + $dlg.find(".btn.primary").click(); + + // Close search bar + var $searchField = $(".modal-bar #find-group input"); + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", $searchField[0]); + }); + + runs(function () { + // Set project back to main test folder + SpecRunnerUtils.loadProjectInTestWindow(testPath); + }); + }); + + it("should find all occurences in folder", function () { + var dirEntry = FileSystem.getDirectoryForPath(testPath + "/css/"); + openSearchBar(dirEntry); + executeSearch("foo"); + + runs(function () { + var fileResults = FindInFiles.searchModel.results[testPath + "/bar.txt"]; + expect(fileResults).toBeFalsy(); + + fileResults = FindInFiles.searchModel.results[testPath + "/foo.html"]; + expect(fileResults).toBeFalsy(); + + fileResults = FindInFiles.searchModel.results[testPath + "/foo.js"]; + expect(fileResults).toBeFalsy(); + + fileResults = FindInFiles.searchModel.results[testPath + "/css/foo.css"]; + expect(fileResults).toBeTruthy(); + expect(fileResults.matches.length).toBe(3); + }); + }); + + it("should find all occurences in single file", function () { + var fileEntry = FileSystem.getFileForPath(testPath + "/foo.js"); + openSearchBar(fileEntry); + executeSearch("foo"); + + runs(function () { + var fileResults = FindInFiles.searchModel.results[testPath + "/bar.txt"]; + expect(fileResults).toBeFalsy(); + + fileResults = FindInFiles.searchModel.results[testPath + "/foo.html"]; + expect(fileResults).toBeFalsy(); + + fileResults = FindInFiles.searchModel.results[testPath + "/foo.js"]; + expect(fileResults).toBeTruthy(); + expect(fileResults.matches.length).toBe(4); + + fileResults = FindInFiles.searchModel.results[testPath + "/css/foo.css"]; + expect(fileResults).toBeFalsy(); + }); + }); + + it("should find start and end positions", function () { + var filePath = testPath + "/foo.js", + fileEntry = FileSystem.getFileForPath(filePath); + + openSearchBar(fileEntry); + executeSearch("callFoo"); + + runs(function () { + var fileResults = FindInFiles.searchModel.results[filePath]; + expect(fileResults).toBeTruthy(); + expect(fileResults.matches.length).toBe(1); + + var match = fileResults.matches[0]; + expect(match.start.ch).toBe(13); + expect(match.start.line).toBe(6); + expect(match.end.ch).toBe(20); + expect(match.end.line).toBe(6); + }); + }); + + it("should dismiss dialog and show panel when there are results", function () { + var filePath = testPath + "/foo.js", + fileEntry = FileSystem.getFileForPath(filePath); + + openSearchBar(fileEntry); + executeSearch("callFoo"); + + waitsFor(function () { + return ($(".modal-bar").length === 0); + }, "search bar close"); + + runs(function () { + var fileResults = FindInFiles.searchModel.results[filePath]; + expect(fileResults).toBeTruthy(); + expect($("#find-in-files-results").is(":visible")).toBeTruthy(); + expect($(".modal-bar").length).toBe(0); + }); + }); + + it("should keep dialog and not show panel when there are no results", function () { + var filePath = testPath + "/bar.txt", + fileEntry = FileSystem.getFileForPath(filePath); + + openSearchBar(fileEntry); + executeSearch("abcdefghi"); + + waitsFor(function () { + return (FindInFiles._searchDone); + }, "search complete"); + + runs(function () { + var result, resultFound = false; + + // verify searchModel.results Object is empty + for (result in FindInFiles.searchModel.results) { + if (FindInFiles.searchModel.results.hasOwnProperty(result)) { + resultFound = true; + } + } + expect(resultFound).toBe(false); + + expect($("#find-in-files-results").is(":visible")).toBeFalsy(); + expect($(".modal-bar").length).toBe(1); + + // Close search bar + var $searchField = $(".modal-bar #find-group input"); + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", $searchField[0]); + }); + }); + + it("should open file in editor and select text when a result is clicked", function () { + var filePath = testPath + "/foo.html", + fileEntry = FileSystem.getFileForPath(filePath); + + openSearchBar(fileEntry); + executeSearch("foo"); + + runs(function () { + // Verify no current document + var editor = EditorManager.getActiveEditor(); + expect(editor).toBeFalsy(); + + // Get panel + var $searchResults = $("#find-in-files-results"); + expect($searchResults.is(":visible")).toBeTruthy(); + + // Get list in panel + var $panelResults = $searchResults.find("table.bottom-panel-table tr"); + expect($panelResults.length).toBe(8); // 7 hits + 1 file section + + // First item in list is file section + expect($($panelResults[0]).hasClass("file-section")).toBeTruthy(); + + // Click second item which is first hit + var $firstHit = $($panelResults[1]); + expect($firstHit.hasClass("file-section")).toBeFalsy(); + $firstHit.click(); + + // Verify current document + editor = EditorManager.getActiveEditor(); + expect(editor.document.file.fullPath).toEqual(filePath); + + // Verify selection + expect(editor.getSelectedText().toLowerCase() === "foo"); + waitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL), "closing all files"); + }); + }); + + it("should open file in working set when a result is double-clicked", function () { + var filePath = testPath + "/foo.js", + fileEntry = FileSystem.getFileForPath(filePath); + + openSearchBar(fileEntry); + executeSearch("foo"); + + runs(function () { + // Verify document is not yet in working set + expect(DocumentManager.findInWorkingSet(filePath)).toBe(-1); + + // Get list in panel + 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 + var $firstHit = $($panelResults[1]); + expect($firstHit.hasClass("file-section")).toBeFalsy(); + $firstHit.dblclick(); + + // Verify document is now in working set + expect(DocumentManager.findInWorkingSet(filePath)).not.toBe(-1); + waitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL), "closing all files"); + }); + }); + + it("should update results when a result in a file is edited", function () { + var filePath = testPath + "/foo.html", + fileEntry = FileSystem.getFileForPath(filePath), + panelListLen = 8, // 7 hits + 1 file section + $panelResults; + + openSearchBar(fileEntry); + executeSearch("foo"); + + runs(function () { + // Verify document is not yet in working set + expect(DocumentManager.findInWorkingSet(filePath)).toBe(-1); + + // Get list in panel + $panelResults = $("#find-in-files-results table.bottom-panel-table tr"); + expect($panelResults.length).toBe(panelListLen); + + // Click second item which is first hit + var $firstHit = $($panelResults[1]); + expect($firstHit.hasClass("file-section")).toBeFalsy(); + $firstHit.click(); + }); + + // Wait for file to open if not already open + waitsFor(function () { + var editor = EditorManager.getActiveEditor(); + return (editor.document.file.fullPath === filePath); + }, 1000, "file open"); + + // Wait for selection to change (this happens asynchronously after file opens) + waitsFor(function () { + var editor = EditorManager.getActiveEditor(), + sel = editor.getSelection(); + return (sel.start.line === 4 && sel.start.ch === 7); + }, 1000, "selection change"); + + runs(function () { + // Verify current selection + var editor = EditorManager.getActiveEditor(); + expect(editor.getSelectedText().toLowerCase()).toBe("foo"); + + // Edit text to remove hit from file + var sel = editor.getSelection(); + editor.document.replaceRange("Bar", sel.start, sel.end); + }); + + // Panel is updated asynchronously + waitsFor(function () { + $panelResults = $("#find-in-files-results table.bottom-panel-table tr"); + return ($panelResults.length < panelListLen); + }, "Results panel updated"); + + runs(function () { + // Verify list automatically updated + expect($panelResults.length).toBe(panelListLen - 1); + + waitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), "closing file"); + }); + }); + }); + + describe("Find results paging", function () { + var expectedPages = [ + { + totalResults: 500, + totalFiles: 2, + overallFirstIndex: 1, + overallLastIndex: 100, + matchRanges: [{file: 0, filename: "manyhits-1.txt", first: 0, firstLine: 1, last: 99, lastLine: 100, pattern: /i'm going to\s+find this\s+now/}], + firstPageEnabled: false, + lastPageEnabled: true, + prevPageEnabled: false, + nextPageEnabled: true + }, + { + totalResults: 500, + totalFiles: 2, + overallFirstIndex: 101, + overallLastIndex: 200, + matchRanges: [{file: 0, filename: "manyhits-1.txt", first: 0, firstLine: 101, last: 99, lastLine: 200, pattern: /i'm going to\s+find this\s+now/}], + firstPageEnabled: true, + lastPageEnabled: true, + prevPageEnabled: true, + nextPageEnabled: true + }, + { + totalResults: 500, + totalFiles: 2, + overallFirstIndex: 201, + overallLastIndex: 300, + matchRanges: [ + {file: 0, filename: "manyhits-1.txt", first: 0, firstLine: 201, last: 49, lastLine: 250, pattern: /i'm going to\s+find this\s+now/}, + {file: 1, filename: "manyhits-2.txt", first: 0, firstLine: 1, last: 49, lastLine: 50, pattern: /you're going to\s+find this\s+now/} + ], + firstPageEnabled: true, + lastPageEnabled: true, + prevPageEnabled: true, + nextPageEnabled: true + }, + { + totalResults: 500, + totalFiles: 2, + overallFirstIndex: 301, + overallLastIndex: 400, + matchRanges: [{file: 0, filename: "manyhits-2.txt", first: 0, firstLine: 51, last: 99, lastLine: 150, pattern: /you're going to\s+find this\s+now/}], + firstPageEnabled: true, + lastPageEnabled: true, + prevPageEnabled: true, + nextPageEnabled: true + }, + { + totalResults: 500, + totalFiles: 2, + overallFirstIndex: 401, + overallLastIndex: 500, + matchRanges: [{file: 0, filename: "manyhits-2.txt", first: 0, firstLine: 151, last: 99, lastLine: 250, pattern: /you're going to\s+find this\s+now/}], + firstPageEnabled: true, + lastPageEnabled: false, + prevPageEnabled: true, + nextPageEnabled: false + } + ]; + + function expectPageDisplay(options) { + // Check the title + expect($("#find-in-files-results .title").text().match("\\b" + options.totalResults + "\\b")).toBeTruthy(); + expect($("#find-in-files-results .title").text().match("\\b" + options.totalFiles + "\\b")).toBeTruthy(); + var paginationInfo = $("#find-in-files-results .pagination-col").text(); + expect(paginationInfo.match("\\b" + options.overallFirstIndex + "\\b")).toBeTruthy(); + expect(paginationInfo.match("\\b" + options.overallLastIndex + "\\b")).toBeTruthy(); + + // Check for presence of file and first/last item rows within each file + options.matchRanges.forEach(function (range) { + var $fileRow = $("#find-in-files-results tr.file-section[data-file='" + range.file + "']"); + expect($fileRow.length).toBe(1); + expect($fileRow.find(".dialog-filename").text()).toEqual(range.filename); + + var $firstMatchRow = $("#find-in-files-results tr[data-file='" + range.file + "'][data-item='" + range.first + "']"); + expect($firstMatchRow.length).toBe(1); + expect($firstMatchRow.find(".line-number").text().match("\\b" + range.firstLine + "\\b")).toBeTruthy(); + expect($firstMatchRow.find(".line-text").text().match(range.pattern)).toBeTruthy(); + + var $lastMatchRow = $("#find-in-files-results tr[data-file='" + range.file + "'][data-item='" + range.last + "']"); + expect($lastMatchRow.length).toBe(1); + expect($lastMatchRow.find(".line-number").text().match("\\b" + range.lastLine + "\\b")).toBeTruthy(); + expect($lastMatchRow.find(".line-text").text().match(range.pattern)).toBeTruthy(); + }); + + // Check enablement of buttons + expect($("#find-in-files-results .first-page").hasClass("disabled")).toBe(!options.firstPageEnabled); + expect($("#find-in-files-results .last-page").hasClass("disabled")).toBe(!options.lastPageEnabled); + expect($("#find-in-files-results .prev-page").hasClass("disabled")).toBe(!options.prevPageEnabled); + expect($("#find-in-files-results .next-page").hasClass("disabled")).toBe(!options.nextPageEnabled); + } + + it("should page forward, then jump back to first page, displaying correct contents at each step", function () { + openProject(SpecRunnerUtils.getTestPath("/spec/FindReplace-test-files-manyhits")); + openSearchBar(); + + // This search will find 500 hits in 2 files. Since there are 100 hits per page, there should + // be five pages, and the third page should have 50 results from the first file and 50 results + // from the second file. + executeSearch("find this"); + + runs(function () { + var i; + for (i = 0; i < 5; i++) { + if (i > 0) { + $("#find-in-files-results .next-page").click(); + } + expectPageDisplay(expectedPages[i]); + } + + $("#find-in-files-results .first-page").click(); + expectPageDisplay(expectedPages[0]); + }); + }); + + it("should jump to last page, then page backward, displaying correct contents at each step", function () { + openProject(SpecRunnerUtils.getTestPath("/spec/FindReplace-test-files-manyhits")); + openSearchBar(); + + executeSearch("find this"); + + runs(function () { + var i; + $("#find-in-files-results .last-page").click(); + for (i = 4; i >= 0; i--) { + if (i < 4) { + $("#find-in-files-results .prev-page").click(); + } + expectPageDisplay(expectedPages[i]); + } + }); + }); + }); + + describe("Replace", function () { + var searchResults; + + /** + * Helper function that calls the given asynchronous processor once on each file in the given subtree + * and returns a promise that's resolved when all files are processed. + * @param {string} rootPath The root of the subtree to search. + * @param {function(string, string): $.Promise} processor The function that processes each file. Args are: + * contents: the contents of the file + * fullPath: the full path to the file on disk + * @return {$.Promise} A promise that is resolved when all files are processed, or rejected if there was + * an error reading one of the files or one of the process steps was rejected. + */ + function visitAndProcessFiles(rootPath, processor) { + var rootEntry = FileSystem.getDirectoryForPath(rootPath), + files = []; + + function visitor(file) { + if (!file.isDirectory) { + // Skip binary files, since we don't care about them for these purposes and we can't read them + // to get their contents. + if (!LanguageManager.getLanguageForPath(file.fullPath).isBinary()) { + files.push(file); + } + } + return true; + } + return promisify(rootEntry, "visit", visitor).then(function () { + return Async.doInParallel(files, function (file) { + return promisify(file, "read").then(function (contents) { + return processor(contents, file.fullPath); + }); + }); + }); + } + + function ensureParentExists(file) { + var parentDir = FileSystem.getDirectoryForPath(file.parentPath); + return promisify(parentDir, "exists").then(function (exists) { + if (!exists) { + return promisify(parentDir, "create"); + } + return null; + }); + } + + function copyWithLineEndings(src, dest, lineEndings) { + function copyOneFileWithLineEndings(contents, srcPath) { + var destPath = dest + srcPath.slice(src.length), + destFile = FileSystem.getFileForPath(destPath), + newContents = FileUtils.translateLineEndings(contents, lineEndings); + return ensureParentExists(destFile).then(function () { + return promisify(destFile, "write", newContents); + }); + } + + return promisify(FileSystem.getDirectoryForPath(dest), "create").then(function () { + return visitAndProcessFiles(src, copyOneFileWithLineEndings); + }); + } + + // Creates a clean copy of the test project before each test. We don't delete the old + // folders as we go along (to avoid problems with deleting the project out from under the + // open test window); we just delete the whole temp folder at the end. + function openTestProjectCopy(sourcePath, lineEndings) { + testPath = SpecRunnerUtils.getTempDirectory() + "/find-in-files-test-" + (nextFolderIndex++); + runs(function () { + if (lineEndings) { + waitsForDone(copyWithLineEndings(sourcePath, testPath, lineEndings), "copy test files with line endings"); + } else { + // Note that we don't skip image files in this case, but it doesn't matter since we'll + // only compare files that have an associated file in the known goods folder. + waitsForDone(SpecRunnerUtils.copy(sourcePath, testPath), "copy test files"); + } + }); + SpecRunnerUtils.loadProjectInTestWindow(testPath); + } + + function expectProjectToMatchKnownGood(kgFolder, lineEndings, filesToSkip) { + runs(function () { + var testRootPath = ProjectManager.getProjectRoot().fullPath, + kgRootPath = SpecRunnerUtils.getTestPath("/spec/FindReplace-known-goods/" + kgFolder + "/"); + + function compareKnownGoodToTestFile(kgContents, kgFilePath) { + var testFilePath = testRootPath + kgFilePath.slice(kgRootPath.length); + if (!filesToSkip || filesToSkip.indexOf(testFilePath) === -1) { + return promisify(FileSystem.getFileForPath(testFilePath), "read").then(function (testContents) { + if (lineEndings) { + kgContents = FileUtils.translateLineEndings(kgContents, lineEndings); + } + expect(testContents).toEqual(kgContents); + }); + } + } + + waitsForDone(visitAndProcessFiles(kgRootPath, compareKnownGoodToTestFile), "project comparison done"); + }); + } + + function numMatches(results) { + return _.reduce(_.pluck(results, "matches"), function (sum, matches) { + return sum + matches.length; + }, 0); + } + + function doSearch(options) { + runs(function () { + FindInFiles.doSearchInScope(options.queryInfo, null, null, options.replaceText).done(function (results) { + searchResults = results; + }); + }); + waitsFor(function () { return searchResults; }, 1000, "search completed"); + runs(function () { + expect(numMatches(searchResults)).toBe(options.numMatches); + }); + } + + function doReplace(options) { + return FindInFiles.doReplace(searchResults, options.replaceText, { + forceFilesOpen: options.forceFilesOpen, + isRegexp: options.queryInfo.isRegexp + }); + } + + // Does a standard test for files on disk: search, replace, and check that files on disk match. + // Options: + // knownGoodFolder: name of folder containing known goods to match to project files on disk + // lineEndings: optional, one of the FileUtils.LINE_ENDINGS_* constants + // - if specified, files on disk are expected to have these line endings + // uncheckMatches: optional array of {file: string, index: number} items to uncheck; if + // index unspecified, will uncheck all matches in file + function doBasicTest(options) { + doSearch(options); + + runs(function () { + if (options.uncheckMatches) { + options.uncheckMatches.forEach(function (matchToUncheck) { + var matches = searchResults[testPath + matchToUncheck.file].matches; + if (matchToUncheck.index) { + matches[matchToUncheck.index].isChecked = false; + } else { + matches.forEach(function (match) { + match.isChecked = false; + }); + } + }); + } + waitsForDone(doReplace(options), "finish replacement"); + }); + expectProjectToMatchKnownGood(options.knownGoodFolder, options.lineEndings); + } + + // Like doBasicTest, but expects some files to have specific errors. + // Options: same as doBasicTest, plus: + // test: optional function (which must contain one or more runs blocks) to run between + // search and replace + // errors: array of errors expected to occur (in the same format as performReplacement() returns) + function doTestWithErrors(options) { + var done = false; + + doSearch(options); + + if (options.test) { + // The test function *must* contain one or more runs blocks. + options.test(); + } + + runs(function () { + doReplace(options) + .then(function () { + expect("should fail due to error").toBe(true); + done = true; + }, function (errors) { + expect(errors).toEqual(options.errors); + done = true; + }); + }); + waitsFor(function () { return done; }, 1000, "finish replacement"); + expectProjectToMatchKnownGood(options.knownGoodFolder, options.lineEndings); + } + + function expectInMemoryFiles(options) { + runs(function () { + waitsForDone(Async.doInParallel(options.inMemoryFiles, function (filePath) { + var fullPath; + + // If this is a full file path (as would be the case for an external file), handle it specially. + if (typeof filePath === "object" && filePath.fullPath) { + fullPath = filePath.fullPath; + filePath = "/" + FileUtils.getBaseName(fullPath); + } else { + fullPath = testPath + filePath; + } + + // Check that the document open in memory was changed and matches the expected replaced version of that file. + var doc = DocumentManager.getOpenDocumentForPath(fullPath); + expect(doc).toBeTruthy(); + expect(doc.isDirty).toBe(true); + + var kgPath = SpecRunnerUtils.getTestPath("/spec/FindReplace-known-goods/" + options.inMemoryKGFolder + filePath), + kgFile = FileSystem.getFileForPath(kgPath); + return promisify(kgFile, "read").then(function (contents) { + expect(doc.getText(true)).toEqual(contents); + }); + }), "check in memory file contents"); + }); + } + + // Like doBasicTest, but expects one or more files to be open in memory and the replacements to happen there. + // Options: same as doBasicTest, plus: + // inMemoryFiles: array of project-relative paths (each starting with "/") to files that should be open in memory + // inMemoryKGFolder: folder containing known goods to compare each of the inMemoryFiles to + function doInMemoryTest(options) { + // Like the basic test, we expect everything on disk to match the kgFolder (which means the file open in memory + // should *not* have changed on disk yet). + doBasicTest(options); + expectInMemoryFiles(options); + } + + beforeEach(function () { + searchResults = null; + }); + + afterEach(function () { + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), "close all files"); + }); + }); + + describe("Engine", function () { + it("should replace all instances of a simple string in a project on disk case-insensitively", function () { + openTestProjectCopy(defaultSourcePath); + doBasicTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive" + }); + }); + + it("should replace all instances of a simple string in a project on disk case-sensitively", function () { + openTestProjectCopy(defaultSourcePath); + doBasicTest({ + queryInfo: {query: "foo", isCaseSensitive: true}, + numMatches: 9, + replaceText: "bar", + knownGoodFolder: "simple-case-sensitive" + }); + }); + + it("should replace all instances of a regexp in a project on disk case-insensitively with a simple replace string", function () { + openTestProjectCopy(defaultSourcePath); + doBasicTest({ + queryInfo: {query: "\\b[a-z]{3}\\b", isRegexp: true}, + numMatches: 33, + replaceText: "CHANGED", + knownGoodFolder: "regexp-case-insensitive" + }); + }); + + it("should replace all instances of a regexp in a project on disk case-sensitively with a simple replace string", function () { + openTestProjectCopy(defaultSourcePath); + doBasicTest({ + queryInfo: {query: "\\b[a-z]{3}\\b", isRegexp: true, isCaseSensitive: true}, + numMatches: 25, + replaceText: "CHANGED", + knownGoodFolder: "regexp-case-sensitive" + }); + }); + + it("should replace instances of a regexp with a $-substitution on disk", function () { + openTestProjectCopy(defaultSourcePath); + doBasicTest({ + queryInfo: {query: "\\b([a-z]{3})\\b", isRegexp: true}, + numMatches: 33, + replaceText: "[$1]", + knownGoodFolder: "regexp-dollar-replace" + }); + }); + + it("should replace instances of a regexp with a $-substitution in in-memory files", function () { + // This test case is necessary because the in-memory case goes through a separate code path before it deals with + // the replace text. + openTestProjectCopy(defaultSourcePath); + + doInMemoryTest({ + queryInfo: {query: "\\b([a-z]{3})\\b", isRegexp: true}, + numMatches: 33, + replaceText: "[$1]", + knownGoodFolder: "unchanged", + forceFilesOpen: true, + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "regexp-dollar-replace" + }); + }); + + it("should replace instances of a string in a project respecting CRLF line endings", function () { + openTestProjectCopy(defaultSourcePath, FileUtils.LINE_ENDINGS_CRLF); + doBasicTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive", + lineEndings: FileUtils.LINE_ENDINGS_CRLF + }); + }); + + it("should replace instances of a string in a project respecting LF line endings", function () { + openTestProjectCopy(defaultSourcePath, FileUtils.LINE_ENDINGS_LF); + doBasicTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive", + lineEndings: FileUtils.LINE_ENDINGS_LF + }); + }); + + it("should not replace unchecked matches on disk", function () { + openTestProjectCopy(defaultSourcePath); + + doBasicTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + uncheckMatches: [{file: "/css/foo.css"}], + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive-except-foo.css" + }); + }); + + it("should do all in-memory replacements synchronously, so user can't accidentally edit document after start of replace process", function () { + openTestProjectCopy(defaultSourcePath); + + // Open two of the documents we want to replace in memory. + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: testPath + "/css/foo.css" }), "opening document"); + }); + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: testPath + "/foo.js" }), "opening document"); + }); + + // We can't use expectInMemoryFiles(), since this test requires everything to happen fully synchronously + // (no file reads) once the replace has started. So we read the files here. + var kgFileContents = {}; + runs(function () { + var kgPath = SpecRunnerUtils.getTestPath("/spec/FindReplace-known-goods/simple-case-insensitive"); + waitsForDone(visitAndProcessFiles(kgPath, function (contents, fullPath) { + // Translate line endings to in-memory document style (always LF) + kgFileContents[fullPath.slice(kgPath.length)] = FileUtils.translateLineEndings(contents, FileUtils.LINE_ENDINGS_LF); + }), "reading known good"); + }); + + doSearch({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar" + }); + + runs(function () { + // Start the replace, but don't wait for it to complete. Since the in-memory replacements should occur + // synchronously, the in-memory documents should have already been changed. This means we don't have to + // worry about detecting changes in documents once the replace starts. (If the user had changed + // the document after the search but before the replace started, we would have already closed the panel, + // preventing the user from doing a replace.) + var promise = FindInFiles.doReplace(searchResults, "bar"); + + // Check the in-memory contents against the known goods. + ["/css/foo.css", "/foo.js"].forEach(function (filename) { + var fullPath = testPath + filename, + doc = DocumentManager.getOpenDocumentForPath(fullPath); + expect(doc).toBeTruthy(); + expect(doc.isDirty).toBe(true); + expect(doc.getText()).toEqual(kgFileContents[filename]); + }); + + // Finish the replace operation, which should go ahead and do the file on disk. + waitsForDone(promise); + }); + + runs(function () { + // Now the file on disk should have been replaced too. + waitsForDone(promisify(FileSystem.getFileForPath(testPath + "/foo.html"), "read").then(function (contents) { + expect(FileUtils.translateLineEndings(contents, FileUtils.LINE_ENDINGS_LF)).toEqual(kgFileContents["/foo.html"]); + }), "checking known good"); + }); + }); + + it("should return an error and not do the replacement in files that have changed on disk since the search", function () { + openTestProjectCopy(defaultSourcePath); + doTestWithErrors({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "changed-file", + test: function () { + // Wait for one second to make sure that the changed file gets an updated timestamp. + // TODO: this seems like a FileSystem issue - we don't get timestamp changes with a resolution + // of less than one second. + waits(1000); + + runs(function () { + // Clone the results so we don't use the version that's auto-updated by FindInFiles when we modify the file + // on disk. This case might not usually come up in the real UI if we always guarantee that the results list will + // be auto-updated, but we want to make sure there's no edge case where we missed an update and still clobber the + // file on disk anyway. + searchResults = _.cloneDeep(searchResults); + waitsForDone(promisify(FileSystem.getFileForPath(testPath + "/css/foo.css"), "write", "/* changed content */"), "modify file"); + }); + }, + errors: [{item: testPath + "/css/foo.css", error: FindUtils.ERROR_FILE_CHANGED}] + }); + }); + + it("should return an error if a write fails", function () { + openTestProjectCopy(defaultSourcePath); + + // Return a fake error when we try to write to the CSS file. (Note that this is spying on the test window's File module.) + var writeSpy = spyOn(File.prototype, "write").andCallFake(function (data, options, callback) { + if (typeof options === "function") { + callback = options; + } else { + callback = callback || function () {}; + } + if (this.fullPath === testPath + "/css/foo.css") { + callback(FileSystemError.NOT_WRITABLE); + } else { + return writeSpy.originalValue.apply(this, arguments); + } + }); + + doTestWithErrors({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive-except-foo.css", + errors: [{item: testPath + "/css/foo.css", error: FileSystemError.NOT_WRITABLE}] + }); + }); + + it("should return an error if a match timestamp doesn't match an in-memory document timestamp", function () { + openTestProjectCopy(defaultSourcePath); + + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: testPath + "/css/foo.css" }), "opening document"); + }); + + doTestWithErrors({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive-except-foo.css", + test: function () { + runs(function () { + // Clone the results so we don't use the version that's auto-updated by FindInFiles when we modify the file + // on disk. This case might not usually come up in the real UI if we always guarantee that the results list will + // be auto-updated, but we want to make sure there's no edge case where we missed an update and still clobber the + // file on disk anyway. + searchResults = _.cloneDeep(searchResults); + var oldTimestamp = searchResults[testPath + "/css/foo.css"].timestamp; + searchResults[testPath + "/css/foo.css"].timestamp = new Date(oldTimestamp.getTime() - 5000); + }); + }, + errors: [{item: testPath + "/css/foo.css", error: FindUtils.ERROR_FILE_CHANGED}] + }); + }); + + it("should do the replacement in memory for a file open in an Editor in the working set", function () { + openTestProjectCopy(defaultSourcePath); + + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, {fullPath: testPath + "/css/foo.css"}), "add file to working set"); + }); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive-except-foo.css", + inMemoryFiles: ["/css/foo.css"], + inMemoryKGFolder: "simple-case-insensitive" + }); + }); + + it("should do the search/replace in the current document content for a dirty in-memory document", function () { + openTestProjectCopy(defaultSourcePath); + + var options = { + queryInfo: {query: "foo"}, + numMatches: 15, + replaceText: "bar", + inMemoryFiles: ["/css/foo.css"], + inMemoryKGFolder: "simple-case-insensitive-modified" + }; + + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, {fullPath: testPath + "/css/foo.css"}), "add file to working set"); + }); + runs(function () { + var doc = DocumentManager.getOpenDocumentForPath(testPath + "/css/foo.css"); + expect(doc).toBeTruthy(); + doc.replaceRange("/* added a foo line */\n", {line: 0, ch: 0}); + }); + doSearch(options); + runs(function () { + waitsForDone(doReplace(options), "replace done"); + }); + expectInMemoryFiles(options); + expectProjectToMatchKnownGood("simple-case-insensitive-modified", null, [testPath + "/css/foo.css"]); + }); + + it("should do the replacement in memory for a file open in an Editor that's not in the working set", function () { + openTestProjectCopy(defaultSourcePath); + + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_OPEN, {fullPath: testPath + "/css/foo.css"}), "open file"); + }); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive-except-foo.css", + inMemoryFiles: ["/css/foo.css"], + inMemoryKGFolder: "simple-case-insensitive" + }); + }); + + it("should open the document in an editor and do the replacement there if the document is open but not in an Editor", function () { + var doc, openFilePath; + openTestProjectCopy(defaultSourcePath); + + runs(function () { + openFilePath = testPath + "/css/foo.css"; + waitsForDone(DocumentManager.getDocumentForPath(openFilePath).done(function (d) { + doc = d; + doc.addRef(); + }), "get document"); + }); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive-except-foo.css", + inMemoryFiles: ["/css/foo.css"], + inMemoryKGFolder: "simple-case-insensitive" + }); + + runs(function () { + var workingSet = DocumentManager.getWorkingSet(); + expect(workingSet.some(function (file) { return file.fullPath === openFilePath; })).toBe(true); + doc.releaseRef(); + }); + }); + + it("should open files and do all replacements in memory if forceFilesOpen is true", function () { + openTestProjectCopy(defaultSourcePath); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "unchanged", + forceFilesOpen: true, + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-insensitive" + }); + }); + + it("should not perform unchecked matches in memory", function () { + openTestProjectCopy(defaultSourcePath); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + uncheckMatches: [{file: "/css/foo.css", index: 1}, {file: "/foo.html", index: 3}], + replaceText: "bar", + knownGoodFolder: "unchanged", + forceFilesOpen: true, + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-insensitive-unchecked" + }); + }); + + it("should not perform unchecked matches on disk", function () { + openTestProjectCopy(defaultSourcePath); + + doBasicTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + uncheckMatches: [{file: "/css/foo.css", index: 1}, {file: "/foo.html", index: 3}], + replaceText: "bar", + knownGoodFolder: "simple-case-insensitive-unchecked" + }); + }); + + it("should select the first modified file in the working set if replacements are done in memory and current editor wasn't affected", function () { + openTestProjectCopy(defaultSourcePath); + + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, {fullPath: testPath + "/bar.txt"}), "open file"); + }); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "unchanged", + forceFilesOpen: true, + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-insensitive" + }); + + runs(function () { + expect(DocumentManager.getCurrentDocument().file.fullPath).toEqual(testPath + "/css/foo.css"); + }); + }); + + it("should select the first modified file in the working set if replacements are done in memory and no editor was open", function () { + openTestProjectCopy(defaultSourcePath); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + replaceText: "bar", + knownGoodFolder: "unchanged", + forceFilesOpen: true, + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-insensitive" + }); + + runs(function () { + expect(DocumentManager.getCurrentDocument().file.fullPath).toEqual(testPath + "/css/foo.css"); + }); + }); + + it("should select the first modified file in the working set if replacements are done in memory and there were no matches checked for current editor", function () { + openTestProjectCopy(defaultSourcePath); + + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, {fullPath: testPath + "/css/foo.css"}), "open file"); + }); + + doInMemoryTest({ + queryInfo: {query: "foo"}, + numMatches: 14, + uncheckMatches: [{file: "/css/foo.css"}], + replaceText: "bar", + knownGoodFolder: "unchanged", + forceFilesOpen: true, + inMemoryFiles: ["/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-insensitive-except-foo.css" + }); + + runs(function () { + expect(DocumentManager.getCurrentDocument().file.fullPath).toEqual(testPath + "/foo.html"); + }); + }); + }); + + describe("UI", function () { + function executeReplace(findText, replaceText, fromKeyboard) { + runs(function () { + FindInFiles._searchDone = false; + FindInFiles._replaceDone = false; + $("#find-what").val(findText).trigger("input"); + $("#replace-with").val(replaceText).trigger("input"); + if (fromKeyboard) { + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_RETURN, "keydown", $("#replace-with").get(0)); + } else { + $("#replace-all").click(); + } + }); + } + + function showSearchResults(findText, replaceText, fromKeyboard) { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + executeReplace(findText, replaceText, fromKeyboard); + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + } + + afterEach(function () { + closeSearchBar(); + }); + + describe("Replace in Files Bar", function () { + it("should only show a Replace All button", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + expect($("#replace-yes").length).toBe(0); + expect($("#replace-all").length).toBe(1); + }); + }); + + it("should disable the Replace button if query is empty", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + expect($("#replace-all").is(":disabled")).toBe(true); + }); + }); + + it("should enable the Replace button if the query is a non-empty string", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + $("#find-what").val("my query").trigger("input"); + expect($("#replace-all").is(":disabled")).toBe(false); + }); + }); + + it("should disable the Replace button if query is an invalid regexp", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + $("#find-regexp").click(); + $("#find-what").val("[invalid").trigger("input"); + expect($("#replace-all").is(":disabled")).toBe(true); + }); + }); + + it("should enable the Replace button if query is a valid regexp", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + $("#find-regexp").click(); + $("#find-what").val("[valid]").trigger("input"); + expect($("#replace-all").is(":disabled")).toBe(false); + }); + }); + + it("should start with focus in Find, and set focus to the Replace field when the user hits enter in the Find field", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + // For some reason using $().is(":focus") here is flaky. + expect(testWindow.document.activeElement).toBe($("#find-what").get(0)); + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_RETURN, "keydown", $("#find-what").get(0)); + expect(testWindow.document.activeElement).toBe($("#replace-with").get(0)); + }); + }); + }); + + describe("Full workflow", function () { + it("should show results from the search with all checkboxes checked", function () { + showSearchResults("foo", "bar"); + runs(function () { + expect($("#find-in-files-results").length).toBe(1); + expect($("#find-in-files-results .check-one").length).toBe(14); + expect($("#find-in-files-results .check-one:checked").length).toBe(14); + }); + }); + + it("should do a simple search/replace all from find bar, opening results in memory, when user clicks on Replace... button", function () { + showSearchResults("foo", "bar"); + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + expectInMemoryFiles({ + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-insensitive" + }); + }); + + it("should do a simple search/replace all from find bar, opening results in memory, when user hits Enter in Replace field", function () { + showSearchResults("foo", "bar"); + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + expectInMemoryFiles({ + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-insensitive" + }); + }); + + it("should do a search in folder, replace all from find bar", function () { + openTestProjectCopy(defaultSourcePath); + var dirEntry = FileSystem.getDirectoryForPath(defaultSourcePath + "/css/"); + openSearchBar(null, true); + executeReplace("foo", "bar", true); + + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + expectInMemoryFiles({ + inMemoryFiles: ["/css/foo.css"], + inMemoryKGFolder: "simple-case-insensitive-only-foo.css" + }); + }); + + it("should do a search in file, replace all from find bar", function () { + openTestProjectCopy(defaultSourcePath); + var dirEntry = FileSystem.getDirectoryForPath(defaultSourcePath + "/css/foo.css"); + openSearchBar(null, true); + executeReplace("foo", "bar", true); + + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + expectInMemoryFiles({ + inMemoryFiles: ["/css/foo.css"], + inMemoryKGFolder: "simple-case-insensitive-only-foo.css" + }); + }); + + it("should do a regexp search/replace from find bar", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + $("#find-regexp").click(); + }); + executeReplace("\\b([a-z]{3})\\b", "[$1]", true); + + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + expectInMemoryFiles({ + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "regexp-dollar-replace" + }); + }); + + it("should do a case-sensitive search/replace from find bar", function () { + openTestProjectCopy(defaultSourcePath); + openSearchBar(null, true); + runs(function () { + $("#find-case-sensitive").click(); + }); + executeReplace("foo", "bar", true); + + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + expectInMemoryFiles({ + inMemoryFiles: ["/css/foo.css", "/foo.html", "/foo.js"], + inMemoryKGFolder: "simple-case-sensitive" + }); + }); + + it("should warn and do changes on disk if there are changes in >20 files", function () { + openTestProjectCopy(SpecRunnerUtils.getTestPath("/spec/FindReplace-test-files-large")); + openSearchBar(null, true); + executeReplace("foo", "bar"); + + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should cause the dialog to appear + runs(function () { + $(".replace-checked").click(); + }); + + runs(function () { + expect(FindInFiles._replaceDone).toBeFalsy(); + }); + + var $okButton; + waitsFor(function () { + $okButton = $(".dialog-button[data-button-id='ok']"); + return !!$okButton.length; + }, "dialog appearing"); + runs(function () { + expect($okButton.length).toBe(1); + expect($okButton.text()).toBe(Strings.BUTTON_REPLACE_WITHOUT_UNDO); + $okButton.click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + expectProjectToMatchKnownGood("simple-case-insensitive-large"); + }); + + it("should not do changes on disk if Cancel is clicked in 'too many files' dialog", function () { + spyOn(FindInFiles, "doReplace").andCallThrough(); + openTestProjectCopy(SpecRunnerUtils.getTestPath("/spec/FindReplace-test-files-large")); + openSearchBar(null, true); + executeReplace("foo", "bar"); + + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should cause the dialog to appear + runs(function () { + $(".replace-checked").click(); + }); + + runs(function () { + expect(FindInFiles._replaceDone).toBeFalsy(); + }); + + var $cancelButton; + waitsFor(function () { + $cancelButton = $(".dialog-button[data-button-id='cancel']"); + return !!$cancelButton.length; + }); + runs(function () { + expect($cancelButton.length).toBe(1); + $cancelButton.click(); + }); + + waitsFor(function () { + return $(".dialog-button[data-button-id='cancel']").length === 0; + }, "dialog dismissed"); + runs(function () { + expect(FindInFiles.doReplace).not.toHaveBeenCalled(); + // Panel should be left open. + expect($("#find-in-files-results").is(":visible")).toBeTruthy(); + }); + }); + + it("should do single-file Replace All in an open file in the project", function () { + openTestProjectCopy(defaultSourcePath); + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: testPath + "/foo.js" }), "open file"); + }); + runs(function () { + waitsForDone(CommandManager.execute(Commands.CMD_REPLACE), "open single-file replace bar"); + }); + waitsFor(function () { + return $(".modal-bar").length === 1; + }, "search bar open"); + + executeReplace("foo", "bar"); + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + + expectInMemoryFiles({ + inMemoryFiles: ["/foo.js"], + inMemoryKGFolder: "simple-case-insensitive" + }); + }); + + it("should do single-file Replace All in a non-project file", function () { + // Open an empty project. + var blankProject = SpecRunnerUtils.getTempDirectory() + "/blank-project", + externalFilePath = defaultSourcePath + "/foo.js"; + runs(function () { + var dirEntry = FileSystem.getDirectoryForPath(blankProject); + waitsForDone(promisify(dirEntry, "create")); + }); + SpecRunnerUtils.loadProjectInTestWindow(blankProject); + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: externalFilePath }), "open external file"); + }); + runs(function () { + waitsForDone(CommandManager.execute(Commands.CMD_REPLACE), "open single-file replace bar"); + }); + waitsFor(function () { + return $(".modal-bar").length === 1; + }, "search bar open"); + + executeReplace("foo", "bar"); + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + + // Click the "Replace" button in the search panel - this should kick off the replace + runs(function () { + $(".replace-checked").click(); + }); + + waitsFor(function () { + return FindInFiles._replaceDone; + }, "replace finished"); + + expectInMemoryFiles({ + inMemoryFiles: [{fullPath: externalFilePath}], // pass a full file path since this is an external file + inMemoryKGFolder: "simple-case-insensitive" + }); + }); + + it("should show an error dialog if errors occurred during the replacement", function () { + showSearchResults("foo", "bar"); + runs(function () { + spyOn(FindInFiles, "doReplace").andCallFake(function () { + return new $.Deferred().reject([ + {item: testPath + "/css/foo.css", error: FindUtils.ERROR_FILE_CHANGED}, + {item: testPath + "/foo.html", error: FileSystemError.NOT_WRITABLE} + ]); + }); + }); + runs(function () { + // This will call our mock doReplace + $(".replace-checked").click(); + }); + + var $dlg; + waitsFor(function () { + $dlg = $(".error-dialog"); + return !!$dlg.length; + }, "dialog appearing"); + runs(function () { + expect($dlg.length).toBe(1); + + // Both files should be mentioned in the dialog. + var text = $dlg.find(".dialog-message").text(); + // Have to check this in a funny way because breakableUrl() adds a special character after the slash. + expect(text.match(/css\/.*foo.css/)).not.toBe(-1); + expect(text.indexOf(StringUtils.breakableUrl("foo.html"))).not.toBe(-1); + $dlg.find(".dialog-button[data-button-id='ok']").click(); + expect($(".error-dialog").length).toBe(0); + }); + }); + }); + + // TODO: these could be split out into unit tests, but would need to be able to instantiate + // a SearchResultsView in the test runner window. + describe("Checkbox interactions", function () { + it("should uncheck all checkboxes and update model when Check All is clicked while checked", function () { + showSearchResults("foo", "bar"); + runs(function () { + expect($(".check-all").is(":checked")).toBeTruthy(); + $(".check-all").click(); + expect($(".check-all").is(":checked")).toBeFalsy(); + expect($(".check-one:checked").length).toBe(0); + expect(_.find(FindInFiles.searchModel.results, function (result) { + return _.find(result.matches, function (match) { return match.isChecked; }); + })).toBeFalsy(); + }); + }); + + it("should uncheck one checkbox and update model, unchecking the Check All checkbox", function () { + showSearchResults("foo", "bar"); + runs(function () { + $(".check-one").eq(1).click(); + expect($(".check-one").eq(1).is(":checked")).toBeFalsy(); + expect($(".check-all").is(":checked")).toBeFalsy(); + // In the sorting, this item should be the second match in the first file, which is css/foo.css. + var uncheckedMatch = FindInFiles.searchModel.results[testPath + "/css/foo.css"].matches[1]; + expect(uncheckedMatch.isChecked).toBe(false); + // Check that all items in the model besides the unchecked one to be checked. + expect(_.every(FindInFiles.searchModel.results, function (result) { + return _.every(result.matches, function (match) { + if (match === uncheckedMatch) { + // This one is already expected to be unchecked. + return true; + } + return match.isChecked; + }); + })).toBeTruthy(); + }); + }); + + it("should re-check unchecked checkbox and update model after clicking Check All again", function () { + showSearchResults("foo", "bar"); + runs(function () { + $(".check-one").eq(1).click(); + expect($(".check-one").eq(1).is(":checked")).toBeFalsy(); + expect($(".check-all").is(":checked")).toBeFalsy(); + $(".check-all").click(); + expect($(".check-all").is(":checked")).toBeTruthy(); + expect($(".check-one:checked").length).toEqual($(".check-one").length); + expect(_.every(FindInFiles.searchModel.results, function (result) { + return _.every(result.matches, function (match) { return match.isChecked; }); + })).toBeTruthy(); + }); + }); + + // TODO: checkboxes with paging + }); + // Untitled documents are covered in the "Search -> Replace All in untitled document" cases above. + + describe("Panel closure on changes", function () { + it("should close the panel and detach listeners if a file is modified on disk", function () { + showSearchResults("foo", "bar"); + runs(function () { + expect($("#find-in-files-results").is(":visible")).toBe(true); + waitsForDone(promisify(FileSystem.getFileForPath(testPath + "/foo.html"), "write", "changed content")); + }); + runs(function () { + expect($("#find-in-files-results").is(":visible")).toBe(false); + }); + }); + + it("should close the panel if a file is modified in memory", function () { + openTestProjectCopy(defaultSourcePath); + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: testPath + "/foo.html" }), "open file"); + }); + openSearchBar(null, true); + executeReplace("foo", "bar"); + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + runs(function () { + expect($("#find-in-files-results").is(":visible")).toBe(true); + + var doc = DocumentManager.getOpenDocumentForPath(testPath + "/foo.html"); + expect(doc).toBeTruthy(); + doc.replaceRange("", {line: 0, ch: 0}, {line: 1, ch: 0}); + + expect($("#find-in-files-results").is(":visible")).toBe(false); + }); + }); + + it("should close the panel if a document was open and modified before the search, but then the file was closed and changes dropped", function () { + var doc; + + openTestProjectCopy(defaultSourcePath); + runs(function () { + waitsForDone(CommandManager.execute(Commands.FILE_ADD_TO_WORKING_SET, { fullPath: testPath + "/foo.html" }), "open file"); + }); + runs(function () { + doc = DocumentManager.getOpenDocumentForPath(testPath + "/foo.html"); + expect(doc).toBeTruthy(); + doc.replaceRange("", {line: 0, ch: 0}, {line: 1, ch: 0}); + }); + openSearchBar(null, true); + executeReplace("foo", "bar"); + waitsFor(function () { + return FindInFiles._searchDone; + }, "search finished"); + runs(function () { + expect($("#find-in-files-results").is(":visible")).toBe(true); + + // We have to go through the dialog workflow for closing the file without saving changes, + // because the "revert" behavior only happens in that workflow (it doesn't happen if you + // do forceClose, since that's only intended as a shortcut for the end of a unit test). + var closePromise = CommandManager.execute(Commands.FILE_CLOSE, { file: doc.file }), + $dontSaveButton = $(".dialog-button[data-button-id='dontsave']"); + expect($dontSaveButton.length).toBe(1); + $dontSaveButton.click(); + waitsForDone(closePromise); + }); + runs(function () { + expect($("#find-in-files-results").is(":visible")).toBe(false); + }); + }); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/spec/FindReplace-known-goods/changed-file/bar.txt b/test/spec/FindReplace-known-goods/changed-file/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/changed-file/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/changed-file/css/foo.css b/test/spec/FindReplace-known-goods/changed-file/css/foo.css new file mode 100644 index 00000000000..b7fb604fde7 --- /dev/null +++ b/test/spec/FindReplace-known-goods/changed-file/css/foo.css @@ -0,0 +1 @@ +/* changed content */ \ No newline at end of file diff --git a/test/spec/FindReplace-known-goods/changed-file/foo.html b/test/spec/FindReplace-known-goods/changed-file/foo.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/changed-file/foo.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/changed-file/foo.js b/test/spec/FindReplace-known-goods/changed-file/foo.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/changed-file/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/regexp-case-insensitive/bar.txt b/test/spec/FindReplace-known-goods/regexp-case-insensitive/bar.txt new file mode 100644 index 00000000000..ed9aac507c0 --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-insensitive/bar.txt @@ -0,0 +1,3 @@ +CHANGED.CHANGED file + +This file should *CHANGED* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/regexp-case-insensitive/css/foo.css b/test/spec/FindReplace-known-goods/regexp-case-insensitive/css/foo.css new file mode 100644 index 00000000000..02dc4918edb --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-insensitive/css/foo.css @@ -0,0 +1,13 @@ +/* CHANGED.CHANGED */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.CHANGED { + list-style: none; +} +.CHANGED { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/regexp-case-insensitive/foo.html b/test/spec/FindReplace-known-goods/regexp-case-insensitive/foo.html new file mode 100644 index 00000000000..32d580f3af6 --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-insensitive/foo.html @@ -0,0 +1,22 @@ + + + + +CHANGED + + + + + + +

CHANGED

+

Intro to CHANGED

+
    +
  • CHANGED
  • +
  • CHANGED
  • +
  • CHANGED
  • +
+

It's CHANGED about CHANGED CHANGED

+ + + diff --git a/test/spec/FindReplace-known-goods/regexp-case-insensitive/foo.js b/test/spec/FindReplace-known-goods/regexp-case-insensitive/foo.js new file mode 100644 index 00000000000..bc0feaec4f6 --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-insensitive/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + CHANGED CHANGED = require("modules/CHANGED"), + CHANGED = require("modules/CHANGED"), + CHANGED = require("modules/CHANGED"); + + function callFoo() { + + CHANGED(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/regexp-case-sensitive/bar.txt b/test/spec/FindReplace-known-goods/regexp-case-sensitive/bar.txt new file mode 100644 index 00000000000..ed9aac507c0 --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-sensitive/bar.txt @@ -0,0 +1,3 @@ +CHANGED.CHANGED file + +This file should *CHANGED* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/regexp-case-sensitive/css/foo.css b/test/spec/FindReplace-known-goods/regexp-case-sensitive/css/foo.css new file mode 100644 index 00000000000..02dc4918edb --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-sensitive/css/foo.css @@ -0,0 +1,13 @@ +/* CHANGED.CHANGED */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.CHANGED { + list-style: none; +} +.CHANGED { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/regexp-case-sensitive/foo.html b/test/spec/FindReplace-known-goods/regexp-case-sensitive/foo.html new file mode 100644 index 00000000000..3d74c223a7a --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-sensitive/foo.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to CHANGED

+
    +
  • CHANGED
  • +
  • CHANGED
  • +
  • CHANGED
  • +
+

It's CHANGED about CHANGED CHANGED

+ + + diff --git a/test/spec/FindReplace-known-goods/regexp-case-sensitive/foo.js b/test/spec/FindReplace-known-goods/regexp-case-sensitive/foo.js new file mode 100644 index 00000000000..a42eb8b01c4 --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-case-sensitive/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + CHANGED Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + CHANGED(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/regexp-dollar-replace/bar.txt b/test/spec/FindReplace-known-goods/regexp-dollar-replace/bar.txt new file mode 100644 index 00000000000..c8a85b52ac1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-dollar-replace/bar.txt @@ -0,0 +1,3 @@ +[bar].[txt] file + +This file should *[not]* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/regexp-dollar-replace/css/foo.css b/test/spec/FindReplace-known-goods/regexp-dollar-replace/css/foo.css new file mode 100644 index 00000000000..533870749eb --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-dollar-replace/css/foo.css @@ -0,0 +1,13 @@ +/* [foo].[css] */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.[foo] { + list-style: none; +} +.[bar] { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/regexp-dollar-replace/foo.html b/test/spec/FindReplace-known-goods/regexp-dollar-replace/foo.html new file mode 100644 index 00000000000..652b817430b --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-dollar-replace/foo.html @@ -0,0 +1,22 @@ + + + + +[Foo] + + + + + + +

[Foo]

+

Intro to [foo]

+
    +
  • [foo]
  • +
  • [bar]
  • +
  • [baz]
  • +
+

It's [all] about [the] [bar]

+ + + diff --git a/test/spec/FindReplace-known-goods/regexp-dollar-replace/foo.js b/test/spec/FindReplace-known-goods/regexp-dollar-replace/foo.js new file mode 100644 index 00000000000..6def5d063ff --- /dev/null +++ b/test/spec/FindReplace-known-goods/regexp-dollar-replace/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + [var] [Foo] = require("modules/[Foo]"), + [Bar] = require("modules/[Bar]"), + [Baz] = require("modules/[Baz]"); + + function callFoo() { + + [foo](); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/bar.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/css/foo.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/css/foo.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/css/foo.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/foo.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/foo.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/foo.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/foo.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/foo.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-except-foo.css/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar2.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar2.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar2.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar3.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar3.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar3.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar4.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar4.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar4.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar5.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar5.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar5.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar6.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar6.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar6.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar7.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar7.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/bar7.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo2.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo2.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo2.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo3.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo3.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo3.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo4.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo4.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo4.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo5.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo5.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo5.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo6.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo6.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo6.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo7.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo7.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/css/foo7.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo2.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo2.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo2.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo2.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo2.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo2.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo3.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo3.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo3.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo3.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo3.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo3.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo4.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo4.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo4.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo4.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo4.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo4.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo5.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo5.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo5.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo5.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo5.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo5.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo6.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo6.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo6.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo6.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo6.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo6.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo7.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo7.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo7.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo7.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo7.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-large/foo7.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/bar.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/css/foo.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/css/foo.css new file mode 100644 index 00000000000..b03979544df --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/css/foo.css @@ -0,0 +1,14 @@ +/* added a bar line */ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/foo.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/foo.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/foo.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/foo.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/foo.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-modified/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/bar.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/css/foo.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/css/foo.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/css/foo.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/foo.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/foo.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/foo.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/foo.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/foo.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-only-foo.css/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/bar.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/css/foo.css b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/css/foo.css new file mode 100644 index 00000000000..36f20e160ba --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/css/foo.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/foo.html b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/foo.html new file mode 100644 index 00000000000..adf0a7748ce --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/foo.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

Foo

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/foo.js b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/foo.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive-unchecked/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive/bar.txt b/test/spec/FindReplace-known-goods/simple-case-insensitive/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive/css/foo.css b/test/spec/FindReplace-known-goods/simple-case-insensitive/css/foo.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive/css/foo.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive/foo.html b/test/spec/FindReplace-known-goods/simple-case-insensitive/foo.html new file mode 100644 index 00000000000..268ac7290e1 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive/foo.html @@ -0,0 +1,22 @@ + + + + +bar + + + + + + +

bar

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-insensitive/foo.js b/test/spec/FindReplace-known-goods/simple-case-insensitive/foo.js new file mode 100644 index 00000000000..6d1eb70bec2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-insensitive/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var bar = require("modules/bar"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callbar() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/simple-case-sensitive/bar.txt b/test/spec/FindReplace-known-goods/simple-case-sensitive/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-sensitive/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/simple-case-sensitive/css/foo.css b/test/spec/FindReplace-known-goods/simple-case-sensitive/css/foo.css new file mode 100644 index 00000000000..a74e06ea03d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-sensitive/css/foo.css @@ -0,0 +1,13 @@ +/* bar.css */ +body { + margin: 0; +} +h1, barter { + padding: 2px auto; +} +ul.bar { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/simple-case-sensitive/foo.html b/test/spec/FindReplace-known-goods/simple-case-sensitive/foo.html new file mode 100644 index 00000000000..7e703f3d60b --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-sensitive/foo.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to bar

+
    +
  • bar
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/simple-case-sensitive/foo.js b/test/spec/FindReplace-known-goods/simple-case-sensitive/foo.js new file mode 100644 index 00000000000..8a99d06172d --- /dev/null +++ b/test/spec/FindReplace-known-goods/simple-case-sensitive/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + bar(); + + } + +} diff --git a/test/spec/FindReplace-known-goods/unchanged/bar.txt b/test/spec/FindReplace-known-goods/unchanged/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-known-goods/unchanged/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-known-goods/unchanged/css/foo.css b/test/spec/FindReplace-known-goods/unchanged/css/foo.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-known-goods/unchanged/css/foo.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-known-goods/unchanged/foo.html b/test/spec/FindReplace-known-goods/unchanged/foo.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-known-goods/unchanged/foo.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-known-goods/unchanged/foo.js b/test/spec/FindReplace-known-goods/unchanged/foo.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-known-goods/unchanged/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-large/bar.txt b/test/spec/FindReplace-test-files-large/bar.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-test-files-large/bar.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-test-files-large/bar2.txt b/test/spec/FindReplace-test-files-large/bar2.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-test-files-large/bar2.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-test-files-large/bar3.txt b/test/spec/FindReplace-test-files-large/bar3.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-test-files-large/bar3.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-test-files-large/bar4.txt b/test/spec/FindReplace-test-files-large/bar4.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-test-files-large/bar4.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-test-files-large/bar5.txt b/test/spec/FindReplace-test-files-large/bar5.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-test-files-large/bar5.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-test-files-large/bar6.txt b/test/spec/FindReplace-test-files-large/bar6.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-test-files-large/bar6.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-test-files-large/bar7.txt b/test/spec/FindReplace-test-files-large/bar7.txt new file mode 100644 index 00000000000..b071cf9ee9c --- /dev/null +++ b/test/spec/FindReplace-test-files-large/bar7.txt @@ -0,0 +1,3 @@ +bar.txt file + +This file should *not* show up in certain searches diff --git a/test/spec/FindReplace-test-files-large/css/foo.css b/test/spec/FindReplace-test-files-large/css/foo.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-test-files-large/css/foo.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-test-files-large/css/foo2.css b/test/spec/FindReplace-test-files-large/css/foo2.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-test-files-large/css/foo2.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-test-files-large/css/foo3.css b/test/spec/FindReplace-test-files-large/css/foo3.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-test-files-large/css/foo3.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-test-files-large/css/foo4.css b/test/spec/FindReplace-test-files-large/css/foo4.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-test-files-large/css/foo4.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-test-files-large/css/foo5.css b/test/spec/FindReplace-test-files-large/css/foo5.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-test-files-large/css/foo5.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-test-files-large/css/foo6.css b/test/spec/FindReplace-test-files-large/css/foo6.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-test-files-large/css/foo6.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-test-files-large/css/foo7.css b/test/spec/FindReplace-test-files-large/css/foo7.css new file mode 100644 index 00000000000..02a7ad9ce7e --- /dev/null +++ b/test/spec/FindReplace-test-files-large/css/foo7.css @@ -0,0 +1,13 @@ +/* foo.css */ +body { + margin: 0; +} +h1, footer { + padding: 2px auto; +} +ul.foo { + list-style: none; +} +.bar { + font-size: large; +} diff --git a/test/spec/FindReplace-test-files-large/foo.html b/test/spec/FindReplace-test-files-large/foo.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-test-files-large/foo.js b/test/spec/FindReplace-test-files-large/foo.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-large/foo2.html b/test/spec/FindReplace-test-files-large/foo2.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo2.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-test-files-large/foo2.js b/test/spec/FindReplace-test-files-large/foo2.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo2.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-large/foo3.html b/test/spec/FindReplace-test-files-large/foo3.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo3.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-test-files-large/foo3.js b/test/spec/FindReplace-test-files-large/foo3.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo3.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-large/foo4.html b/test/spec/FindReplace-test-files-large/foo4.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo4.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-test-files-large/foo4.js b/test/spec/FindReplace-test-files-large/foo4.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo4.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-large/foo5.html b/test/spec/FindReplace-test-files-large/foo5.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo5.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-test-files-large/foo5.js b/test/spec/FindReplace-test-files-large/foo5.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo5.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-large/foo6.html b/test/spec/FindReplace-test-files-large/foo6.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo6.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-test-files-large/foo6.js b/test/spec/FindReplace-test-files-large/foo6.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo6.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-large/foo7.html b/test/spec/FindReplace-test-files-large/foo7.html new file mode 100644 index 00000000000..1c7ead8038d --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo7.html @@ -0,0 +1,22 @@ + + + + +Foo + + + + + + +

Foo

+

Intro to foo

+
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
+

It's all about the bar

+ + + diff --git a/test/spec/FindReplace-test-files-large/foo7.js b/test/spec/FindReplace-test-files-large/foo7.js new file mode 100644 index 00000000000..e6951ce3fa2 --- /dev/null +++ b/test/spec/FindReplace-test-files-large/foo7.js @@ -0,0 +1,13 @@ +/* Test comment */ +define(function (require, exports, module) { + var Foo = require("modules/Foo"), + Bar = require("modules/Bar"), + Baz = require("modules/Baz"); + + function callFoo() { + + foo(); + + } + +} diff --git a/test/spec/FindReplace-test-files-manyhits/manyhits-1.txt b/test/spec/FindReplace-test-files-manyhits/manyhits-1.txt new file mode 100644 index 00000000000..48660af48c3 --- /dev/null +++ b/test/spec/FindReplace-test-files-manyhits/manyhits-1.txt @@ -0,0 +1,250 @@ +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now +i'm going to find this now \ No newline at end of file diff --git a/test/spec/FindReplace-test-files-manyhits/manyhits-2.txt b/test/spec/FindReplace-test-files-manyhits/manyhits-2.txt new file mode 100644 index 00000000000..66dcdbcb372 --- /dev/null +++ b/test/spec/FindReplace-test-files-manyhits/manyhits-2.txt @@ -0,0 +1,250 @@ +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now +you're going to find this now \ No newline at end of file diff --git a/test/spec/FindReplace-test.js b/test/spec/FindReplace-test.js index a4ffc007ded..7b499d913e7 100644 --- a/test/spec/FindReplace-test.js +++ b/test/spec/FindReplace-test.js @@ -22,7 +22,7 @@ */ /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, describe, it, expect, beforeFirst, afterLast, beforeEach, afterEach, waitsFor, waitsForDone, runs, window, jasmine */ +/*global define, describe, it, expect, beforeFirst, afterLast, beforeEach, afterEach, waits, waitsFor, waitsForDone, runs, window, jasmine, spyOn */ /*unittests: FindReplace*/ define(function (require, exports, module) { @@ -32,9 +32,8 @@ define(function (require, exports, module) { FindReplace = require("search/FindReplace"), KeyEvent = require("utils/KeyEvent"), SpecRunnerUtils = require("spec/SpecRunnerUtils"), - StringUtils = require("utils/StringUtils"), - Strings = require("strings"); - + _ = require("thirdparty/lodash"); + var defaultContent = "/* Test comment */\n" + "define(function (require, exports, module) {\n" + " var Foo = require(\"modules/Foo\"),\n" + @@ -318,7 +317,7 @@ define(function (require, exports, module) { ]; - var testWindow, twCommandManager, twEditorManager, tw$; + var testWindow, twCommandManager, twEditorManager, twFindInFiles, tw$; var myDocument, myEditor; // Helper functions for testing cursor position / selection range @@ -435,6 +434,7 @@ define(function (require, exports, module) { // Load module instances from brackets.test twCommandManager = testWindow.brackets.test.CommandManager; twEditorManager = testWindow.brackets.test.EditorManager; + twFindInFiles = testWindow.brackets.test.FindInFiles; tw$ = testWindow.$; SpecRunnerUtils.loadProjectInTestWindow(SpecRunnerUtils.getTempDirectory()); @@ -445,6 +445,7 @@ define(function (require, exports, module) { testWindow = null; twCommandManager = null; twEditorManager = null; + twFindInFiles = null; tw$ = null; SpecRunnerUtils.closeTestWindow(); @@ -555,7 +556,7 @@ define(function (require, exports, module) { }); it("should have a scroll track marker for every match", function () { - twCommandManager.execute(Commands.EDIT_FIND); + twCommandManager.execute(Commands.CMD_FIND); enterSearchText("foo"); expectHighlightedMatches(fooExpectedMatches); @@ -805,21 +806,21 @@ define(function (require, exports, module) { it("should use empty initial query for single cursor selection", function () { myEditor.setSelection({line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START}); - twCommandManager.execute(Commands.EDIT_FIND); + twCommandManager.execute(Commands.CMD_FIND); expect(getSearchField().val()).toEqual(""); }); it("should use empty initial query for multiple cursor selection", function () { myEditor.setSelections([{start: {line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START}, end: {line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START}, primary: true}, {start: {line: 1, ch: 0}, end: {line: 1, ch: 0}}]); - twCommandManager.execute(Commands.EDIT_FIND); + twCommandManager.execute(Commands.CMD_FIND); expect(getSearchField().val()).toEqual(""); }); it("should get single selection as initial query", function () { myEditor.setSelection({line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START}, {line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_PAREN}); - twCommandManager.execute(Commands.EDIT_FIND); + twCommandManager.execute(Commands.CMD_FIND); expect(getSearchField().val()).toEqual("require"); }); @@ -1126,7 +1127,7 @@ define(function (require, exports, module) { it("should find and skip then replace string", function () { runs(function () { - twCommandManager.execute(Commands.EDIT_REPLACE); + twCommandManager.execute(Commands.CMD_REPLACE); enterSearchText("foo"); enterReplaceText("bar"); @@ -1332,12 +1333,28 @@ define(function (require, exports, module) { }); - describe("Search -> Replace All", function () { + describe("Search -> Replace All in untitled document", function () { + function expectTextAtPositions(text, posArray) { + posArray.forEach(function (pos) { + expect(myEditor.document.getRange(pos, {line: pos.line, ch: pos.ch + text.length})).toEqual(text); + }); + } + function dontExpectTextAtPositions(text, posArray) { + posArray.forEach(function (pos) { + expect(myEditor.document.getRange(pos, {line: pos.line, ch: pos.ch + text.length})).not.toEqual(text); + }); + } + + beforeEach(function () { + twFindInFiles._searchDone = false; + twFindInFiles._replaceDone = false; + }); + it("should find and replace all", function () { + var searchText = "require", + replaceText = "brackets.getModule"; runs(function () { - var searchText = "require", - replaceText = "brackets.getModule"; - twCommandManager.execute(Commands.EDIT_REPLACE); + twCommandManager.execute(Commands.CMD_REPLACE); enterSearchText(searchText); enterReplaceText(replaceText); @@ -1346,43 +1363,139 @@ define(function (require, exports, module) { expect(tw$("#replace-all").is(":enabled")).toBe(true); tw$("#replace-all").click(); - tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); - myEditor.setSelection({line: 1, ch: 17}, {line: 1, ch: 17 + replaceText.length}); - expect(myEditor.getSelectedText()).toBe(replaceText); + runs(function () { + tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + runs(function () { // Note: LINE_FIRST_REQUIRE and CH_REQUIRE_START refer to first call to "require", // but not first instance of "require" in text - myEditor.setSelection({line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START}, - {line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START + replaceText.length}); - expect(myEditor.getSelectedText()).toBe(replaceText); + expectTextAtPositions(replaceText, [ + {line: 1, ch: 17}, + {line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START}, + {line: LINE_FIRST_REQUIRE + 1, ch: CH_REQUIRE_START}, + {line: LINE_FIRST_REQUIRE + 2, ch: CH_REQUIRE_START} + ]); + }); + }); + + it("should close panel if document modified", function () { + var searchText = "require", + replaceText = "brackets.getModule"; + runs(function () { + twCommandManager.execute(Commands.CMD_REPLACE); + enterSearchText(searchText); + enterReplaceText(replaceText); - myEditor.setSelection({line: LINE_FIRST_REQUIRE + 1, ch: CH_REQUIRE_START}, - {line: LINE_FIRST_REQUIRE + 1, ch: CH_REQUIRE_START + replaceText.length}); - expect(myEditor.getSelectedText()).toBe(replaceText); + expectSelection({start: {line: 1, ch: 17}, end: {line: 1, ch: 17 + searchText.length}}); + expect(myEditor.getSelectedText()).toBe(searchText); - myEditor.setSelection({line: LINE_FIRST_REQUIRE + 2, ch: CH_REQUIRE_START}, - {line: LINE_FIRST_REQUIRE + 2, ch: CH_REQUIRE_START + replaceText.length}); + expect(tw$("#replace-all").is(":enabled")).toBe(true); + tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { + expect(tw$("#find-in-files-results").is(":visible")).toBe(true); + myEditor.document.replaceRange("", {line: 0, ch: 0}, {line: 1, ch: 0}); + expect(tw$("#find-in-files-results").is(":visible")).toBe(false); + }); + }); + + it("should not replace unchecked items", function () { + var searchText = "require", + replaceText = "brackets.getModule"; + runs(function () { + twCommandManager.execute(Commands.CMD_REPLACE); + enterSearchText(searchText); + enterReplaceText(replaceText); + + expectSelection({start: {line: 1, ch: 17}, end: {line: 1, ch: 17 + searchText.length}}); + expect(myEditor.getSelectedText()).toBe(searchText); + + expect(tw$("#replace-all").is(":enabled")).toBe(true); + tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { + // verify that all items are checked by default + var $checked = tw$(".check-one:checked"); + expect($checked.length).toBe(4); + + // uncheck second and fourth + $checked.eq(1).click(); + $checked.eq(3).click(); + expect(tw$(".check-one:checked").length).toBe(2); + + tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + + runs(function () { + + myEditor.setSelection({line: 1, ch: 17}, {line: 1, ch: 17 + replaceText.length}); expect(myEditor.getSelectedText()).toBe(replaceText); + + expectTextAtPositions(replaceText, [ + {line: 1, ch: 17}, + {line: LINE_FIRST_REQUIRE + 1, ch: CH_REQUIRE_START} + ]); + dontExpectTextAtPositions(replaceText, [ + {line: LINE_FIRST_REQUIRE, ch: CH_REQUIRE_START}, + {line: LINE_FIRST_REQUIRE + 2, ch: CH_REQUIRE_START} + ]); }); }); it("should find all regexps and replace them with $n", function () { + var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; + runs(function () { twCommandManager.execute(Commands.CMD_REPLACE); toggleRegexp(true); enterSearchText("(modules)\\/(\\w+)"); enterReplaceText("$2:$1"); - var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; - expectSelection(expectedMatch); expect(/foo/i.test(myEditor.getSelectedText())).toBe(true); expect(tw$("#replace-all").is(":enabled")).toBe(true); tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + runs(function () { myEditor.setSelection(expectedMatch.start, expectedMatch.end); expect(/Foo:modules/i.test(myEditor.getSelectedText())).toBe(true); @@ -1395,21 +1508,34 @@ define(function (require, exports, module) { }); it("should find all regexps and replace them with $n (empty subexpression)", function () { + var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; + runs(function () { twCommandManager.execute(Commands.CMD_REPLACE); toggleRegexp(true); enterSearchText("(modules)(.*)\\/(\\w+)"); enterReplaceText("$3$2:$1"); - var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; - expectSelection(expectedMatch); expect(/foo/i.test(myEditor.getSelectedText())).toBe(true); expect(tw$("#replace-all").is(":enabled")).toBe(true); tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + runs(function () { myEditor.setSelection(expectedMatch.start, expectedMatch.end); expect(/Foo:modules/i.test(myEditor.getSelectedText())).toBe(true); @@ -1422,21 +1548,34 @@ define(function (require, exports, module) { }); it("should find all regexps and replace them with $nn (n has two digits)", function () { + var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; + runs(function () { twCommandManager.execute(Commands.CMD_REPLACE); toggleRegexp(true); enterSearchText("()()()()()()()()()()(modules)\\/()()()(\\w+)"); enterReplaceText("$15:$11"); - var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; - expectSelection(expectedMatch); expect(/foo/i.test(myEditor.getSelectedText())).toBe(true); expect(tw$("#replace-all").is(":enabled")).toBe(true); tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + runs(function () { myEditor.setSelection(expectedMatch.start, expectedMatch.end); expect(/Foo:modules/i.test(myEditor.getSelectedText())).toBe(true); @@ -1449,21 +1588,34 @@ define(function (require, exports, module) { }); it("should find all regexps and replace them with $$n (not a subexpression, escaped dollar)", function () { + var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; + runs(function () { twCommandManager.execute(Commands.CMD_REPLACE); toggleRegexp(true); enterSearchText("(modules)\\/(\\w+)"); enterReplaceText("$$2_$$10:$2"); - var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; - expectSelection(expectedMatch); expect(/foo/i.test(myEditor.getSelectedText())).toBe(true); expect(tw$("#replace-all").is(":enabled")).toBe(true); tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + runs(function () { myEditor.setSelection(expectedMatch.start, expectedMatch.end); expect(/\$2_\$10:Foo/i.test(myEditor.getSelectedText())).toBe(true); @@ -1476,21 +1628,34 @@ define(function (require, exports, module) { }); it("should find all regexps and replace them with $$$n (correct subexpression)", function () { + var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; + runs(function () { twCommandManager.execute(Commands.CMD_REPLACE); toggleRegexp(true); enterSearchText("(modules)\\/(\\w+)"); enterReplaceText("$2$$$1"); - var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; - expectSelection(expectedMatch); expect(/foo/i.test(myEditor.getSelectedText())).toBe(true); expect(tw$("#replace-all").is(":enabled")).toBe(true); tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + runs(function () { myEditor.setSelection(expectedMatch.start, expectedMatch.end); expect(/Foo\$modules/i.test(myEditor.getSelectedText())).toBe(true); @@ -1503,21 +1668,34 @@ define(function (require, exports, module) { }); it("should find all regexps and replace them with $& (whole match)", function () { + var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; + runs(function () { twCommandManager.execute(Commands.CMD_REPLACE); toggleRegexp(true); enterSearchText("(modules)\\/(\\w+)"); enterReplaceText("_$&-$2$$&"); - var expectedMatch = {start: {line: LINE_FIRST_REQUIRE, ch: 23}, end: {line: LINE_FIRST_REQUIRE, ch: 34}}; - expectSelection(expectedMatch); expect(/foo/i.test(myEditor.getSelectedText())).toBe(true); expect(tw$("#replace-all").is(":enabled")).toBe(true); tw$("#replace-all").click(); + }); + + waitsFor(function () { + return twFindInFiles._searchDone; + }, "search finished"); + + runs(function () { tw$(".replace-checked").click(); + }); + + waitsFor(function () { + return twFindInFiles._replaceDone; + }, "replace finished"); + runs(function () { myEditor.setSelection({line: LINE_FIRST_REQUIRE, ch: 23}, {line: LINE_FIRST_REQUIRE, ch: 41}); expect(/_modules\/Foo-Foo\$&/i.test(myEditor.getSelectedText())).toBe(true); @@ -1530,390 +1708,4 @@ define(function (require, exports, module) { }); }); }); - - - describe("FindInFiles", function () { - - this.category = "integration"; - - var testPath = SpecRunnerUtils.getTestPath("/spec/FindReplace-test-files"), - CommandManager, - DocumentManager, - EditorManager, - FileSystem, - FindInFiles, - testWindow, - $; - - beforeFirst(function () { - // Create a new window that will be shared by ALL tests in this spec. - SpecRunnerUtils.createTestWindowAndRun(this, function (w) { - testWindow = w; - - // Load module instances from brackets.test - CommandManager = testWindow.brackets.test.CommandManager; - DocumentManager = testWindow.brackets.test.DocumentManager; - EditorManager = testWindow.brackets.test.EditorManager; - FileSystem = testWindow.brackets.test.FileSystem; - FindInFiles = testWindow.brackets.test.FindInFiles; - CommandManager = testWindow.brackets.test.CommandManager; - $ = testWindow.$; - - SpecRunnerUtils.loadProjectInTestWindow(testPath); - }); - }); - - afterLast(function () { - CommandManager = null; - DocumentManager = null; - EditorManager = null; - FileSystem = null; - FindInFiles = null; - $ = null; - testWindow = null; - SpecRunnerUtils.closeTestWindow(); - }); - - function openSearchBar(scope) { - // Make sure search bar from previous test has animated out fully - runs(function () { - waitsFor(function () { - return $(".modal-bar").length === 0; - }, "search bar close"); - }); - runs(function () { - FindInFiles._doFindInFiles(scope); - }); - } - - function executeSearch(searchString) { - var $searchField = $(".modal-bar #find-group input"); - $searchField.val(searchString).trigger("input"); - SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_RETURN, "keydown", $searchField[0]); - waitsFor(function () { - return FindInFiles._searchResults; - }, "Find in Files done"); - } - - - it("should find all occurences in project", function () { - openSearchBar(); - runs(function () { - executeSearch("foo"); - }); - - runs(function () { - var fileResults = FindInFiles._searchResults[testPath + "/bar.txt"]; - expect(fileResults).toBeFalsy(); - - fileResults = FindInFiles._searchResults[testPath + "/foo.html"]; - expect(fileResults).toBeTruthy(); - expect(fileResults.matches.length).toBe(7); - - fileResults = FindInFiles._searchResults[testPath + "/foo.js"]; - expect(fileResults).toBeTruthy(); - expect(fileResults.matches.length).toBe(4); - - fileResults = FindInFiles._searchResults[testPath + "/css/foo.css"]; - expect(fileResults).toBeTruthy(); - expect(fileResults.matches.length).toBe(3); - }); - }); - - it("should ignore binary files", function () { - var $dlg, actualMessage, expectedMessage, - exists = false, - done = false, - imageDirPath = testPath + "/images"; - - runs(function () { - // Set project to have only images - SpecRunnerUtils.loadProjectInTestWindow(imageDirPath); - - // Verify an image exists in folder - var file = FileSystem.getFileForPath(testPath + "/images/icon_twitter.png"); - - file.exists(function (fileError, fileExists) { - exists = fileExists; - done = true; - }); - }); - - waitsFor(function () { - return done; - }, "file.exists"); - - runs(function () { - expect(exists).toBe(true); - openSearchBar(); - }); - - runs(function () { - // Launch filter editor - $(".filter-picker button").click(); - - // Dialog should state there are 0 files in project - $dlg = $(".modal"); - expectedMessage = StringUtils.format(Strings.FILTER_FILE_COUNT_ALL, 0, Strings.FIND_IN_FILES_NO_SCOPE); - }); - - // Message loads asynchronously, but dialog should evetually state: "Allows all 0 files in project" - waitsFor(function () { - actualMessage = $dlg.find(".exclusions-filecount").text(); - return (actualMessage === expectedMessage); - }, "display file count"); - - runs(function () { - // Dismiss filter dialog - $dlg.find(".btn.primary").click(); - - // Close search bar - var $searchField = $(".modal-bar #find-group input"); - SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", $searchField[0]); - }); - - runs(function () { - // Set project back to main test folder - SpecRunnerUtils.loadProjectInTestWindow(testPath); - }); - }); - - it("should find all occurences in folder", function () { - var dirEntry = FileSystem.getDirectoryForPath(testPath + "/css/"); - openSearchBar(dirEntry); - runs(function () { - executeSearch("foo"); - }); - - runs(function () { - var fileResults = FindInFiles._searchResults[testPath + "/bar.txt"]; - expect(fileResults).toBeFalsy(); - - fileResults = FindInFiles._searchResults[testPath + "/foo.html"]; - expect(fileResults).toBeFalsy(); - - fileResults = FindInFiles._searchResults[testPath + "/foo.js"]; - expect(fileResults).toBeFalsy(); - - fileResults = FindInFiles._searchResults[testPath + "/css/foo.css"]; - expect(fileResults).toBeTruthy(); - expect(fileResults.matches.length).toBe(3); - }); - }); - - it("should find all occurences in single file", function () { - var fileEntry = FileSystem.getFileForPath(testPath + "/foo.js"); - openSearchBar(fileEntry); - runs(function () { - executeSearch("foo"); - }); - - runs(function () { - var fileResults = FindInFiles._searchResults[testPath + "/bar.txt"]; - expect(fileResults).toBeFalsy(); - - fileResults = FindInFiles._searchResults[testPath + "/foo.html"]; - expect(fileResults).toBeFalsy(); - - fileResults = FindInFiles._searchResults[testPath + "/foo.js"]; - expect(fileResults).toBeTruthy(); - expect(fileResults.matches.length).toBe(4); - - fileResults = FindInFiles._searchResults[testPath + "/css/foo.css"]; - expect(fileResults).toBeFalsy(); - }); - }); - - it("should find start and end positions", function () { - var filePath = testPath + "/foo.js", - fileEntry = FileSystem.getFileForPath(filePath); - - openSearchBar(fileEntry); - runs(function () { - executeSearch("callFoo"); - }); - - runs(function () { - var fileResults = FindInFiles._searchResults[filePath]; - expect(fileResults).toBeTruthy(); - expect(fileResults.matches.length).toBe(1); - - var match = fileResults.matches[0]; - expect(match.start.ch).toBe(13); - expect(match.start.line).toBe(6); - expect(match.end.ch).toBe(20); - expect(match.end.line).toBe(6); - }); - }); - - it("should dismiss dialog and show panel when there are results", function () { - var filePath = testPath + "/foo.js", - fileEntry = FileSystem.getFileForPath(filePath); - - openSearchBar(fileEntry); - runs(function () { - executeSearch("callFoo"); - }); - - waitsFor(function () { - return ($(".modal-bar").length === 0); - }, "search bar close"); - - runs(function () { - var fileResults = FindInFiles._searchResults[filePath]; - expect(fileResults).toBeTruthy(); - expect($("#search-results").is(":visible")).toBeTruthy(); - expect($(".modal-bar").length).toBe(0); - }); - }); - - it("should keep dialog and not show panel when there are no results", function () { - var filePath = testPath + "/bar.txt", - fileEntry = FileSystem.getFileForPath(filePath); - - openSearchBar(fileEntry); - runs(function () { - executeSearch("abcdefghi"); - }); - - waitsFor(function () { - return (FindInFiles._searchResults); - }, "search complete"); - - runs(function () { - var result, resultFound = false; - - // verify _searchResults Object is empty - for (result in FindInFiles._searchResults) { - if (FindInFiles._searchResults.hasOwnProperty(result)) { - resultFound = true; - } - } - expect(resultFound).toBe(false); - - expect($("#search-results").is(":visible")).toBeFalsy(); - expect($(".modal-bar").length).toBe(1); - - // Close search bar - var $searchField = $(".modal-bar #find-group input"); - SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", $searchField[0]); - }); - }); - - it("should open file in editor and select text when a result is clicked", function () { - var filePath = testPath + "/foo.html", - fileEntry = FileSystem.getFileForPath(filePath); - - openSearchBar(fileEntry); - runs(function () { - executeSearch("foo"); - }); - - runs(function () { - // Verify no current document - var editor = EditorManager.getActiveEditor(); - expect(editor).toBeFalsy(); - - // Get panel - var $searchResults = $("#search-results"); - expect($searchResults.is(":visible")).toBeTruthy(); - - // Get list in panel - var $panelResults = $searchResults.find("table.bottom-panel-table tr"); - expect($panelResults.length).toBe(8); // 7 hits + 1 file section - - // First item in list is file section - expect($($panelResults[0]).hasClass("file-section")).toBeTruthy(); - - // Click second item which is first hit - var $firstHit = $($panelResults[1]); - expect($firstHit.hasClass("file-section")).toBeFalsy(); - $firstHit.click(); - - // Verify current document - editor = EditorManager.getActiveEditor(); - expect(editor.document.file.fullPath).toEqual(filePath); - - // Verify selection - expect(editor.getSelectedText().toLowerCase() === "foo"); - CommandManager.execute(Commands.FILE_CLOSE_ALL); - }); - }); - - it("should open file in working set when a result is double-clicked", function () { - var filePath = testPath + "/foo.js", - fileEntry = FileSystem.getFileForPath(filePath); - - openSearchBar(fileEntry); - runs(function () { - executeSearch("foo"); - }); - - runs(function () { - // Verify document is not yet in working set - expect(DocumentManager.findInWorkingSet(filePath)).toBe(-1); - - // Get list in panel - var $panelResults = $("#search-results table.bottom-panel-table tr"); - expect($panelResults.length).toBe(5); // 4 hits + 1 file section - - // Double-click second item which is first hit - var $firstHit = $($panelResults[1]); - expect($firstHit.hasClass("file-section")).toBeFalsy(); - $firstHit.dblclick(); - - // Verify document is now in working set - expect(DocumentManager.findInWorkingSet(filePath)).not.toBe(-1); - CommandManager.execute(Commands.FILE_CLOSE_ALL); - }); - }); - - it("should update results when a result in a file is edited", function () { - var filePath = testPath + "/foo.html", - fileEntry = FileSystem.getFileForPath(filePath), - panelListLen = 8, // 7 hits + 1 file section - $panelResults; - - openSearchBar(fileEntry); - runs(function () { - executeSearch("foo"); - }); - - runs(function () { - // Verify document is not yet in working set - expect(DocumentManager.findInWorkingSet(filePath)).toBe(-1); - - // Get list in panel - $panelResults = $("#search-results table.bottom-panel-table tr"); - expect($panelResults.length).toBe(panelListLen); - - // Double-click second item which is first hit - var $firstHit = $($panelResults[1]); - expect($firstHit.hasClass("file-section")).toBeFalsy(); - $firstHit.dblclick(); - - // Verify current document & selection - var editor = EditorManager.getActiveEditor(); - expect(editor.document.file.fullPath).toEqual(filePath); - expect(editor.getSelectedText().toLowerCase() === "foo"); - - // Edit text to remove hit from file - var sel = editor.getSelection(); - editor.document.replaceRange("Bar", sel.start, sel.end); - }); - - // Panel is updated asynchronously - waitsFor(function () { - $panelResults = $("#search-results table.bottom-panel-table tr"); - return ($panelResults.length < panelListLen); - }, "Results panel updated"); - - runs(function () { - // Verify list automatically updated - expect($panelResults.length).toBe(panelListLen - 1); - - CommandManager.execute(Commands.FILE_CLOSE_ALL); - }); - }); - }); });