diff options
Diffstat (limited to 'devtools/client/debugger/views/global-search-view.js')
-rw-r--r-- | devtools/client/debugger/views/global-search-view.js | 756 |
1 files changed, 756 insertions, 0 deletions
diff --git a/devtools/client/debugger/views/global-search-view.js b/devtools/client/debugger/views/global-search-view.js new file mode 100644 index 000000000..c6a627971 --- /dev/null +++ b/devtools/client/debugger/views/global-search-view.js @@ -0,0 +1,756 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* import-globals-from ../debugger-controller.js */ +/* import-globals-from ../debugger-view.js */ +/* import-globals-from ../utils.js */ +/* globals document, window */ +"use strict"; + +/** + * Functions handling the global search UI. + */ +function GlobalSearchView(DebuggerController, DebuggerView) { + dumpn("GlobalSearchView was instantiated"); + + this.SourceScripts = DebuggerController.SourceScripts; + this.DebuggerView = DebuggerView; + + this._onHeaderClick = this._onHeaderClick.bind(this); + this._onLineClick = this._onLineClick.bind(this); + this._onMatchClick = this._onMatchClick.bind(this); +} + +GlobalSearchView.prototype = Heritage.extend(WidgetMethods, { + /** + * Initialization function, called when the debugger is started. + */ + initialize: function () { + dumpn("Initializing the GlobalSearchView"); + + this.widget = new SimpleListWidget(document.getElementById("globalsearch")); + this._splitter = document.querySelector("#globalsearch + .devtools-horizontal-splitter"); + + this.emptyText = L10N.getStr("noMatchingStringsText"); + }, + + /** + * Destruction function, called when the debugger is closed. + */ + destroy: function () { + dumpn("Destroying the GlobalSearchView"); + }, + + /** + * Sets the results container hidden or visible. It's hidden by default. + * @param boolean aFlag + */ + set hidden(aFlag) { + this.widget.setAttribute("hidden", aFlag); + this._splitter.setAttribute("hidden", aFlag); + }, + + /** + * Gets the visibility state of the global search container. + * @return boolean + */ + get hidden() { + return this.widget.getAttribute("hidden") == "true" || + this._splitter.getAttribute("hidden") == "true"; + }, + + /** + * Hides and removes all items from this search container. + */ + clearView: function () { + this.hidden = true; + this.empty(); + }, + + /** + * Selects the next found item in this container. + * Does not change the currently focused node. + */ + selectNext: function () { + let totalLineResults = LineResults.size(); + if (!totalLineResults) { + return; + } + if (++this._currentlyFocusedMatch >= totalLineResults) { + this._currentlyFocusedMatch = 0; + } + this._onMatchClick({ + target: LineResults.getElementAtIndex(this._currentlyFocusedMatch) + }); + }, + + /** + * Selects the previously found item in this container. + * Does not change the currently focused node. + */ + selectPrev: function () { + let totalLineResults = LineResults.size(); + if (!totalLineResults) { + return; + } + if (--this._currentlyFocusedMatch < 0) { + this._currentlyFocusedMatch = totalLineResults - 1; + } + this._onMatchClick({ + target: LineResults.getElementAtIndex(this._currentlyFocusedMatch) + }); + }, + + /** + * Schedules searching for a string in all of the sources. + * + * @param string aToken + * The string to search for. + * @param number aWait + * The amount of milliseconds to wait until draining. + */ + scheduleSearch: function (aToken, aWait) { + // The amount of time to wait for the requests to settle. + let maxDelay = GLOBAL_SEARCH_ACTION_MAX_DELAY; + let delay = aWait === undefined ? maxDelay / aToken.length : aWait; + + // Allow requests to settle down first. + setNamedTimeout("global-search", delay, () => { + // Start fetching as many sources as possible, then perform the search. + let actors = this.DebuggerView.Sources.values; + let sourcesFetched = DebuggerController.dispatch(actions.getTextForSources(actors)); + sourcesFetched.then(aSources => this._doSearch(aToken, aSources)); + }); + }, + + /** + * Finds string matches in all the sources stored in the controller's cache, + * and groups them by url and line number. + * + * @param string aToken + * The string to search for. + * @param array aSources + * An array of [url, text] tuples for each source. + */ + _doSearch: function (aToken, aSources) { + // Don't continue filtering if the searched token is an empty string. + if (!aToken) { + this.clearView(); + return; + } + + // Search is not case sensitive, prepare the actual searched token. + let lowerCaseToken = aToken.toLowerCase(); + let tokenLength = aToken.length; + + // Create a Map containing search details for each source. + let globalResults = new GlobalResults(); + + // Search for the specified token in each source's text. + for (let [actor, text] of aSources) { + let item = this.DebuggerView.Sources.getItemByValue(actor); + let url = item.attachment.source.url; + if (!url) { + continue; + } + + // Verify that the search token is found anywhere in the source. + if (!text.toLowerCase().includes(lowerCaseToken)) { + continue; + } + // ...and if so, create a Map containing search details for each line. + let sourceResults = new SourceResults(actor, + globalResults, + this.DebuggerView.Sources); + + // Search for the specified token in each line's text. + text.split("\n").forEach((aString, aLine) => { + // Search is not case sensitive, prepare the actual searched line. + let lowerCaseLine = aString.toLowerCase(); + + // Verify that the search token is found anywhere in this line. + if (!lowerCaseLine.includes(lowerCaseToken)) { + return; + } + // ...and if so, create a Map containing search details for each word. + let lineResults = new LineResults(aLine, sourceResults); + + // Search for the specified token this line's text. + lowerCaseLine.split(lowerCaseToken).reduce((aPrev, aCurr, aIndex, aArray) => { + let prevLength = aPrev.length; + let currLength = aCurr.length; + + // Everything before the token is unmatched. + let unmatched = aString.substr(prevLength, currLength); + lineResults.add(unmatched); + + // The lowered-case line was split by the lowered-case token. So, + // get the actual matched text from the original line's text. + if (aIndex != aArray.length - 1) { + let matched = aString.substr(prevLength + currLength, tokenLength); + let range = { start: prevLength + currLength, length: matched.length }; + lineResults.add(matched, range, true); + } + + // Continue with the next sub-region in this line's text. + return aPrev + aToken + aCurr; + }, ""); + + if (lineResults.matchCount) { + sourceResults.add(lineResults); + } + }); + + if (sourceResults.matchCount) { + globalResults.add(sourceResults); + } + } + + // Rebuild the results, then signal if there are any matches. + if (globalResults.matchCount) { + this.hidden = false; + this._currentlyFocusedMatch = -1; + this._createGlobalResultsUI(globalResults); + window.emit(EVENTS.GLOBAL_SEARCH_MATCH_FOUND); + } else { + window.emit(EVENTS.GLOBAL_SEARCH_MATCH_NOT_FOUND); + } + }, + + /** + * Creates global search results entries and adds them to this container. + * + * @param GlobalResults aGlobalResults + * An object containing all source results, grouped by source location. + */ + _createGlobalResultsUI: function (aGlobalResults) { + let i = 0; + + for (let sourceResults of aGlobalResults) { + if (i++ == 0) { + this._createSourceResultsUI(sourceResults); + } else { + // Dispatch subsequent document manipulation operations, to avoid + // blocking the main thread when a large number of search results + // is found, thus giving the impression of faster searching. + Services.tm.currentThread.dispatch({ run: + this._createSourceResultsUI.bind(this, sourceResults) + }, 0); + } + } + }, + + /** + * Creates source search results entries and adds them to this container. + * + * @param SourceResults aSourceResults + * An object containing all the matched lines for a specific source. + */ + _createSourceResultsUI: function (aSourceResults) { + // Create the element node for the source results item. + let container = document.createElement("hbox"); + aSourceResults.createView(container, { + onHeaderClick: this._onHeaderClick, + onLineClick: this._onLineClick, + onMatchClick: this._onMatchClick + }); + + // Append a source results item to this container. + let item = this.push([container], { + index: -1, /* specifies on which position should the item be appended */ + attachment: { + sourceResults: aSourceResults + } + }); + }, + + /** + * The click listener for a results header. + */ + _onHeaderClick: function (e) { + let sourceResultsItem = SourceResults.getItemForElement(e.target); + sourceResultsItem.instance.toggle(e); + }, + + /** + * The click listener for a results line. + */ + _onLineClick: function (e) { + let lineResultsItem = LineResults.getItemForElement(e.target); + this._onMatchClick({ target: lineResultsItem.firstMatch }); + }, + + /** + * The click listener for a result match. + */ + _onMatchClick: function (e) { + if (e instanceof Event) { + e.preventDefault(); + e.stopPropagation(); + } + + let target = e.target; + let sourceResultsItem = SourceResults.getItemForElement(target); + let lineResultsItem = LineResults.getItemForElement(target); + + sourceResultsItem.instance.expand(); + this._currentlyFocusedMatch = LineResults.indexOfElement(target); + this._scrollMatchIntoViewIfNeeded(target); + this._bounceMatch(target); + + let actor = sourceResultsItem.instance.actor; + let line = lineResultsItem.instance.line; + + this.DebuggerView.setEditorLocation(actor, line + 1, { noDebug: true }); + + let range = lineResultsItem.lineData.range; + let cursor = this.DebuggerView.editor.getOffset({ line: line, ch: 0 }); + let [ anchor, head ] = this.DebuggerView.editor.getPosition( + cursor + range.start, + cursor + range.start + range.length + ); + + this.DebuggerView.editor.setSelection(anchor, head); + }, + + /** + * Scrolls a match into view if not already visible. + * + * @param nsIDOMNode aMatch + * The match to scroll into view. + */ + _scrollMatchIntoViewIfNeeded: function (aMatch) { + this.widget.ensureElementIsVisible(aMatch); + }, + + /** + * Starts a bounce animation for a match. + * + * @param nsIDOMNode aMatch + * The match to start a bounce animation for. + */ + _bounceMatch: function (aMatch) { + Services.tm.currentThread.dispatch({ run: () => { + aMatch.addEventListener("transitionend", function onEvent() { + aMatch.removeEventListener("transitionend", onEvent); + aMatch.removeAttribute("focused"); + }); + aMatch.setAttribute("focused", ""); + }}, 0); + aMatch.setAttribute("focusing", ""); + }, + + _splitter: null, + _currentlyFocusedMatch: -1, + _forceExpandResults: false +}); + +DebuggerView.GlobalSearch = new GlobalSearchView(DebuggerController, DebuggerView); + +/** + * An object containing all source results, grouped by source location. + * Iterable via "for (let [location, sourceResults] of globalResults) { }". + */ +function GlobalResults() { + this._store = []; + SourceResults._itemsByElement = new Map(); + LineResults._itemsByElement = new Map(); +} + +GlobalResults.prototype = { + /** + * Adds source results to this store. + * + * @param SourceResults aSourceResults + * An object containing search results for a specific source. + */ + add: function (aSourceResults) { + this._store.push(aSourceResults); + }, + + /** + * Gets the number of source results in this store. + */ + get matchCount() { + return this._store.length; + } +}; + +/** + * An object containing all the matched lines for a specific source. + * Iterable via "for (let [lineNumber, lineResults] of sourceResults) { }". + * + * @param string aActor + * The target source actor id. + * @param GlobalResults aGlobalResults + * An object containing all source results, grouped by source location. + */ +function SourceResults(aActor, aGlobalResults, sourcesView) { + let item = sourcesView.getItemByValue(aActor); + this.actor = aActor; + this.label = item.attachment.source.url; + this._globalResults = aGlobalResults; + this._store = []; +} + +SourceResults.prototype = { + /** + * Adds line results to this store. + * + * @param LineResults aLineResults + * An object containing search results for a specific line. + */ + add: function (aLineResults) { + this._store.push(aLineResults); + }, + + /** + * Gets the number of line results in this store. + */ + get matchCount() { + return this._store.length; + }, + + /** + * Expands the element, showing all the added details. + */ + expand: function () { + this._resultsContainer.removeAttribute("hidden"); + this._arrow.setAttribute("open", ""); + }, + + /** + * Collapses the element, hiding all the added details. + */ + collapse: function () { + this._resultsContainer.setAttribute("hidden", "true"); + this._arrow.removeAttribute("open"); + }, + + /** + * Toggles between the element collapse/expand state. + */ + toggle: function (e) { + this.expanded ^= 1; + }, + + /** + * Gets this element's expanded state. + * @return boolean + */ + get expanded() { + return this._resultsContainer.getAttribute("hidden") != "true" && + this._arrow.hasAttribute("open"); + }, + + /** + * Sets this element's expanded state. + * @param boolean aFlag + */ + set expanded(aFlag) { + this[aFlag ? "expand" : "collapse"](); + }, + + /** + * Gets the element associated with this item. + * @return nsIDOMNode + */ + get target() { + return this._target; + }, + + /** + * Customization function for creating this item's UI. + * + * @param nsIDOMNode aElementNode + * The element associated with the displayed item. + * @param object aCallbacks + * An object containing all the necessary callback functions: + * - onHeaderClick + * - onMatchClick + */ + createView: function (aElementNode, aCallbacks) { + this._target = aElementNode; + + let arrow = this._arrow = document.createElement("box"); + arrow.className = "arrow"; + + let locationNode = document.createElement("label"); + locationNode.className = "plain dbg-results-header-location"; + locationNode.setAttribute("value", this.label); + + let matchCountNode = document.createElement("label"); + matchCountNode.className = "plain dbg-results-header-match-count"; + matchCountNode.setAttribute("value", "(" + this.matchCount + ")"); + + let resultsHeader = this._resultsHeader = document.createElement("hbox"); + resultsHeader.className = "dbg-results-header"; + resultsHeader.setAttribute("align", "center"); + resultsHeader.appendChild(arrow); + resultsHeader.appendChild(locationNode); + resultsHeader.appendChild(matchCountNode); + resultsHeader.addEventListener("click", aCallbacks.onHeaderClick, false); + + let resultsContainer = this._resultsContainer = document.createElement("vbox"); + resultsContainer.className = "dbg-results-container"; + resultsContainer.setAttribute("hidden", "true"); + + // Create lines search results entries and add them to this container. + // Afterwards, if the number of matches is reasonable, expand this + // container automatically. + for (let lineResults of this._store) { + lineResults.createView(resultsContainer, aCallbacks); + } + if (this.matchCount < GLOBAL_SEARCH_EXPAND_MAX_RESULTS) { + this.expand(); + } + + let resultsBox = document.createElement("vbox"); + resultsBox.setAttribute("flex", "1"); + resultsBox.appendChild(resultsHeader); + resultsBox.appendChild(resultsContainer); + + aElementNode.id = "source-results-" + this.actor; + aElementNode.className = "dbg-source-results"; + aElementNode.appendChild(resultsBox); + + SourceResults._itemsByElement.set(aElementNode, { instance: this }); + }, + + actor: "", + _globalResults: null, + _store: null, + _target: null, + _arrow: null, + _resultsHeader: null, + _resultsContainer: null +}; + +/** + * An object containing all the matches for a specific line. + * Iterable via "for (let chunk of lineResults) { }". + * + * @param number aLine + * The target line in the source. + * @param SourceResults aSourceResults + * An object containing all the matched lines for a specific source. + */ +function LineResults(aLine, aSourceResults) { + this.line = aLine; + this._sourceResults = aSourceResults; + this._store = []; + this._matchCount = 0; +} + +LineResults.prototype = { + /** + * Adds string details to this store. + * + * @param string aString + * The text contents chunk in the line. + * @param object aRange + * An object containing the { start, length } of the chunk. + * @param boolean aMatchFlag + * True if the chunk is a matched string, false if just text content. + */ + add: function (aString, aRange, aMatchFlag) { + this._store.push({ string: aString, range: aRange, match: !!aMatchFlag }); + this._matchCount += aMatchFlag ? 1 : 0; + }, + + /** + * Gets the number of word results in this store. + */ + get matchCount() { + return this._matchCount; + }, + + /** + * Gets the element associated with this item. + * @return nsIDOMNode + */ + get target() { + return this._target; + }, + + /** + * Customization function for creating this item's UI. + * + * @param nsIDOMNode aElementNode + * The element associated with the displayed item. + * @param object aCallbacks + * An object containing all the necessary callback functions: + * - onMatchClick + * - onLineClick + */ + createView: function (aElementNode, aCallbacks) { + this._target = aElementNode; + + let lineNumberNode = document.createElement("label"); + lineNumberNode.className = "plain dbg-results-line-number"; + lineNumberNode.classList.add("devtools-monospace"); + lineNumberNode.setAttribute("value", this.line + 1); + + let lineContentsNode = document.createElement("hbox"); + lineContentsNode.className = "dbg-results-line-contents"; + lineContentsNode.classList.add("devtools-monospace"); + lineContentsNode.setAttribute("flex", "1"); + + let lineString = ""; + let lineLength = 0; + let firstMatch = null; + + for (let lineChunk of this._store) { + let { string, range, match } = lineChunk; + lineString = string.substr(0, GLOBAL_SEARCH_LINE_MAX_LENGTH - lineLength); + lineLength += string.length; + + let lineChunkNode = document.createElement("label"); + lineChunkNode.className = "plain dbg-results-line-contents-string"; + lineChunkNode.setAttribute("value", lineString); + lineChunkNode.setAttribute("match", match); + lineContentsNode.appendChild(lineChunkNode); + + if (match) { + this._entangleMatch(lineChunkNode, lineChunk); + lineChunkNode.addEventListener("click", aCallbacks.onMatchClick, false); + firstMatch = firstMatch || lineChunkNode; + } + if (lineLength >= GLOBAL_SEARCH_LINE_MAX_LENGTH) { + lineContentsNode.appendChild(this._ellipsis.cloneNode(true)); + break; + } + } + + this._entangleLine(lineContentsNode, firstMatch); + lineContentsNode.addEventListener("click", aCallbacks.onLineClick, false); + + let searchResult = document.createElement("hbox"); + searchResult.className = "dbg-search-result"; + searchResult.appendChild(lineNumberNode); + searchResult.appendChild(lineContentsNode); + + aElementNode.appendChild(searchResult); + }, + + /** + * Handles a match while creating the view. + * @param nsIDOMNode aNode + * @param object aMatchChunk + */ + _entangleMatch: function (aNode, aMatchChunk) { + LineResults._itemsByElement.set(aNode, { + instance: this, + lineData: aMatchChunk + }); + }, + + /** + * Handles a line while creating the view. + * @param nsIDOMNode aNode + * @param nsIDOMNode aFirstMatch + */ + _entangleLine: function (aNode, aFirstMatch) { + LineResults._itemsByElement.set(aNode, { + instance: this, + firstMatch: aFirstMatch, + ignored: true + }); + }, + + /** + * An nsIDOMNode label with an ellipsis value. + */ + _ellipsis: (function () { + let label = document.createElement("label"); + label.className = "plain dbg-results-line-contents-string"; + label.setAttribute("value", ELLIPSIS); + return label; + })(), + + line: 0, + _sourceResults: null, + _store: null, + _target: null +}; + +/** + * A generator-iterator over the global, source or line results. + */ +GlobalResults.prototype[Symbol.iterator] = +SourceResults.prototype[Symbol.iterator] = +LineResults.prototype[Symbol.iterator] = function* () { + yield* this._store; +}; + +/** + * Gets the item associated with the specified element. + * + * @param nsIDOMNode aElement + * The element used to identify the item. + * @return object + * The matched item, or null if nothing is found. + */ +SourceResults.getItemForElement = +LineResults.getItemForElement = function (aElement) { + return WidgetMethods.getItemForElement.call(this, aElement, { noSiblings: true }); +}; + +/** + * Gets the element associated with a particular item at a specified index. + * + * @param number aIndex + * The index used to identify the item. + * @return nsIDOMNode + * The matched element, or null if nothing is found. + */ +SourceResults.getElementAtIndex = +LineResults.getElementAtIndex = function (aIndex) { + for (let [element, item] of this._itemsByElement) { + if (!item.ignored && !aIndex--) { + return element; + } + } + return null; +}; + +/** + * Gets the index of an item associated with the specified element. + * + * @param nsIDOMNode aElement + * The element to get the index for. + * @return number + * The index of the matched element, or -1 if nothing is found. + */ +SourceResults.indexOfElement = +LineResults.indexOfElement = function (aElement) { + let count = 0; + for (let [element, item] of this._itemsByElement) { + if (element == aElement) { + return count; + } + if (!item.ignored) { + count++; + } + } + return -1; +}; + +/** + * Gets the number of cached items associated with a specified element. + * + * @return number + * The number of key/value pairs in the corresponding map. + */ +SourceResults.size = +LineResults.size = function () { + let count = 0; + for (let [, item] of this._itemsByElement) { + if (!item.ignored) { + count++; + } + } + return count; +}; |