tenfourfox/devtools/client/debugger/views/global-search-view.js
Cameron Kaiser c9b2922b70 hello FPR
2017-04-19 00:56:45 -07:00

755 lines
22 KiB
JavaScript

/* 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", L10N.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;
};