diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/client/debugger/content | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/debugger/content')
18 files changed, 2868 insertions, 0 deletions
diff --git a/devtools/client/debugger/content/actions/breakpoints.js b/devtools/client/debugger/content/actions/breakpoints.js new file mode 100644 index 000000000..5c5552d78 --- /dev/null +++ b/devtools/client/debugger/content/actions/breakpoints.js @@ -0,0 +1,191 @@ +/* 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/. */ +"use strict"; + +const constants = require("../constants"); +const promise = require("promise"); +const { asPaused } = require("../utils"); +const { PROMISE } = require("devtools/client/shared/redux/middleware/promise"); +const { + getSource, getBreakpoint, getBreakpoints, makeLocationId +} = require("../queries"); +const { Task } = require("devtools/shared/task"); + +// Because breakpoints are just simple data structures, we still need +// a way to lookup the actual client instance to talk to the server. +// We keep an internal database of clients based off of actor ID. +const BREAKPOINT_CLIENT_STORE = new Map(); + +function setBreakpointClient(actor, client) { + BREAKPOINT_CLIENT_STORE.set(actor, client); +} + +function getBreakpointClient(actor) { + return BREAKPOINT_CLIENT_STORE.get(actor); +} + +function enableBreakpoint(location) { + // Enabling is exactly the same as adding. It will use the existing + // breakpoint that still stored. + return addBreakpoint(location); +} + +function _breakpointExists(state, location) { + const currentBp = getBreakpoint(state, location); + return currentBp && !currentBp.disabled; +} + +function _getOrCreateBreakpoint(state, location, condition) { + return getBreakpoint(state, location) || { location, condition }; +} + +function addBreakpoint(location, condition) { + return (dispatch, getState) => { + if (_breakpointExists(getState(), location)) { + return; + } + + const bp = _getOrCreateBreakpoint(getState(), location, condition); + + return dispatch({ + type: constants.ADD_BREAKPOINT, + breakpoint: bp, + condition: condition, + [PROMISE]: Task.spawn(function* () { + const sourceClient = gThreadClient.source( + getSource(getState(), bp.location.actor) + ); + const [response, bpClient] = yield sourceClient.setBreakpoint({ + line: bp.location.line, + column: bp.location.column, + condition: bp.condition + }); + const { isPending, actualLocation } = response; + + // Save the client instance + setBreakpointClient(bpClient.actor, bpClient); + + return { + text: DebuggerView.editor.getText( + (actualLocation ? actualLocation.line : bp.location.line) - 1 + ).trim(), + + // If the breakpoint response has an "actualLocation" attached, then + // the original requested placement for the breakpoint wasn't + // accepted. + actualLocation: isPending ? null : actualLocation, + actor: bpClient.actor + }; + }) + }); + }; +} + +function disableBreakpoint(location) { + return _removeOrDisableBreakpoint(location, true); +} + +function removeBreakpoint(location) { + return _removeOrDisableBreakpoint(location); +} + +function _removeOrDisableBreakpoint(location, isDisabled) { + return (dispatch, getState) => { + let bp = getBreakpoint(getState(), location); + if (!bp) { + throw new Error("attempt to remove breakpoint that does not exist"); + } + if (bp.loading) { + // TODO(jwl): make this wait until the breakpoint is saved if it + // is still loading + throw new Error("attempt to remove unsaved breakpoint"); + } + + const bpClient = getBreakpointClient(bp.actor); + const action = { + type: constants.REMOVE_BREAKPOINT, + breakpoint: bp, + disabled: isDisabled + }; + + // If the breakpoint is already disabled, we don't need to remove + // it from the server. We just need to dispatch an action + // simulating a successful server request to remove it, and it + // will be removed completely from the state. + if (!bp.disabled) { + return dispatch(Object.assign({}, action, { + [PROMISE]: bpClient.remove() + })); + } else { + return dispatch(Object.assign({}, action, { status: "done" })); + } + }; +} + +function removeAllBreakpoints() { + return (dispatch, getState) => { + const breakpoints = getBreakpoints(getState()); + const activeBreakpoints = breakpoints.filter(bp => !bp.disabled); + activeBreakpoints.forEach(bp => removeBreakpoint(bp.location)); + }; +} + +/** + * Update the condition of a breakpoint. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + * @param string aClients + * The condition to set on the breakpoint + * @return object + * A promise that will be resolved with the breakpoint client + */ +function setBreakpointCondition(location, condition) { + return (dispatch, getState) => { + const bp = getBreakpoint(getState(), location); + if (!bp) { + throw new Error("Breakpoint does not exist at the specified location"); + } + if (bp.loading) { + // TODO(jwl): when this function is called, make sure the action + // creator waits for the breakpoint to exist + throw new Error("breakpoint must be saved"); + } + + const bpClient = getBreakpointClient(bp.actor); + const action = { + type: constants.SET_BREAKPOINT_CONDITION, + breakpoint: bp, + condition: condition + }; + + // If it's not disabled, we need to update the condition on the + // server. Otherwise, just dispatch a non-remote action that + // updates the condition locally. + if (!bp.disabled) { + return dispatch(Object.assign({}, action, { + [PROMISE]: Task.spawn(function* () { + const newClient = yield bpClient.setCondition(gThreadClient, condition); + + // Remove the old instance and save the new one + setBreakpointClient(bpClient.actor, null); + setBreakpointClient(newClient.actor, newClient); + + return { actor: newClient.actor }; + }) + })); + } else { + return dispatch(action); + } + }; +} + +module.exports = { + enableBreakpoint, + addBreakpoint, + disableBreakpoint, + removeBreakpoint, + removeAllBreakpoints, + setBreakpointCondition +}; diff --git a/devtools/client/debugger/content/actions/event-listeners.js b/devtools/client/debugger/content/actions/event-listeners.js new file mode 100644 index 000000000..4bca557fe --- /dev/null +++ b/devtools/client/debugger/content/actions/event-listeners.js @@ -0,0 +1,118 @@ +/* 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/. */ +"use strict"; + +const constants = require("../constants"); +const { asPaused } = require("../utils"); +const { reportException } = require("devtools/shared/DevToolsUtils"); +const { setNamedTimeout } = require("devtools/client/shared/widgets/view-helpers"); +const { Task } = require("devtools/shared/task"); + +const FETCH_EVENT_LISTENERS_DELAY = 200; // ms + +function fetchEventListeners() { + return (dispatch, getState) => { + // Make sure we"re not sending a batch of closely repeated requests. + // This can easily happen whenever new sources are fetched. + setNamedTimeout("event-listeners-fetch", FETCH_EVENT_LISTENERS_DELAY, () => { + // In case there is still a request of listeners going on (it + // takes several RDP round trips right now), make sure we wait + // on a currently running request + if (getState().eventListeners.fetchingListeners) { + dispatch({ + type: services.WAIT_UNTIL, + predicate: action => ( + action.type === constants.FETCH_EVENT_LISTENERS && + action.status === "done" + ), + run: dispatch => dispatch(fetchEventListeners()) + }); + return; + } + + dispatch({ + type: constants.FETCH_EVENT_LISTENERS, + status: "begin" + }); + + asPaused(gThreadClient, _getListeners).then(listeners => { + // Notify that event listeners were fetched and shown in the view, + // and callback to resume the active thread if necessary. + window.emit(EVENTS.EVENT_LISTENERS_FETCHED); + + dispatch({ + type: constants.FETCH_EVENT_LISTENERS, + status: "done", + listeners: listeners + }); + }); + }); + }; +} + +const _getListeners = Task.async(function* () { + const response = yield gThreadClient.eventListeners(); + + // Make sure all the listeners are sorted by the event type, since + // they"re not guaranteed to be clustered together. + response.listeners.sort((a, b) => a.type > b.type ? 1 : -1); + + // Add all the listeners in the debugger view event linsteners container. + let fetchedDefinitions = new Map(); + let listeners = []; + for (let listener of response.listeners) { + let definitionSite; + if (fetchedDefinitions.has(listener.function.actor)) { + definitionSite = fetchedDefinitions.get(listener.function.actor); + } else if (listener.function.class == "Function") { + definitionSite = yield _getDefinitionSite(listener.function); + if (!definitionSite) { + // We don"t know where this listener comes from so don"t show it in + // the UI as breaking on it doesn"t work (bug 942899). + continue; + } + + fetchedDefinitions.set(listener.function.actor, definitionSite); + } + listener.function.url = definitionSite; + listeners.push(listener); + } + fetchedDefinitions.clear(); + + return listeners; +}); + +const _getDefinitionSite = Task.async(function* (aFunction) { + const grip = gThreadClient.pauseGrip(aFunction); + let response; + + try { + response = yield grip.getDefinitionSite(); + } + catch (e) { + // Don't make this error fatal, because it would break the entire events pane. + reportException("_getDefinitionSite", e); + return null; + } + + return response.source.url; +}); + +function updateEventBreakpoints(eventNames) { + return dispatch => { + setNamedTimeout("event-breakpoints-update", 0, () => { + gThreadClient.pauseOnDOMEvents(eventNames, function () { + // Notify that event breakpoints were added/removed on the server. + window.emit(EVENTS.EVENT_BREAKPOINTS_UPDATED); + + dispatch({ + type: constants.UPDATE_EVENT_BREAKPOINTS, + eventNames: eventNames + }); + }); + }); + }; +} + +module.exports = { updateEventBreakpoints, fetchEventListeners }; diff --git a/devtools/client/debugger/content/actions/moz.build b/devtools/client/debugger/content/actions/moz.build new file mode 100644 index 000000000..13a2dd9ad --- /dev/null +++ b/devtools/client/debugger/content/actions/moz.build @@ -0,0 +1,10 @@ +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'breakpoints.js', + 'event-listeners.js', + 'sources.js' +) diff --git a/devtools/client/debugger/content/actions/sources.js b/devtools/client/debugger/content/actions/sources.js new file mode 100644 index 000000000..d7e0728e7 --- /dev/null +++ b/devtools/client/debugger/content/actions/sources.js @@ -0,0 +1,280 @@ +/* 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/. */ +"use strict"; + +const constants = require("../constants"); +const promise = require("promise"); +const Services = require("Services"); +const { dumpn } = require("devtools/shared/DevToolsUtils"); +const { PROMISE, HISTOGRAM_ID } = require("devtools/client/shared/redux/middleware/promise"); +const { getSource, getSourceText } = require("../queries"); +const { Task } = require("devtools/shared/task"); + +const NEW_SOURCE_IGNORED_URLS = ["debugger eval code", "XStringBundle"]; +const FETCH_SOURCE_RESPONSE_DELAY = 200; // ms + +function getSourceClient(source) { + return gThreadClient.source(source); +} + +/** + * Handler for the debugger client's unsolicited newSource notification. + */ +function newSource(source) { + return dispatch => { + // Ignore bogus scripts, e.g. generated from 'clientEvaluate' packets. + if (NEW_SOURCE_IGNORED_URLS.indexOf(source.url) != -1) { + return; + } + + // Signal that a new source has been added. + window.emit(EVENTS.NEW_SOURCE); + + return dispatch({ + type: constants.ADD_SOURCE, + source: source + }); + }; +} + +function selectSource(source, opts) { + return (dispatch, getState) => { + if (!gThreadClient) { + // No connection, do nothing. This happens when the debugger is + // shut down too fast and it tries to display a default source. + return; + } + + source = getSource(getState(), source.actor); + + // Make sure to start a request to load the source text. + dispatch(loadSourceText(source)); + + dispatch({ + type: constants.SELECT_SOURCE, + source: source, + opts: opts + }); + }; +} + +function loadSources() { + return { + type: constants.LOAD_SOURCES, + [PROMISE]: Task.spawn(function* () { + const response = yield gThreadClient.getSources(); + + // Top-level breakpoints may pause the entire loading process + // because scripts are executed as they are loaded, so the + // engine may pause in the middle of loading all the sources. + // This is relatively harmless, as individual `newSource` + // notifications are fired for each script and they will be + // added to the UI through that. + if (!response.sources) { + dumpn( + "Error getting sources, probably because a top-level " + + "breakpoint was hit while executing them" + ); + return; + } + + // Ignore bogus scripts, e.g. generated from 'clientEvaluate' packets. + return response.sources.filter(source => { + return NEW_SOURCE_IGNORED_URLS.indexOf(source.url) === -1; + }); + }) + }; +} + +/** + * Set the black boxed status of the given source. + * + * @param Object aSource + * The source form. + * @param bool aBlackBoxFlag + * True to black box the source, false to un-black box it. + * @returns Promise + * A promize that resolves to [aSource, isBlackBoxed] or rejects to + * [aSource, error]. + */ +function blackbox(source, shouldBlackBox) { + const client = getSourceClient(source); + + return { + type: constants.BLACKBOX, + source: source, + [PROMISE]: Task.spawn(function* () { + yield shouldBlackBox ? client.blackBox() : client.unblackBox(); + return { + isBlackBoxed: shouldBlackBox + }; + }) + }; +} + +/** + * Toggle the pretty printing of a source's text. All subsequent calls to + * |getText| will return the pretty-toggled text. Nothing will happen for + * non-javascript files. + * + * @param Object aSource + * The source form from the RDP. + * @returns Promise + * A promise that resolves to [aSource, prettyText] or rejects to + * [aSource, error]. + */ +function togglePrettyPrint(source) { + return (dispatch, getState) => { + const sourceClient = getSourceClient(source); + const wantPretty = !source.isPrettyPrinted; + + return dispatch({ + type: constants.TOGGLE_PRETTY_PRINT, + source: source, + [PROMISE]: Task.spawn(function* () { + let response; + + // Only attempt to pretty print JavaScript sources. + const sourceText = getSourceText(getState(), source.actor); + const contentType = sourceText ? sourceText.contentType : null; + if (!SourceUtils.isJavaScript(source.url, contentType)) { + throw new Error("Can't prettify non-javascript files."); + } + + if (wantPretty) { + response = yield sourceClient.prettyPrint(Prefs.editorTabSize); + } + else { + response = yield sourceClient.disablePrettyPrint(); + } + + // Remove the cached source AST from the Parser, to avoid getting + // wrong locations when searching for functions. + DebuggerController.Parser.clearSource(source.url); + + return { + isPrettyPrinted: wantPretty, + text: response.source, + contentType: response.contentType + }; + }) + }); + }; +} + +function loadSourceText(source) { + return (dispatch, getState) => { + // Fetch the source text only once. + let textInfo = getSourceText(getState(), source.actor); + if (textInfo) { + // It's already loaded or is loading + return promise.resolve(textInfo); + } + + const sourceClient = getSourceClient(source); + + return dispatch({ + type: constants.LOAD_SOURCE_TEXT, + source: source, + [PROMISE]: Task.spawn(function* () { + let transportType = gClient.localTransport ? "_LOCAL" : "_REMOTE"; + let histogramId = "DEVTOOLS_DEBUGGER_DISPLAY_SOURCE" + transportType + "_MS"; + let histogram = Services.telemetry.getHistogramById(histogramId); + let startTime = Date.now(); + + const response = yield sourceClient.source(); + + histogram.add(Date.now() - startTime); + + // Automatically pretty print if enabled and the test is + // detected to be "minified" + if (Prefs.autoPrettyPrint && + !source.isPrettyPrinted && + SourceUtils.isMinified(source.actor, response.source)) { + dispatch(togglePrettyPrint(source)); + } + + return { text: response.source, + contentType: response.contentType }; + }) + }); + }; +} + +/** + * Starts fetching all the sources, silently. + * + * @param array aUrls + * The urls for the sources to fetch. If fetching a source's text + * takes too long, it will be discarded. + * @return object + * A promise that is resolved after source texts have been fetched. + */ +function getTextForSources(actors) { + return (dispatch, getState) => { + let deferred = promise.defer(); + let pending = new Set(actors); + let fetched = []; + + // Can't use promise.all, because if one fetch operation is rejected, then + // everything is considered rejected, thus no other subsequent source will + // be getting fetched. We don't want that. Something like Q's allSettled + // would work like a charm here. + + // Try to fetch as many sources as possible. + for (let actor of actors) { + let source = getSource(getState(), actor); + dispatch(loadSourceText(source)).then(({ text, contentType }) => { + onFetch([source, text, contentType]); + }, err => { + onError(source, err); + }); + } + + setTimeout(onTimeout, FETCH_SOURCE_RESPONSE_DELAY); + + /* Called if fetching a source takes too long. */ + function onTimeout() { + pending = new Set(); + maybeFinish(); + } + + /* Called if fetching a source finishes successfully. */ + function onFetch([aSource, aText, aContentType]) { + // If fetching the source has previously timed out, discard it this time. + if (!pending.has(aSource.actor)) { + return; + } + pending.delete(aSource.actor); + fetched.push([aSource.actor, aText, aContentType]); + maybeFinish(); + } + + /* Called if fetching a source failed because of an error. */ + function onError([aSource, aError]) { + pending.delete(aSource.actor); + maybeFinish(); + } + + /* Called every time something interesting happens while fetching sources. */ + function maybeFinish() { + if (pending.size == 0) { + // Sort the fetched sources alphabetically by their url. + deferred.resolve(fetched.sort(([aFirst], [aSecond]) => aFirst > aSecond)); + } + } + + return deferred.promise; + }; +} + +module.exports = { + newSource, + selectSource, + loadSources, + blackbox, + togglePrettyPrint, + loadSourceText, + getTextForSources +}; diff --git a/devtools/client/debugger/content/constants.js b/devtools/client/debugger/content/constants.js new file mode 100644 index 000000000..0099477b7 --- /dev/null +++ b/devtools/client/debugger/content/constants.js @@ -0,0 +1,25 @@ +/* -*- 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/. */ +"use strict"; + +exports.UPDATE_EVENT_BREAKPOINTS = "UPDATE_EVENT_BREAKPOINTS"; +exports.FETCH_EVENT_LISTENERS = "FETCH_EVENT_LISTENERS"; + +exports.TOGGLE_PRETTY_PRINT = "TOGGLE_PRETTY_PRINT"; +exports.BLACKBOX = "BLACKBOX"; + +exports.ADD_BREAKPOINT = "ADD_BREAKPOINT"; +exports.REMOVE_BREAKPOINT = "REMOVE_BREAKPOINT"; +exports.ENABLE_BREAKPOINT = "ENABLE_BREAKPOINT"; +exports.DISABLE_BREAKPOINT = "DISABLE_BREAKPOINT"; +exports.SET_BREAKPOINT_CONDITION = "SET_BREAKPOINT_CONDITION"; + +exports.ADD_SOURCE = "ADD_SOURCE"; +exports.LOAD_SOURCES = "LOAD_SOURCES"; +exports.LOAD_SOURCE_TEXT = "LOAD_SOURCE_TEXT"; +exports.SELECT_SOURCE = "SELECT_SOURCE"; +exports.UNLOAD = "UNLOAD"; +exports.RELOAD = "RELOAD"; diff --git a/devtools/client/debugger/content/globalActions.js b/devtools/client/debugger/content/globalActions.js new file mode 100644 index 000000000..3f02be36e --- /dev/null +++ b/devtools/client/debugger/content/globalActions.js @@ -0,0 +1,18 @@ +/* -*- 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/. */ +"use strict"; + +const constants = require("./constants"); + +// Fired when the page is being unloaded, for example when it's being +// navigated away from. +function unload() { + return { + type: constants.UNLOAD + }; +} + +module.exports = { unload }; diff --git a/devtools/client/debugger/content/moz.build b/devtools/client/debugger/content/moz.build new file mode 100644 index 000000000..fcca58e65 --- /dev/null +++ b/devtools/client/debugger/content/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# 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/. + +DIRS += [ + 'actions', + 'reducers', + 'views', +] + +DevToolsModules( + 'constants.js', + 'globalActions.js', + 'queries.js', + 'utils.js' +) diff --git a/devtools/client/debugger/content/queries.js b/devtools/client/debugger/content/queries.js new file mode 100644 index 000000000..3a0c54b88 --- /dev/null +++ b/devtools/client/debugger/content/queries.js @@ -0,0 +1,70 @@ + +function getSource(state, actor) { + return state.sources.sources[actor]; +} + +function getSources(state) { + return state.sources.sources; +} + +function getSourceCount(state) { + return Object.keys(state.sources.sources).length; +} + +function getSourceByURL(state, url) { + for (let k in state.sources.sources) { + const source = state.sources.sources[k]; + if (source.url === url) { + return source; + } + } +} + +function getSourceByActor(state, actor) { + for (let k in state.sources.sources) { + const source = state.sources.sources[k]; + if (source.actor === actor) { + return source; + } + } +} + +function getSelectedSource(state) { + return state.sources.sources[state.sources.selectedSource]; +} + +function getSelectedSourceOpts(state) { + return state.sources.selectedSourceOpts; +} + +function getSourceText(state, actor) { + return state.sources.sourcesText[actor]; +} + +function getBreakpoints(state) { + return Object.keys(state.breakpoints.breakpoints).map(k => { + return state.breakpoints.breakpoints[k]; + }); +} + +function getBreakpoint(state, location) { + return state.breakpoints.breakpoints[makeLocationId(location)]; +} + +function makeLocationId(location) { + return location.actor + ":" + location.line.toString(); +} + +module.exports = { + getSource, + getSources, + getSourceCount, + getSourceByURL, + getSourceByActor, + getSelectedSource, + getSelectedSourceOpts, + getSourceText, + getBreakpoint, + getBreakpoints, + makeLocationId +}; diff --git a/devtools/client/debugger/content/reducers/async-requests.js b/devtools/client/debugger/content/reducers/async-requests.js new file mode 100644 index 000000000..206e1cf60 --- /dev/null +++ b/devtools/client/debugger/content/reducers/async-requests.js @@ -0,0 +1,31 @@ +/* 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/. */ +"use strict"; + +const constants = require("../constants"); +const initialState = []; + +function update(state = initialState, action, emitChange) { + const { seqId } = action; + + if (action.type === constants.UNLOAD) { + return initialState; + } + else if (seqId) { + let newState; + if (action.status === "start") { + newState = [...state, seqId]; + } + else if (action.status === "error" || action.status === "done") { + newState = state.filter(id => id !== seqId); + } + + emitChange("open-requests", newState); + return newState; + } + + return state; +} + +module.exports = update; diff --git a/devtools/client/debugger/content/reducers/breakpoints.js b/devtools/client/debugger/content/reducers/breakpoints.js new file mode 100644 index 000000000..7e42098e8 --- /dev/null +++ b/devtools/client/debugger/content/reducers/breakpoints.js @@ -0,0 +1,153 @@ +/* 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/. */ +"use strict"; + +const constants = require("../constants"); +const Immutable = require("devtools/client/shared/vendor/seamless-immutable"); +const { mergeIn, setIn, deleteIn } = require("../utils"); +const { makeLocationId } = require("../queries"); + +const initialState = Immutable({ + breakpoints: {} +}); + +// Return the first argument that is a string, or null if nothing is a +// string. +function firstString(...args) { + for (var arg of args) { + if (typeof arg === "string") { + return arg; + } + } + return null; +} + +function update(state = initialState, action, emitChange) { + switch (action.type) { + case constants.ADD_BREAKPOINT: { + const id = makeLocationId(action.breakpoint.location); + + if (action.status === "start") { + const existingBp = state.breakpoints[id]; + const bp = existingBp || Immutable(action.breakpoint); + + state = setIn(state, ["breakpoints", id], bp.merge({ + disabled: false, + loading: true, + // We want to do an OR here, but we can't because we need + // empty strings to be truthy, i.e. an empty string is a valid + // condition. + condition: firstString(action.condition, bp.condition) + })); + + emitChange(existingBp ? "breakpoint-enabled" : "breakpoint-added", + state.breakpoints[id]); + return state; + } + else if (action.status === "done") { + const { actor, text } = action.value; + let { actualLocation } = action.value; + + // If the breakpoint moved, update the map + if (actualLocation) { + // XXX Bug 1227417: The `setBreakpoint` RDP request rdp + // request returns an `actualLocation` field that doesn't + // conform to the regular { actor, line } location shape, but + // it has a `source` field. We should fix that. + actualLocation = { actor: actualLocation.source.actor, + line: actualLocation.line }; + + state = deleteIn(state, ["breakpoints", id]); + + const movedId = makeLocationId(actualLocation); + const currentBp = state.breakpoints[movedId] || Immutable(action.breakpoint); + const prevLocation = action.breakpoint.location; + const newBp = currentBp.merge({ location: actualLocation }); + state = setIn(state, ["breakpoints", movedId], newBp); + + emitChange("breakpoint-moved", { + breakpoint: newBp, + prevLocation: prevLocation + }); + } + + const finalLocation = ( + actualLocation ? actualLocation : action.breakpoint.location + ); + const finalLocationId = makeLocationId(finalLocation); + state = mergeIn(state, ["breakpoints", finalLocationId], { + disabled: false, + loading: false, + actor: actor, + text: text + }); + emitChange("breakpoint-updated", state.breakpoints[finalLocationId]); + return state; + } + else if (action.status === "error") { + // Remove the optimistic update + emitChange("breakpoint-removed", state.breakpoints[id]); + return deleteIn(state, ["breakpoints", id]); + } + break; + } + + case constants.REMOVE_BREAKPOINT: { + if (action.status === "done") { + const id = makeLocationId(action.breakpoint.location); + const bp = state.breakpoints[id]; + + if (action.disabled) { + state = mergeIn(state, ["breakpoints", id], + { loading: false, disabled: true }); + emitChange("breakpoint-disabled", state.breakpoints[id]); + return state; + } + + state = deleteIn(state, ["breakpoints", id]); + emitChange("breakpoint-removed", bp); + return state; + } + break; + } + + case constants.SET_BREAKPOINT_CONDITION: { + const id = makeLocationId(action.breakpoint.location); + const bp = state.breakpoints[id]; + emitChange("breakpoint-condition-updated", bp); + + if (!action.status) { + // No status means that it wasn't a remote request. Just update + // the condition locally. + return mergeIn(state, ["breakpoints", id], { + condition: action.condition + }); + } + else if (action.status === "start") { + return mergeIn(state, ["breakpoints", id], { + loading: true, + condition: action.condition + }); + } + else if (action.status === "done") { + return mergeIn(state, ["breakpoints", id], { + loading: false, + condition: action.condition, + // Setting a condition creates a new breakpoint client as of + // now, so we need to update the actor + actor: action.value.actor + }); + } + else if (action.status === "error") { + emitChange("breakpoint-removed", bp); + return deleteIn(state, ["breakpoints", id]); + } + + break; + }} + + return state; +} + +module.exports = update; diff --git a/devtools/client/debugger/content/reducers/event-listeners.js b/devtools/client/debugger/content/reducers/event-listeners.js new file mode 100644 index 000000000..fdd3da99d --- /dev/null +++ b/devtools/client/debugger/content/reducers/event-listeners.js @@ -0,0 +1,37 @@ +/* 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/. */ +"use strict"; + +const constants = require("../constants"); + +const FETCH_EVENT_LISTENERS_DELAY = 200; // ms + +const initialState = { + activeEventNames: [], + listeners: [], + fetchingListeners: false, +}; + +function update(state = initialState, action, emit) { + switch (action.type) { + case constants.UPDATE_EVENT_BREAKPOINTS: + state.activeEventNames = action.eventNames; + emit("activeEventNames", state.activeEventNames); + break; + case constants.FETCH_EVENT_LISTENERS: + if (action.status === "begin") { + state.fetchingListeners = true; + } + else if (action.status === "done") { + state.fetchingListeners = false; + state.listeners = action.listeners; + emit("event-listeners", state.listeners); + } + break; + } + + return state; +} + +module.exports = update; diff --git a/devtools/client/debugger/content/reducers/index.js b/devtools/client/debugger/content/reducers/index.js new file mode 100644 index 000000000..27f2059f9 --- /dev/null +++ b/devtools/client/debugger/content/reducers/index.js @@ -0,0 +1,16 @@ +/* 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/. */ +"use strict"; + +const eventListeners = require("./event-listeners"); +const sources = require("./sources"); +const breakpoints = require("./breakpoints"); +const asyncRequests = require("./async-requests"); + +module.exports = { + eventListeners, + sources, + breakpoints, + asyncRequests +}; diff --git a/devtools/client/debugger/content/reducers/moz.build b/devtools/client/debugger/content/reducers/moz.build new file mode 100644 index 000000000..0433a099c --- /dev/null +++ b/devtools/client/debugger/content/reducers/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'async-requests.js', + 'breakpoints.js', + 'event-listeners.js', + 'index.js', + 'sources.js' +) diff --git a/devtools/client/debugger/content/reducers/sources.js b/devtools/client/debugger/content/reducers/sources.js new file mode 100644 index 000000000..963a52fb5 --- /dev/null +++ b/devtools/client/debugger/content/reducers/sources.js @@ -0,0 +1,128 @@ +/* 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/. */ +"use strict"; + +const constants = require("../constants"); +const Immutable = require("devtools/client/shared/vendor/seamless-immutable"); +const { mergeIn, setIn } = require("../utils"); + +const initialState = Immutable({ + sources: {}, + selectedSource: null, + selectedSourceOpts: null, + sourcesText: {} +}); + +function update(state = initialState, action, emitChange) { + switch (action.type) { + case constants.ADD_SOURCE: + emitChange("source", action.source); + return mergeIn(state, ["sources", action.source.actor], action.source); + + case constants.LOAD_SOURCES: + if (action.status === "done") { + const sources = action.value; + if (!sources) { + return state; + } + const sourcesByActor = {}; + sources.forEach(source => { + if (!state.sources[source.actor]) { + emitChange("source", source); + } + sourcesByActor[source.actor] = source; + }); + return mergeIn(state, ["sources"], state.sources.merge(sourcesByActor)); + } + break; + + case constants.SELECT_SOURCE: + emitChange("source-selected", action.source); + return state.merge({ + selectedSource: action.source.actor, + selectedSourceOpts: action.opts + }); + + case constants.LOAD_SOURCE_TEXT: { + const s = _updateText(state, action); + emitChange("source-text-loaded", s.sources[action.source.actor]); + return s; + } + + case constants.BLACKBOX: + if (action.status === "done") { + const s = mergeIn(state, + ["sources", action.source.actor, "isBlackBoxed"], + action.value.isBlackBoxed); + emitChange("blackboxed", s.sources[action.source.actor]); + return s; + } + break; + + case constants.TOGGLE_PRETTY_PRINT: + let s = state; + if (action.status === "error") { + s = mergeIn(state, ["sourcesText", action.source.actor], { + loading: false + }); + + // If it errored, just display the source as it was before, but + // only if there is existing text already. If auto-prettifying + // is on, the original text may still be coming in and we don't + // have it yet. If we try to set empty text we confuse the + // editor because it thinks it's already displaying the source's + // text and won't load the text when it actually comes in. + if (s.sourcesText[action.source.actor].text != null) { + emitChange("prettyprinted", s.sources[action.source.actor]); + } + } + else { + s = _updateText(state, action); + // Don't do this yet, the progress bar is still imperatively shown + // from the source view. We will fix in the next iteration. + // emitChange('source-text-loaded', s.sources[action.source.actor]); + + if (action.status === "done") { + s = mergeIn(s, + ["sources", action.source.actor, "isPrettyPrinted"], + action.value.isPrettyPrinted); + emitChange("prettyprinted", s.sources[action.source.actor]); + } + } + return s; + + case constants.UNLOAD: + // Reset the entire state to just the initial state, a blank state + // if you will. + return initialState; + } + + return state; +} + +function _updateText(state, action) { + const { source } = action; + + if (action.status === "start") { + // Merge this in, don't set it. That way the previous value is + // still stored here, and we can retrieve it if whatever we're + // doing fails. + return mergeIn(state, ["sourcesText", source.actor], { + loading: true + }); + } + else if (action.status === "error") { + return setIn(state, ["sourcesText", source.actor], { + error: action.error + }); + } + else { + return setIn(state, ["sourcesText", source.actor], { + text: action.value.text, + contentType: action.value.contentType + }); + } +} + +module.exports = update; diff --git a/devtools/client/debugger/content/utils.js b/devtools/client/debugger/content/utils.js new file mode 100644 index 000000000..59993e9b4 --- /dev/null +++ b/devtools/client/debugger/content/utils.js @@ -0,0 +1,88 @@ +/* -*- 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/. */ +"use strict"; + +const { reportException } = require("devtools/shared/DevToolsUtils"); +const { Task } = require("devtools/shared/task"); + +function asPaused(client, func) { + if (client.state != "paused") { + return Task.spawn(function* () { + yield client.interrupt(); + let result; + + try { + result = yield func(); + } + catch (e) { + // Try to put the debugger back in a working state by resuming + // it + yield client.resume(); + throw e; + } + + yield client.resume(); + return result; + }); + } else { + return func(); + } +} + +function handleError(err) { + reportException("promise", err.toString()); +} + +function onReducerEvents(controller, listeners, thisContext) { + Object.keys(listeners).forEach(name => { + const listener = listeners[name]; + controller.onChange(name, payload => { + listener.call(thisContext, payload); + }); + }); +} + +function _getIn(destObj, path) { + return path.reduce(function (acc, name) { + return acc[name]; + }, destObj); +} + +function mergeIn(destObj, path, value) { + path = [...path]; + path.reverse(); + var obj = path.reduce(function (acc, name) { + return { [name]: acc }; + }, value); + + return destObj.merge(obj, { deep: true }); +} + +function setIn(destObj, path, value) { + destObj = mergeIn(destObj, path, null); + return mergeIn(destObj, path, value); +} + +function updateIn(destObj, path, fn) { + return setIn(destObj, path, fn(_getIn(destObj, path))); +} + +function deleteIn(destObj, path) { + const objPath = path.slice(0, -1); + const propName = path[path.length - 1]; + const obj = _getIn(destObj, objPath); + return setIn(destObj, objPath, obj.without(propName)); +} + +module.exports = { + asPaused, + handleError, + onReducerEvents, + mergeIn, + setIn, + updateIn, + deleteIn +}; diff --git a/devtools/client/debugger/content/views/event-listeners-view.js b/devtools/client/debugger/content/views/event-listeners-view.js new file mode 100644 index 000000000..993d6506e --- /dev/null +++ b/devtools/client/debugger/content/views/event-listeners-view.js @@ -0,0 +1,295 @@ +/* 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/. */ + +"use strict"; + +/* import-globals-from ../../debugger-controller.js */ + +const actions = require("../actions/event-listeners"); +const { bindActionCreators } = require("devtools/client/shared/vendor/redux"); +const { Heritage, WidgetMethods } = require("devtools/client/shared/widgets/view-helpers"); +const { SideMenuWidget } = require("resource://devtools/client/shared/widgets/SideMenuWidget.jsm"); + +/** + * Functions handling the event listeners UI. + */ +function EventListenersView(controller) { + dumpn("EventListenersView was instantiated"); + + this.actions = bindActionCreators(actions, controller.dispatch); + this.getState = () => controller.getState().eventListeners; + + this._onCheck = this._onCheck.bind(this); + this._onClick = this._onClick.bind(this); + + controller.onChange("event-listeners", this.renderListeners.bind(this)); +} + +EventListenersView.prototype = Heritage.extend(WidgetMethods, { + /** + * Initialization function, called when the debugger is started. + */ + initialize: function () { + dumpn("Initializing the EventListenersView"); + + this.widget = new SideMenuWidget(document.getElementById("event-listeners"), { + showItemCheckboxes: true, + showGroupCheckboxes: true + }); + + this.emptyText = L10N.getStr("noEventListenersText"); + this._eventCheckboxTooltip = L10N.getStr("eventCheckboxTooltip"); + this._onSelectorString = " " + L10N.getStr("eventOnSelector") + " "; + this._inSourceString = " " + L10N.getStr("eventInSource") + " "; + this._inNativeCodeString = L10N.getStr("eventNative"); + + this.widget.addEventListener("check", this._onCheck, false); + this.widget.addEventListener("click", this._onClick, false); + }, + + /** + * Destruction function, called when the debugger is closed. + */ + destroy: function () { + dumpn("Destroying the EventListenersView"); + + this.widget.removeEventListener("check", this._onCheck, false); + this.widget.removeEventListener("click", this._onClick, false); + }, + + renderListeners: function (listeners) { + listeners.forEach(listener => { + this.addListener(listener, { staged: true }); + }); + + // Flushes all the prepared events into the event listeners container. + this.commit(); + }, + + /** + * Adds an event to this event listeners container. + * + * @param object aListener + * The listener object coming from the active thread. + * @param object aOptions [optional] + * Additional options for adding the source. Supported options: + * - staged: true to stage the item to be appended later + */ + addListener: function (aListener, aOptions = {}) { + let { node: { selector }, function: { url }, type } = aListener; + if (!type) return; + + // Some listener objects may be added from plugins, thus getting + // translated to native code. + if (!url) { + url = this._inNativeCodeString; + } + + // If an event item for this listener's url and type was already added, + // avoid polluting the view and simply increase the "targets" count. + let eventItem = this.getItemForPredicate(aItem => + aItem.attachment.url == url && + aItem.attachment.type == type); + + if (eventItem) { + let { selectors, view: { targets } } = eventItem.attachment; + if (selectors.indexOf(selector) == -1) { + selectors.push(selector); + targets.setAttribute("value", L10N.getFormatStr("eventNodes", selectors.length)); + } + return; + } + + // There's no easy way of grouping event types into higher-level groups, + // so we need to do this by hand. + let is = (...args) => args.indexOf(type) != -1; + let has = str => type.includes(str); + let starts = str => type.startsWith(str); + let group; + + if (starts("animation")) { + group = L10N.getStr("animationEvents"); + } else if (starts("audio")) { + group = L10N.getStr("audioEvents"); + } else if (is("levelchange")) { + group = L10N.getStr("batteryEvents"); + } else if (is("cut", "copy", "paste")) { + group = L10N.getStr("clipboardEvents"); + } else if (starts("composition")) { + group = L10N.getStr("compositionEvents"); + } else if (starts("device")) { + group = L10N.getStr("deviceEvents"); + } else if (is("fullscreenchange", "fullscreenerror", "orientationchange", + "overflow", "resize", "scroll", "underflow", "zoom")) { + group = L10N.getStr("displayEvents"); + } else if (starts("drag") || starts("drop")) { + group = L10N.getStr("dragAndDropEvents"); + } else if (starts("gamepad")) { + group = L10N.getStr("gamepadEvents"); + } else if (is("canplay", "canplaythrough", "durationchange", "emptied", + "ended", "loadeddata", "loadedmetadata", "pause", "play", "playing", + "ratechange", "seeked", "seeking", "stalled", "suspend", "timeupdate", + "volumechange", "waiting")) { + group = L10N.getStr("mediaEvents"); + } else if (is("blocked", "complete", "success", "upgradeneeded", "versionchange")) { + group = L10N.getStr("indexedDBEvents"); + } else if (is("blur", "change", "focus", "focusin", "focusout", "invalid", + "reset", "select", "submit")) { + group = L10N.getStr("interactionEvents"); + } else if (starts("key") || is("input")) { + group = L10N.getStr("keyboardEvents"); + } else if (starts("mouse") || has("click") || is("contextmenu", "show", "wheel")) { + group = L10N.getStr("mouseEvents"); + } else if (starts("DOM")) { + group = L10N.getStr("mutationEvents"); + } else if (is("abort", "error", "hashchange", "load", "loadend", "loadstart", + "pagehide", "pageshow", "progress", "timeout", "unload", "uploadprogress", + "visibilitychange")) { + group = L10N.getStr("navigationEvents"); + } else if (is("pointerlockchange", "pointerlockerror")) { + group = L10N.getStr("pointerLockEvents"); + } else if (is("compassneedscalibration", "userproximity")) { + group = L10N.getStr("sensorEvents"); + } else if (starts("storage")) { + group = L10N.getStr("storageEvents"); + } else if (is("beginEvent", "endEvent", "repeatEvent")) { + group = L10N.getStr("timeEvents"); + } else if (starts("touch")) { + group = L10N.getStr("touchEvents"); + } else { + group = L10N.getStr("otherEvents"); + } + + // Create the element node for the event listener item. + const itemView = this._createItemView(type, selector, url); + + // Event breakpoints survive target navigations. Make sure the newly + // inserted event item is correctly checked. + const activeEventNames = this.getState().activeEventNames; + const checkboxState = activeEventNames.indexOf(type) != -1; + + // Append an event listener item to this container. + this.push([itemView.container], { + staged: aOptions.staged, /* stage the item to be appended later? */ + attachment: { + url: url, + type: type, + view: itemView, + selectors: [selector], + group: group, + checkboxState: checkboxState, + checkboxTooltip: this._eventCheckboxTooltip + } + }); + }, + + /** + * Gets all the event types known to this container. + * + * @return array + * List of event types, for example ["load", "click"...] + */ + getAllEvents: function () { + return this.attachments.map(e => e.type); + }, + + /** + * Gets the checked event types in this container. + * + * @return array + * List of event types, for example ["load", "click"...] + */ + getCheckedEvents: function () { + return this.attachments.filter(e => e.checkboxState).map(e => e.type); + }, + + /** + * Customization function for creating an item's UI. + * + * @param string aType + * The event type, for example "click". + * @param string aSelector + * The target element's selector. + * @param string url + * The source url in which the event listener is located. + * @return object + * An object containing the event listener view nodes. + */ + _createItemView: function (aType, aSelector, aUrl) { + let container = document.createElement("hbox"); + container.className = "dbg-event-listener"; + + let eventType = document.createElement("label"); + eventType.className = "plain dbg-event-listener-type"; + eventType.setAttribute("value", aType); + container.appendChild(eventType); + + let typeSeparator = document.createElement("label"); + typeSeparator.className = "plain dbg-event-listener-separator"; + typeSeparator.setAttribute("value", this._onSelectorString); + container.appendChild(typeSeparator); + + let eventTargets = document.createElement("label"); + eventTargets.className = "plain dbg-event-listener-targets"; + eventTargets.setAttribute("value", aSelector); + container.appendChild(eventTargets); + + let selectorSeparator = document.createElement("label"); + selectorSeparator.className = "plain dbg-event-listener-separator"; + selectorSeparator.setAttribute("value", this._inSourceString); + container.appendChild(selectorSeparator); + + let eventLocation = document.createElement("label"); + eventLocation.className = "plain dbg-event-listener-location"; + eventLocation.setAttribute("value", SourceUtils.getSourceLabel(aUrl)); + eventLocation.setAttribute("flex", "1"); + eventLocation.setAttribute("crop", "center"); + container.appendChild(eventLocation); + + return { + container: container, + type: eventType, + targets: eventTargets, + location: eventLocation + }; + }, + + /** + * The check listener for the event listeners container. + */ + _onCheck: function ({ detail: { description, checked }, target }) { + if (description == "item") { + this.getItemForElement(target).attachment.checkboxState = checked; + + this.actions.updateEventBreakpoints(this.getCheckedEvents()); + return; + } + + // Check all the event items in this group. + this.items + .filter(e => e.attachment.group == description) + .forEach(e => this.callMethod("checkItem", e.target, checked)); + }, + + /** + * The select listener for the event listeners container. + */ + _onClick: function ({ target }) { + // Changing the checkbox state is handled by the _onCheck event. Avoid + // handling that again in this click event, so pass in "noSiblings" + // when retrieving the target's item, to ignore the checkbox. + let eventItem = this.getItemForElement(target, { noSiblings: true }); + if (eventItem) { + let newState = eventItem.attachment.checkboxState ^= 1; + this.callMethod("checkItem", eventItem.target, newState); + } + }, + + _eventCheckboxTooltip: "", + _onSelectorString: "", + _inSourceString: "", + _inNativeCodeString: "" +}); + +module.exports = EventListenersView; diff --git a/devtools/client/debugger/content/views/moz.build b/devtools/client/debugger/content/views/moz.build new file mode 100644 index 000000000..de1ecc184 --- /dev/null +++ b/devtools/client/debugger/content/views/moz.build @@ -0,0 +1,9 @@ +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'event-listeners-view.js', + 'sources-view.js' +) diff --git a/devtools/client/debugger/content/views/sources-view.js b/devtools/client/debugger/content/views/sources-view.js new file mode 100644 index 000000000..bb68afcf4 --- /dev/null +++ b/devtools/client/debugger/content/views/sources-view.js @@ -0,0 +1,1370 @@ +/* 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/. */ + +"use strict"; + +/* import-globals-from ../../debugger-controller.js */ + +const utils = require("../utils"); +const { + getSelectedSource, + getSourceByURL, + getBreakpoint, + getBreakpoints, + makeLocationId +} = require("../queries"); +const actions = Object.assign( + {}, + require("../actions/sources"), + require("../actions/breakpoints") +); +const { bindActionCreators } = require("devtools/client/shared/vendor/redux"); +const { + Heritage, + WidgetMethods, + setNamedTimeout +} = require("devtools/client/shared/widgets/view-helpers"); +const { Task } = require("devtools/shared/task"); +const { SideMenuWidget } = require("resource://devtools/client/shared/widgets/SideMenuWidget.jsm"); +const { gDevTools } = require("devtools/client/framework/devtools"); +const {KeyCodes} = require("devtools/client/shared/keycodes"); + +const NEW_SOURCE_DISPLAY_DELAY = 200; // ms +const FUNCTION_SEARCH_POPUP_POSITION = "topcenter bottomleft"; +const BREAKPOINT_LINE_TOOLTIP_MAX_LENGTH = 1000; // chars +const BREAKPOINT_CONDITIONAL_POPUP_POSITION = "before_start"; +const BREAKPOINT_CONDITIONAL_POPUP_OFFSET_X = 7; // px +const BREAKPOINT_CONDITIONAL_POPUP_OFFSET_Y = -3; // px + +/** + * Functions handling the sources UI. + */ +function SourcesView(controller, DebuggerView) { + dumpn("SourcesView was instantiated"); + + utils.onReducerEvents(controller, { + "source": this.renderSource, + "blackboxed": this.renderBlackBoxed, + "prettyprinted": this.updateToolbarButtonsState, + "source-selected": this.renderSourceSelected, + "breakpoint-updated": bp => this.renderBreakpoint(bp), + "breakpoint-enabled": bp => this.renderBreakpoint(bp), + "breakpoint-disabled": bp => this.renderBreakpoint(bp), + "breakpoint-removed": bp => this.renderBreakpoint(bp, true), + }, this); + + this.getState = controller.getState; + this.actions = bindActionCreators(actions, controller.dispatch); + this.DebuggerView = DebuggerView; + this.Parser = DebuggerController.Parser; + + this.togglePrettyPrint = this.togglePrettyPrint.bind(this); + this.toggleBlackBoxing = this.toggleBlackBoxing.bind(this); + this.toggleBreakpoints = this.toggleBreakpoints.bind(this); + + this._onEditorCursorActivity = this._onEditorCursorActivity.bind(this); + this._onMouseDown = this._onMouseDown.bind(this); + this._onSourceSelect = this._onSourceSelect.bind(this); + this._onStopBlackBoxing = this._onStopBlackBoxing.bind(this); + this._onBreakpointRemoved = this._onBreakpointRemoved.bind(this); + this._onBreakpointClick = this._onBreakpointClick.bind(this); + this._onBreakpointCheckboxClick = this._onBreakpointCheckboxClick.bind(this); + this._onConditionalPopupShowing = this._onConditionalPopupShowing.bind(this); + this._onConditionalPopupShown = this._onConditionalPopupShown.bind(this); + this._onConditionalPopupHiding = this._onConditionalPopupHiding.bind(this); + this._onConditionalTextboxKeyPress = this._onConditionalTextboxKeyPress.bind(this); + this._onEditorContextMenuOpen = this._onEditorContextMenuOpen.bind(this); + this._onCopyUrlCommand = this._onCopyUrlCommand.bind(this); + this._onNewTabCommand = this._onNewTabCommand.bind(this); + this._onConditionalPopupHidden = this._onConditionalPopupHidden.bind(this); +} + +SourcesView.prototype = Heritage.extend(WidgetMethods, { + /** + * Initialization function, called when the debugger is started. + */ + initialize: function (isWorker) { + dumpn("Initializing the SourcesView"); + + this.widget = new SideMenuWidget(document.getElementById("sources"), { + contextMenu: document.getElementById("debuggerSourcesContextMenu"), + showArrows: true + }); + + this._preferredSourceURL = null; + this._unnamedSourceIndex = 0; + this.emptyText = L10N.getStr("noSourcesText"); + this._blackBoxCheckboxTooltip = L10N.getStr("blackBoxCheckboxTooltip"); + + this._commandset = document.getElementById("debuggerCommands"); + this._popupset = document.getElementById("debuggerPopupset"); + this._cmPopup = document.getElementById("sourceEditorContextMenu"); + this._cbPanel = document.getElementById("conditional-breakpoint-panel"); + this._cbTextbox = document.getElementById("conditional-breakpoint-panel-textbox"); + this._blackBoxButton = document.getElementById("black-box"); + this._stopBlackBoxButton = document.getElementById("black-boxed-message-button"); + this._prettyPrintButton = document.getElementById("pretty-print"); + this._toggleBreakpointsButton = document.getElementById("toggle-breakpoints"); + this._newTabMenuItem = document.getElementById("debugger-sources-context-newtab"); + this._copyUrlMenuItem = document.getElementById("debugger-sources-context-copyurl"); + + this._noResultsFoundToolTip = new Tooltip(document); + this._noResultsFoundToolTip.defaultPosition = FUNCTION_SEARCH_POPUP_POSITION; + + // We don't show the pretty print button if debugger a worker + // because it simply doesn't work yet. (bug 1273730) + if (Prefs.prettyPrintEnabled && !isWorker) { + this._prettyPrintButton.removeAttribute("hidden"); + } + + this._editorContainer = document.getElementById("editor"); + this._editorContainer.addEventListener("mousedown", this._onMouseDown, false); + + this.widget.addEventListener("select", this._onSourceSelect, false); + + this._stopBlackBoxButton.addEventListener("click", this._onStopBlackBoxing, false); + this._cbPanel.addEventListener("popupshowing", this._onConditionalPopupShowing, false); + this._cbPanel.addEventListener("popupshown", this._onConditionalPopupShown, false); + this._cbPanel.addEventListener("popuphiding", this._onConditionalPopupHiding, false); + this._cbPanel.addEventListener("popuphidden", this._onConditionalPopupHidden, false); + this._cbTextbox.addEventListener("keypress", this._onConditionalTextboxKeyPress, false); + this._copyUrlMenuItem.addEventListener("command", this._onCopyUrlCommand, false); + this._newTabMenuItem.addEventListener("command", this._onNewTabCommand, false); + + this._cbPanel.hidden = true; + this.allowFocusOnRightClick = true; + this.autoFocusOnSelection = false; + this.autoFocusOnFirstItem = false; + + // Sort the contents by the displayed label. + this.sortContents((aFirst, aSecond) => { + return +(aFirst.attachment.label.toLowerCase() > + aSecond.attachment.label.toLowerCase()); + }); + + // Sort known source groups towards the end of the list + this.widget.groupSortPredicate = function (a, b) { + if ((a in KNOWN_SOURCE_GROUPS) == (b in KNOWN_SOURCE_GROUPS)) { + return a.localeCompare(b); + } + return (a in KNOWN_SOURCE_GROUPS) ? 1 : -1; + }; + + this.DebuggerView.editor.on("popupOpen", this._onEditorContextMenuOpen); + + this._addCommands(); + }, + + /** + * Destruction function, called when the debugger is closed. + */ + destroy: function () { + dumpn("Destroying the SourcesView"); + + this.widget.removeEventListener("select", this._onSourceSelect, false); + this._stopBlackBoxButton.removeEventListener("click", this._onStopBlackBoxing, false); + this._cbPanel.removeEventListener("popupshowing", this._onConditionalPopupShowing, false); + this._cbPanel.removeEventListener("popupshown", this._onConditionalPopupShown, false); + this._cbPanel.removeEventListener("popuphiding", this._onConditionalPopupHiding, false); + this._cbPanel.removeEventListener("popuphidden", this._onConditionalPopupHidden, false); + this._cbTextbox.removeEventListener("keypress", this._onConditionalTextboxKeyPress, false); + this._copyUrlMenuItem.removeEventListener("command", this._onCopyUrlCommand, false); + this._newTabMenuItem.removeEventListener("command", this._onNewTabCommand, false); + this.DebuggerView.editor.off("popupOpen", this._onEditorContextMenuOpen, false); + }, + + empty: function () { + WidgetMethods.empty.call(this); + this._unnamedSourceIndex = 0; + this._selectedBreakpoint = null; + }, + + /** + * Add commands that XUL can fire. + */ + _addCommands: function () { + XULUtils.addCommands(this._commandset, { + addBreakpointCommand: e => this._onCmdAddBreakpoint(e), + addConditionalBreakpointCommand: e => this._onCmdAddConditionalBreakpoint(e), + blackBoxCommand: () => this.toggleBlackBoxing(), + unBlackBoxButton: () => this._onStopBlackBoxing(), + prettyPrintCommand: () => this.togglePrettyPrint(), + toggleBreakpointsCommand: () =>this.toggleBreakpoints(), + nextSourceCommand: () => this.selectNextItem(), + prevSourceCommand: () => this.selectPrevItem() + }); + }, + + /** + * Sets the preferred location to be selected in this sources container. + * @param string aUrl + */ + set preferredSource(aUrl) { + this._preferredValue = aUrl; + + // Selects the element with the specified value in this sources container, + // if already inserted. + if (this.containsValue(aUrl)) { + this.selectedValue = aUrl; + } + }, + + sourcesDidUpdate: function () { + if (!getSelectedSource(this.getState())) { + let url = this._preferredSourceURL; + let source = url && getSourceByURL(this.getState(), url); + if (source) { + this.actions.selectSource(source); + } + else { + setNamedTimeout("new-source", NEW_SOURCE_DISPLAY_DELAY, () => { + if (!getSelectedSource(this.getState()) && this.itemCount > 0) { + this.actions.selectSource(this.getItemAtIndex(0).attachment.source); + } + }); + } + } + }, + + renderSource: function (source) { + this.addSource(source, { staged: false }); + for (let bp of getBreakpoints(this.getState())) { + if (bp.location.actor === source.actor) { + this.renderBreakpoint(bp); + } + } + this.sourcesDidUpdate(); + }, + + /** + * Adds a source to this sources container. + * + * @param object aSource + * The source object coming from the active thread. + * @param object aOptions [optional] + * Additional options for adding the source. Supported options: + * - staged: true to stage the item to be appended later + */ + addSource: function (aSource, aOptions = {}) { + if (!aSource.url && !aOptions.force) { + // We don't show any unnamed eval scripts yet (see bug 1124106) + return; + } + + let { label, group, unicodeUrl } = this._parseUrl(aSource); + + let contents = document.createElement("label"); + contents.className = "plain dbg-source-item"; + contents.setAttribute("value", label); + contents.setAttribute("crop", "start"); + contents.setAttribute("flex", "1"); + contents.setAttribute("tooltiptext", unicodeUrl); + + if (aSource.introductionType === "wasm") { + const wasm = document.createElement("box"); + wasm.className = "dbg-wasm-item"; + const icon = document.createElement("box"); + icon.setAttribute("tooltiptext", L10N.getStr("experimental")); + icon.className = "icon"; + wasm.appendChild(icon); + wasm.appendChild(contents); + + contents = wasm; + } + + // If the source is blackboxed, apply the appropriate style. + if (gThreadClient.source(aSource).isBlackBoxed) { + contents.classList.add("black-boxed"); + } + + // Append a source item to this container. + this.push([contents, aSource.actor], { + staged: aOptions.staged, /* stage the item to be appended later? */ + attachment: { + label: label, + group: group, + checkboxState: !aSource.isBlackBoxed, + checkboxTooltip: this._blackBoxCheckboxTooltip, + source: aSource + } + }); + }, + + _parseUrl: function (aSource) { + let fullUrl = aSource.url; + let url, unicodeUrl, label, group; + + if (!fullUrl) { + unicodeUrl = "SCRIPT" + this._unnamedSourceIndex++; + label = unicodeUrl; + group = L10N.getStr("anonymousSourcesLabel"); + } + else { + let url = fullUrl.split(" -> ").pop(); + label = aSource.addonPath ? aSource.addonPath : SourceUtils.getSourceLabel(url); + group = aSource.addonID ? aSource.addonID : SourceUtils.getSourceGroup(url); + unicodeUrl = NetworkHelper.convertToUnicode(unescape(fullUrl)); + } + + return { + label: label, + group: group, + unicodeUrl: unicodeUrl + }; + }, + + renderBreakpoint: function (breakpoint, removed) { + if (removed) { + // Be defensive about the breakpoint not existing. + if (this._getBreakpoint(breakpoint)) { + this._removeBreakpoint(breakpoint); + } + } + else { + if (this._getBreakpoint(breakpoint)) { + this._updateBreakpointStatus(breakpoint); + } + else { + this._addBreakpoint(breakpoint); + } + } + }, + + /** + * Adds a breakpoint to this sources container. + * + * @param object aBreakpointClient + * See Breakpoints.prototype._showBreakpoint + * @param object aOptions [optional] + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _addBreakpoint: function (breakpoint, options = {}) { + let disabled = breakpoint.disabled; + let location = breakpoint.location; + + // Get the source item to which the breakpoint should be attached. + let sourceItem = this.getItemByValue(location.actor); + if (!sourceItem) { + return; + } + + // Create the element node and menu popup for the breakpoint item. + let breakpointArgs = Heritage.extend(breakpoint.asMutable(), options); + let breakpointView = this._createBreakpointView.call(this, breakpointArgs); + let contextMenu = this._createContextMenu.call(this, breakpointArgs); + + // Append a breakpoint child item to the corresponding source item. + sourceItem.append(breakpointView.container, { + attachment: Heritage.extend(breakpointArgs, { + actor: location.actor, + line: location.line, + view: breakpointView, + popup: contextMenu + }), + attributes: [ + ["contextmenu", contextMenu.menupopupId] + ], + // Make sure that when the breakpoint item is removed, the corresponding + // menupopup and commandset are also destroyed. + finalize: this._onBreakpointRemoved + }); + + if (typeof breakpoint.condition === "string") { + this.highlightBreakpoint(breakpoint.location, { + openPopup: true, + noEditorUpdate: true + }); + } + + window.emit(EVENTS.BREAKPOINT_SHOWN_IN_PANE); + }, + + /** + * Removes a breakpoint from this sources container. + * It does not also remove the breakpoint from the controller. Be careful. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _removeBreakpoint: function (breakpoint) { + // When a parent source item is removed, all the child breakpoint items are + // also automagically removed. + let sourceItem = this.getItemByValue(breakpoint.location.actor); + if (!sourceItem) { + return; + } + + // Clear the breakpoint view. + sourceItem.remove(this._getBreakpoint(breakpoint)); + + if (this._selectedBreakpoint && + (queries.makeLocationId(this._selectedBreakpoint.location) === + queries.makeLocationId(breakpoint.location))) { + this._selectedBreakpoint = null; + } + + window.emit(EVENTS.BREAKPOINT_HIDDEN_IN_PANE); + }, + + _getBreakpoint: function (bp) { + return this.getItemForPredicate(item => { + return item.attachment.actor === bp.location.actor && + item.attachment.line === bp.location.line; + }); + }, + + /** + * Updates a breakpoint. + * + * @param object breakpoint + */ + _updateBreakpointStatus: function (breakpoint) { + let location = breakpoint.location; + let breakpointItem = this._getBreakpoint(getBreakpoint(this.getState(), location)); + if (!breakpointItem) { + return promise.reject(new Error("No breakpoint found.")); + } + + // Breakpoint will now be enabled. + let attachment = breakpointItem.attachment; + + // Update the corresponding menu items to reflect the enabled state. + let prefix = "bp-cMenu-"; // "breakpoints context menu" + let identifier = makeLocationId(location); + let enableSelfId = prefix + "enableSelf-" + identifier + "-menuitem"; + let disableSelfId = prefix + "disableSelf-" + identifier + "-menuitem"; + let enableSelf = document.getElementById(enableSelfId); + let disableSelf = document.getElementById(disableSelfId); + + if (breakpoint.disabled) { + enableSelf.removeAttribute("hidden"); + disableSelf.setAttribute("hidden", true); + attachment.view.checkbox.removeAttribute("checked"); + } + else { + enableSelf.setAttribute("hidden", true); + disableSelf.removeAttribute("hidden"); + attachment.view.checkbox.setAttribute("checked", "true"); + + // Update the breakpoint toggle button checked state. + this._toggleBreakpointsButton.removeAttribute("checked"); + } + + }, + + /** + * Highlights a breakpoint in this sources container. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + * @param object aOptions [optional] + * An object containing some of the following boolean properties: + * - openPopup: tells if the expression popup should be shown. + * - noEditorUpdate: tells if you want to skip editor updates. + */ + highlightBreakpoint: function (aLocation, aOptions = {}) { + let breakpoint = getBreakpoint(this.getState(), aLocation); + if (!breakpoint) { + return; + } + + // Breakpoint will now be selected. + this._selectBreakpoint(breakpoint); + + // Update the editor location if necessary. + if (!aOptions.noEditorUpdate) { + this.DebuggerView.setEditorLocation(aLocation.actor, aLocation.line, { noDebug: true }); + } + + // If the breakpoint requires a new conditional expression, display + // the panel to input the corresponding expression. + if (aOptions.openPopup) { + return this._openConditionalPopup(); + } else { + return this._hideConditionalPopup(); + } + }, + + /** + * Highlight the breakpoint on the current currently focused line/column + * if it exists. + */ + highlightBreakpointAtCursor: function () { + let actor = this.selectedValue; + let line = this.DebuggerView.editor.getCursor().line + 1; + + let location = { actor: actor, line: line }; + this.highlightBreakpoint(location, { noEditorUpdate: true }); + }, + + /** + * Unhighlights the current breakpoint in this sources container. + */ + unhighlightBreakpoint: function () { + this._hideConditionalPopup(); + this._unselectBreakpoint(); + }, + + /** + * Display the message thrown on breakpoint condition + */ + showBreakpointConditionThrownMessage: function (aLocation, aMessage = "") { + let breakpointItem = this._getBreakpoint(getBreakpoint(this.getState(), aLocation)); + if (!breakpointItem) { + return; + } + let attachment = breakpointItem.attachment; + attachment.view.container.classList.add("dbg-breakpoint-condition-thrown"); + attachment.view.message.setAttribute("value", aMessage); + }, + + /** + * Update the checked/unchecked and enabled/disabled states of the buttons in + * the sources toolbar based on the currently selected source's state. + */ + updateToolbarButtonsState: function (source) { + if (source.isBlackBoxed) { + this._blackBoxButton.setAttribute("checked", true); + this._prettyPrintButton.setAttribute("checked", true); + } else { + this._blackBoxButton.removeAttribute("checked"); + this._prettyPrintButton.removeAttribute("checked"); + } + + if (source.isPrettyPrinted) { + this._prettyPrintButton.setAttribute("checked", true); + } else { + this._prettyPrintButton.removeAttribute("checked"); + } + }, + + /** + * Toggle the pretty printing of the selected source. + */ + togglePrettyPrint: function () { + if (this._prettyPrintButton.hasAttribute("disabled")) { + return; + } + + this.DebuggerView.showProgressBar(); + const source = getSelectedSource(this.getState()); + const sourceClient = gThreadClient.source(source); + const shouldPrettyPrint = !source.isPrettyPrinted; + + // This is only here to give immediate feedback, + // `renderPrettyPrinted` will set the final status of the buttons + if (shouldPrettyPrint) { + this._prettyPrintButton.setAttribute("checked", true); + } else { + this._prettyPrintButton.removeAttribute("checked"); + } + + this.actions.togglePrettyPrint(source); + }, + + /** + * Toggle the black boxed state of the selected source. + */ + toggleBlackBoxing: Task.async(function* () { + const source = getSelectedSource(this.getState()); + const shouldBlackBox = !source.isBlackBoxed; + + // Be optimistic that the (un-)black boxing will succeed, so + // enable/disable the pretty print button and check/uncheck the + // black box button immediately. + if (shouldBlackBox) { + this._prettyPrintButton.setAttribute("disabled", true); + this._blackBoxButton.setAttribute("checked", true); + } else { + this._prettyPrintButton.removeAttribute("disabled"); + this._blackBoxButton.removeAttribute("checked"); + } + + this.actions.blackbox(source, shouldBlackBox); + }), + + renderBlackBoxed: function (source) { + const sourceItem = this.getItemByValue(source.actor); + sourceItem.prebuiltNode.classList.toggle( + "black-boxed", + source.isBlackBoxed + ); + + if (getSelectedSource(this.getState()).actor === source.actor) { + this.updateToolbarButtonsState(source); + } + }, + + /** + * Toggles all breakpoints enabled/disabled. + */ + toggleBreakpoints: function () { + let breakpoints = getBreakpoints(this.getState()); + let hasBreakpoints = breakpoints.length > 0; + let hasEnabledBreakpoints = breakpoints.some(bp => !bp.disabled); + + if (hasBreakpoints && hasEnabledBreakpoints) { + this._toggleBreakpointsButton.setAttribute("checked", true); + this._onDisableAll(); + } else { + this._toggleBreakpointsButton.removeAttribute("checked"); + this._onEnableAll(); + } + }, + + hidePrettyPrinting: function () { + this._prettyPrintButton.style.display = "none"; + + if (this._blackBoxButton.style.display === "none") { + let sep = document.querySelector("#sources-toolbar .devtools-separator"); + sep.style.display = "none"; + } + }, + + hideBlackBoxing: function () { + this._blackBoxButton.style.display = "none"; + + if (this._prettyPrintButton.style.display === "none") { + let sep = document.querySelector("#sources-toolbar .devtools-separator"); + sep.style.display = "none"; + } + }, + + getDisplayURL: function (source) { + if (!source.url) { + return this.getItemByValue(source.actor).attachment.label; + } + return NetworkHelper.convertToUnicode(unescape(source.url)); + }, + + /** + * Marks a breakpoint as selected in this sources container. + * + * @param object aItem + * The breakpoint item to select. + */ + _selectBreakpoint: function (bp) { + if (this._selectedBreakpoint === bp) { + return; + } + this._unselectBreakpoint(); + this._selectedBreakpoint = bp; + + const item = this._getBreakpoint(bp); + item.target.classList.add("selected"); + + // Ensure the currently selected breakpoint is visible. + this.widget.ensureElementIsVisible(item.target); + }, + + /** + * Marks the current breakpoint as unselected in this sources container. + */ + _unselectBreakpoint: function () { + if (!this._selectedBreakpoint) { + return; + } + + const item = this._getBreakpoint(this._selectedBreakpoint); + item.target.classList.remove("selected"); + + this._selectedBreakpoint = null; + }, + + /** + * Opens a conditional breakpoint's expression input popup. + */ + _openConditionalPopup: function () { + let breakpointItem = this._getBreakpoint(this._selectedBreakpoint); + let attachment = breakpointItem.attachment; + // Check if this is an enabled conditional breakpoint, and if so, + // retrieve the current conditional epression. + let bp = getBreakpoint(this.getState(), attachment); + let expr = (bp ? (bp.condition || "") : ""); + let cbPanel = this._cbPanel; + + // Update the conditional expression textbox. If no expression was + // previously set, revert to using an empty string by default. + this._cbTextbox.value = expr; + + function openPopup() { + // Show the conditional expression panel. The popup arrow should be pointing + // at the line number node in the breakpoint item view. + cbPanel.hidden = false; + cbPanel.openPopup(breakpointItem.attachment.view.lineNumber, + BREAKPOINT_CONDITIONAL_POPUP_POSITION, + BREAKPOINT_CONDITIONAL_POPUP_OFFSET_X, + BREAKPOINT_CONDITIONAL_POPUP_OFFSET_Y); + + cbPanel.removeEventListener("popuphidden", openPopup, false); + } + + // Wait until the other cb panel is closed + if (!this._cbPanel.hidden) { + this._cbPanel.addEventListener("popuphidden", openPopup, false); + } else { + openPopup(); + } + }, + + /** + * Hides a conditional breakpoint's expression input popup. + */ + _hideConditionalPopup: function () { + // Sometimes this._cbPanel doesn't have hidePopup method which doesn't + // break anything but simply outputs an exception to the console. + if (this._cbPanel.hidePopup) { + this._cbPanel.hidePopup(); + } + }, + + /** + * Customization function for creating a breakpoint item's UI. + * + * @param object aOptions + * A couple of options or flags supported by this operation: + * - location: the breakpoint's source location and line number + * - disabled: the breakpoint's disabled state, boolean + * - text: the breakpoint's line text to be displayed + * - message: thrown string when the breakpoint condition throws + * @return object + * An object containing the breakpoint container, checkbox, + * line number and line text nodes. + */ + _createBreakpointView: function (aOptions) { + let { location, disabled, text, message } = aOptions; + let identifier = makeLocationId(location); + + let checkbox = document.createElement("checkbox"); + if (!disabled) { + checkbox.setAttribute("checked", true); + } + checkbox.className = "dbg-breakpoint-checkbox"; + + let lineNumberNode = document.createElement("label"); + lineNumberNode.className = "plain dbg-breakpoint-line"; + lineNumberNode.setAttribute("value", location.line); + + let lineTextNode = document.createElement("label"); + lineTextNode.className = "plain dbg-breakpoint-text"; + lineTextNode.setAttribute("value", text); + lineTextNode.setAttribute("crop", "end"); + lineTextNode.setAttribute("flex", "1"); + + let tooltip = text ? text.substr(0, BREAKPOINT_LINE_TOOLTIP_MAX_LENGTH) : ""; + lineTextNode.setAttribute("tooltiptext", tooltip); + + let thrownNode = document.createElement("label"); + thrownNode.className = "plain dbg-breakpoint-condition-thrown-message dbg-breakpoint-text"; + thrownNode.setAttribute("value", message); + thrownNode.setAttribute("crop", "end"); + thrownNode.setAttribute("flex", "1"); + + let bpLineContainer = document.createElement("hbox"); + bpLineContainer.className = "plain dbg-breakpoint-line-container"; + bpLineContainer.setAttribute("flex", "1"); + + bpLineContainer.appendChild(lineNumberNode); + bpLineContainer.appendChild(lineTextNode); + + let bpDetailContainer = document.createElement("vbox"); + bpDetailContainer.className = "plain dbg-breakpoint-detail-container"; + bpDetailContainer.setAttribute("flex", "1"); + + bpDetailContainer.appendChild(bpLineContainer); + bpDetailContainer.appendChild(thrownNode); + + let container = document.createElement("hbox"); + container.id = "breakpoint-" + identifier; + container.className = "dbg-breakpoint side-menu-widget-item-other"; + container.classList.add("devtools-monospace"); + container.setAttribute("align", "center"); + container.setAttribute("flex", "1"); + + container.addEventListener("click", this._onBreakpointClick, false); + checkbox.addEventListener("click", this._onBreakpointCheckboxClick, false); + + container.appendChild(checkbox); + container.appendChild(bpDetailContainer); + + return { + container: container, + checkbox: checkbox, + lineNumber: lineNumberNode, + lineText: lineTextNode, + message: thrownNode + }; + }, + + /** + * Creates a context menu for a breakpoint element. + * + * @param object aOptions + * A couple of options or flags supported by this operation: + * - location: the breakpoint's source location and line number + * - disabled: the breakpoint's disabled state, boolean + * @return object + * An object containing the breakpoint commandset and menu popup ids. + */ + _createContextMenu: function (aOptions) { + let { location, disabled } = aOptions; + let identifier = makeLocationId(location); + + let commandset = document.createElement("commandset"); + let menupopup = document.createElement("menupopup"); + commandset.id = "bp-cSet-" + identifier; + menupopup.id = "bp-mPop-" + identifier; + + createMenuItem.call(this, "enableSelf", !disabled); + createMenuItem.call(this, "disableSelf", disabled); + createMenuItem.call(this, "deleteSelf"); + createMenuSeparator(); + createMenuItem.call(this, "setConditional"); + createMenuSeparator(); + createMenuItem.call(this, "enableOthers"); + createMenuItem.call(this, "disableOthers"); + createMenuItem.call(this, "deleteOthers"); + createMenuSeparator(); + createMenuItem.call(this, "enableAll"); + createMenuItem.call(this, "disableAll"); + createMenuSeparator(); + createMenuItem.call(this, "deleteAll"); + + this._popupset.appendChild(menupopup); + this._commandset.appendChild(commandset); + + return { + commandsetId: commandset.id, + menupopupId: menupopup.id + }; + + /** + * Creates a menu item specified by a name with the appropriate attributes + * (label and handler). + * + * @param string aName + * A global identifier for the menu item. + * @param boolean aHiddenFlag + * True if this menuitem should be hidden. + */ + function createMenuItem(aName, aHiddenFlag) { + let menuitem = document.createElement("menuitem"); + let command = document.createElement("command"); + + let prefix = "bp-cMenu-"; // "breakpoints context menu" + let commandId = prefix + aName + "-" + identifier + "-command"; + let menuitemId = prefix + aName + "-" + identifier + "-menuitem"; + + let label = L10N.getStr("breakpointMenuItem." + aName); + let func = "_on" + aName.charAt(0).toUpperCase() + aName.slice(1); + + command.id = commandId; + command.setAttribute("label", label); + command.addEventListener("command", () => this[func](location), false); + + menuitem.id = menuitemId; + menuitem.setAttribute("command", commandId); + aHiddenFlag && menuitem.setAttribute("hidden", "true"); + + commandset.appendChild(command); + menupopup.appendChild(menuitem); + } + + /** + * Creates a simple menu separator element and appends it to the current + * menupopup hierarchy. + */ + function createMenuSeparator() { + let menuseparator = document.createElement("menuseparator"); + menupopup.appendChild(menuseparator); + } + }, + + /** + * Copy the source url from the currently selected item. + */ + _onCopyUrlCommand: function () { + let selected = this.selectedItem && this.selectedItem.attachment; + if (!selected) { + return; + } + clipboardHelper.copyString(selected.source.url); + }, + + /** + * Opens selected item source in a new tab. + */ + _onNewTabCommand: function () { + let win = Services.wm.getMostRecentWindow(gDevTools.chromeWindowType); + let selected = this.selectedItem.attachment; + win.openUILinkIn(selected.source.url, "tab", { relatedToCurrent: true }); + }, + + /** + * Function called each time a breakpoint item is removed. + * + * @param object aItem + * The corresponding item. + */ + _onBreakpointRemoved: function (aItem) { + dumpn("Finalizing breakpoint item: " + aItem.stringify()); + + // Destroy the context menu for the breakpoint. + let contextMenu = aItem.attachment.popup; + document.getElementById(contextMenu.commandsetId).remove(); + document.getElementById(contextMenu.menupopupId).remove(); + }, + + _onMouseDown: function (e) { + this.hideNoResultsTooltip(); + + if (!e.metaKey) { + return; + } + + let editor = this.DebuggerView.editor; + let identifier = this._findIdentifier(e.clientX, e.clientY); + + if (!identifier) { + return; + } + + let foundDefinitions = this._getFunctionDefinitions(identifier); + + if (!foundDefinitions || !foundDefinitions.definitions) { + return; + } + + this._showFunctionDefinitionResults(identifier, foundDefinitions.definitions, editor); + }, + + /** + * Searches for function definition of a function in a given source file + */ + + _findDefinition: function (parsedSource, aName) { + let functionDefinitions = parsedSource.getNamedFunctionDefinitions(aName); + + let resultList = []; + + if (!functionDefinitions || !functionDefinitions.length || !functionDefinitions[0].length) { + return { + definitions: resultList + }; + } + + // functionDefinitions is a list with an object full of metadata, + // extract the data and use to construct a more useful, less + // cluttered, contextual list + for (let i = 0; i < functionDefinitions.length; i++) { + let functionDefinition = { + source: functionDefinitions[i].sourceUrl, + startLine: functionDefinitions[i][0].functionLocation.start.line, + startColumn: functionDefinitions[i][0].functionLocation.start.column, + name: functionDefinitions[i][0].functionName + }; + + resultList.push(functionDefinition); + } + + return { + definitions: resultList + }; + }, + + /** + * Searches for an identifier underneath the specified position in the + * source editor. + * + * @param number x, y + * The left/top coordinates where to look for an identifier. + */ + _findIdentifier: function (x, y) { + let parsedSource = SourceUtils.parseSource(this.DebuggerView, this.Parser); + let identifierInfo = SourceUtils.findIdentifier(this.DebuggerView.editor, parsedSource, x, y); + + // Not hovering over an identifier + if (!identifierInfo) { + return; + } + + return identifierInfo; + }, + + /** + * The selection listener for the source editor. + */ + _onEditorCursorActivity: function (e) { + let editor = this.DebuggerView.editor; + let start = editor.getCursor("start").line + 1; + let end = editor.getCursor().line + 1; + let source = getSelectedSource(this.getState()); + + if (source) { + let location = { actor: source.actor, line: start }; + if (getBreakpoint(this.getState(), location) && start == end) { + this.highlightBreakpoint(location, { noEditorUpdate: true }); + } else { + this.unhighlightBreakpoint(); + } + } + }, + + /* + * Uses function definition data to perform actions in different + * cases of how many locations were found: zero, one, or multiple definitions + */ + _showFunctionDefinitionResults: function (aHoveredFunction, aDefinitionList, aEditor) { + let definitions = aDefinitionList; + let hoveredFunction = aHoveredFunction; + + // show a popup saying no results were found + if (definitions.length == 0) { + this._noResultsFoundToolTip.setTextContent({ + messages: [L10N.getStr("noMatchingStringsText")] + }); + + this._markedIdentifier = aEditor.markText( + { line: hoveredFunction.location.start.line - 1, ch: hoveredFunction.location.start.column }, + { line: hoveredFunction.location.end.line - 1, ch: hoveredFunction.location.end.column }); + + this._noResultsFoundToolTip.show(this._markedIdentifier.anchor); + + } else if (definitions.length == 1) { + this.DebuggerView.setEditorLocation(definitions[0].source, definitions[0].startLine); + } else { + // TODO: multiple definitions found, do something else + this.DebuggerView.setEditorLocation(definitions[0].source, definitions[0].startLine); + } + }, + + /** + * Hides the tooltip and clear marked text popup. + */ + hideNoResultsTooltip: function () { + this._noResultsFoundToolTip.hide(); + if (this._markedIdentifier) { + this._markedIdentifier.clear(); + this._markedIdentifier = null; + } + }, + + /* + * Gets the definition locations from function metadata + */ + _getFunctionDefinitions: function (aIdentifierInfo) { + let parsedSource = SourceUtils.parseSource(this.DebuggerView, this.Parser); + let definition_info = this._findDefinition(parsedSource, aIdentifierInfo.name); + + // Did not find any definitions for the identifier + if (!definition_info) { + return; + } + + return definition_info; + }, + + /** + * The select listener for the sources container. + */ + _onSourceSelect: function ({ detail: sourceItem }) { + if (!sourceItem) { + return; + } + + const { source } = sourceItem.attachment; + this.actions.selectSource(source); + }, + + renderSourceSelected: function (source) { + if (source.url) { + this._preferredSourceURL = source.url; + } + this.updateToolbarButtonsState(source); + this._selectItem(this.getItemByValue(source.actor)); + }, + + /** + * The click listener for the "stop black boxing" button. + */ + _onStopBlackBoxing: Task.async(function* () { + this.actions.blackbox(getSelectedSource(this.getState()), false); + }), + + /** + * The source editor's contextmenu handler. + * - Toggles "Add Conditional Breakpoint" and "Edit Conditional Breakpoint" items + */ + _onEditorContextMenuOpen: function (message, ev, popup) { + let actor = this.selectedValue; + let line = this.DebuggerView.editor.getCursor().line + 1; + let location = { actor, line }; + + let breakpoint = getBreakpoint(this.getState(), location); + let addConditionalBreakpointMenuItem = popup.querySelector("#se-dbg-cMenu-addConditionalBreakpoint"); + let editConditionalBreakpointMenuItem = popup.querySelector("#se-dbg-cMenu-editConditionalBreakpoint"); + + if (breakpoint && !!breakpoint.condition) { + editConditionalBreakpointMenuItem.removeAttribute("hidden"); + addConditionalBreakpointMenuItem.setAttribute("hidden", true); + } + else { + addConditionalBreakpointMenuItem.removeAttribute("hidden"); + editConditionalBreakpointMenuItem.setAttribute("hidden", true); + } + }, + + /** + * The click listener for a breakpoint container. + */ + _onBreakpointClick: function (e) { + let sourceItem = this.getItemForElement(e.target); + let breakpointItem = this.getItemForElement.call(sourceItem, e.target); + let attachment = breakpointItem.attachment; + let bp = getBreakpoint(this.getState(), attachment); + if (bp) { + this.highlightBreakpoint(bp.location, { + openPopup: bp.condition && e.button == 0 + }); + } else { + this.highlightBreakpoint(bp.location); + } + }, + + /** + * The click listener for a breakpoint checkbox. + */ + _onBreakpointCheckboxClick: function (e) { + let sourceItem = this.getItemForElement(e.target); + let breakpointItem = this.getItemForElement.call(sourceItem, e.target); + let bp = getBreakpoint(this.getState(), breakpointItem.attachment); + + if (bp.disabled) { + this.actions.enableBreakpoint(bp.location); + } + else { + this.actions.disableBreakpoint(bp.location); + } + + // Don't update the editor location (avoid propagating into _onBreakpointClick). + e.preventDefault(); + e.stopPropagation(); + }, + + /** + * The popup showing listener for the breakpoints conditional expression panel. + */ + _onConditionalPopupShowing: function () { + this._conditionalPopupVisible = true; // Used in tests. + }, + + /** + * The popup shown listener for the breakpoints conditional expression panel. + */ + _onConditionalPopupShown: function () { + this._cbTextbox.focus(); + this._cbTextbox.select(); + window.emit(EVENTS.CONDITIONAL_BREAKPOINT_POPUP_SHOWN); + }, + + /** + * The popup hiding listener for the breakpoints conditional expression panel. + */ + _onConditionalPopupHiding: function () { + this._conditionalPopupVisible = false; // Used in tests. + + // Check if this is an enabled conditional breakpoint, and if so, + // save the current conditional expression. + let bp = this._selectedBreakpoint; + if (bp) { + let condition = this._cbTextbox.value; + this.actions.setBreakpointCondition(bp.location, condition); + } + }, + + /** + * The popup hidden listener for the breakpoints conditional expression panel. + */ + _onConditionalPopupHidden: function () { + this._cbPanel.hidden = true; + window.emit(EVENTS.CONDITIONAL_BREAKPOINT_POPUP_HIDDEN); + }, + + /** + * The keypress listener for the breakpoints conditional expression textbox. + */ + _onConditionalTextboxKeyPress: function (e) { + if (e.keyCode == KeyCodes.DOM_VK_RETURN) { + this._hideConditionalPopup(); + } + }, + + /** + * Called when the add breakpoint key sequence was pressed. + */ + _onCmdAddBreakpoint: function (e) { + let actor = this.selectedValue; + let line = (this.DebuggerView.clickedLine ? + this.DebuggerView.clickedLine + 1 : + this.DebuggerView.editor.getCursor().line + 1); + let location = { actor, line }; + let bp = getBreakpoint(this.getState(), location); + + // If a breakpoint already existed, remove it now. + if (bp) { + this.actions.removeBreakpoint(bp.location); + } + // No breakpoint existed at the required location, add one now. + else { + this.actions.addBreakpoint(location); + } + }, + + /** + * Called when the add conditional breakpoint key sequence was pressed. + */ + _onCmdAddConditionalBreakpoint: function (e) { + let actor = this.selectedValue; + let line = (this.DebuggerView.clickedLine ? + this.DebuggerView.clickedLine + 1 : + this.DebuggerView.editor.getCursor().line + 1); + + let location = { actor, line }; + let bp = getBreakpoint(this.getState(), location); + + // If a breakpoint already existed or wasn't a conditional, morph it now. + if (bp) { + this.highlightBreakpoint(bp.location, { openPopup: true }); + } + // No breakpoint existed at the required location, add one now. + else { + this.actions.addBreakpoint(location, ""); + } + }, + + getOtherBreakpoints: function (location) { + const bps = getBreakpoints(this.getState()); + if (location) { + return bps.filter(bp => { + return (bp.location.actor !== location.actor || + bp.location.line !== location.line); + }); + } + return bps; + }, + + /** + * Function invoked on the "setConditional" menuitem command. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _onSetConditional: function (aLocation) { + // Highlight the breakpoint and show a conditional expression popup. + this.highlightBreakpoint(aLocation, { openPopup: true }); + }, + + /** + * Function invoked on the "enableSelf" menuitem command. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _onEnableSelf: function (aLocation) { + // Enable the breakpoint, in this container and the controller store. + this.actions.enableBreakpoint(aLocation); + }, + + /** + * Function invoked on the "disableSelf" menuitem command. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _onDisableSelf: function (aLocation) { + const bp = getBreakpoint(this.getState(), aLocation); + if (!bp.disabled) { + this.actions.disableBreakpoint(aLocation); + } + }, + + /** + * Function invoked on the "deleteSelf" menuitem command. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _onDeleteSelf: function (aLocation) { + this.actions.removeBreakpoint(aLocation); + }, + + /** + * Function invoked on the "enableOthers" menuitem command. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _onEnableOthers: function (aLocation) { + let other = this.getOtherBreakpoints(aLocation); + // TODO(jwl): batch these and interrupt the thread for all of them + other.forEach(bp => this._onEnableSelf(bp.location)); + }, + + /** + * Function invoked on the "disableOthers" menuitem command. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _onDisableOthers: function (aLocation) { + let other = this.getOtherBreakpoints(aLocation); + other.forEach(bp => this._onDisableSelf(bp.location)); + }, + + /** + * Function invoked on the "deleteOthers" menuitem command. + * + * @param object aLocation + * @see DebuggerController.Breakpoints.addBreakpoint + */ + _onDeleteOthers: function (aLocation) { + let other = this.getOtherBreakpoints(aLocation); + other.forEach(bp => this._onDeleteSelf(bp.location)); + }, + + /** + * Function invoked on the "enableAll" menuitem command. + */ + _onEnableAll: function () { + this._onEnableOthers(undefined); + }, + + /** + * Function invoked on the "disableAll" menuitem command. + */ + _onDisableAll: function () { + this._onDisableOthers(undefined); + }, + + /** + * Function invoked on the "deleteAll" menuitem command. + */ + _onDeleteAll: function () { + this._onDeleteOthers(undefined); + }, + + _commandset: null, + _popupset: null, + _cmPopup: null, + _cbPanel: null, + _cbTextbox: null, + _selectedBreakpointItem: null, + _conditionalPopupVisible: false, + _noResultsFoundToolTip: null, + _markedIdentifier: null, + _selectedBreakpoint: null, + _conditionalPopupVisible: false +}); + +module.exports = SourcesView; |