summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/views/global-search-view.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/views/global-search-view.js')
-rw-r--r--devtools/client/debugger/views/global-search-view.js756
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;
+};