/* vim:set ts=2 sw=2 sts=2 et: * 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/. */ /* * Original version history can be found here: * https://github.com/mozilla/workspace * * Copied and relicensed from the Public Domain. * See bug 653934 for details. * https://bugzilla.mozilla.org/show_bug.cgi?id=653934 */ "use strict"; var Cu = Components.utils; var Cc = Components.classes; var Ci = Components.interfaces; const SCRATCHPAD_CONTEXT_CONTENT = 1; const SCRATCHPAD_CONTEXT_BROWSER = 2; const BUTTON_POSITION_SAVE = 0; const BUTTON_POSITION_CANCEL = 1; const BUTTON_POSITION_DONT_SAVE = 2; const BUTTON_POSITION_REVERT = 0; const EVAL_FUNCTION_TIMEOUT = 1000; // milliseconds const MAXIMUM_FONT_SIZE = 96; const MINIMUM_FONT_SIZE = 6; const NORMAL_FONT_SIZE = 12; const SCRATCHPAD_L10N = "chrome://devtools/locale/scratchpad.properties"; const DEVTOOLS_CHROME_ENABLED = "devtools.chrome.enabled"; const PREF_RECENT_FILES_MAX = "devtools.scratchpad.recentFilesMax"; const SHOW_LINE_NUMBERS = "devtools.scratchpad.lineNumbers"; const WRAP_TEXT = "devtools.scratchpad.wrapText"; const SHOW_TRAILING_SPACE = "devtools.scratchpad.showTrailingSpace"; const EDITOR_FONT_SIZE = "devtools.scratchpad.editorFontSize"; const ENABLE_AUTOCOMPLETION = "devtools.scratchpad.enableAutocompletion"; const TAB_SIZE = "devtools.editor.tabsize"; const FALLBACK_CHARSET_LIST = "intl.fallbackCharsetList.ISO-8859-1"; const VARIABLES_VIEW_URL = "chrome://devtools/content/shared/widgets/VariablesView.xul"; const {require, loader} = Cu.import("resource://devtools/shared/Loader.jsm", {}); const Editor = require("devtools/client/sourceeditor/editor"); const TargetFactory = require("devtools/client/framework/target").TargetFactory; const EventEmitter = require("devtools/shared/event-emitter"); const {DevToolsWorker} = require("devtools/shared/worker/worker"); const DevToolsUtils = require("devtools/shared/DevToolsUtils"); const flags = require("devtools/shared/flags"); const promise = require("promise"); const Services = require("Services"); const {gDevTools} = require("devtools/client/framework/devtools"); const {Heritage} = require("devtools/client/shared/widgets/view-helpers"); const {XPCOMUtils} = require("resource://gre/modules/XPCOMUtils.jsm"); const {NetUtil} = require("resource://gre/modules/NetUtil.jsm"); const {ScratchpadManager} = require("resource://devtools/client/scratchpad/scratchpad-manager.jsm"); const {addDebuggerToGlobal} = require("resource://gre/modules/jsdebugger.jsm"); const {OS} = require("resource://gre/modules/osfile.jsm"); const {Reflect} = require("resource://gre/modules/reflect.jsm"); XPCOMUtils.defineConstant(this, "SCRATCHPAD_CONTEXT_CONTENT", SCRATCHPAD_CONTEXT_CONTENT); XPCOMUtils.defineConstant(this, "SCRATCHPAD_CONTEXT_BROWSER", SCRATCHPAD_CONTEXT_BROWSER); XPCOMUtils.defineConstant(this, "BUTTON_POSITION_SAVE", BUTTON_POSITION_SAVE); XPCOMUtils.defineConstant(this, "BUTTON_POSITION_CANCEL", BUTTON_POSITION_CANCEL); XPCOMUtils.defineConstant(this, "BUTTON_POSITION_DONT_SAVE", BUTTON_POSITION_DONT_SAVE); XPCOMUtils.defineConstant(this, "BUTTON_POSITION_REVERT", BUTTON_POSITION_REVERT); XPCOMUtils.defineLazyModuleGetter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "VariablesViewController", "resource://devtools/client/shared/widgets/VariablesViewController.jsm"); loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true); loader.lazyRequireGetter(this, "DebuggerClient", "devtools/shared/client/main", true); loader.lazyRequireGetter(this, "EnvironmentClient", "devtools/shared/client/main", true); loader.lazyRequireGetter(this, "ObjectClient", "devtools/shared/client/main", true); loader.lazyRequireGetter(this, "HUDService", "devtools/client/webconsole/hudservice", true); XPCOMUtils.defineLazyGetter(this, "REMOTE_TIMEOUT", () => Services.prefs.getIntPref("devtools.debugger.remote-timeout")); XPCOMUtils.defineLazyModuleGetter(this, "ShortcutUtils", "resource://gre/modules/ShortcutUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Reflect", "resource://gre/modules/reflect.jsm"); var WebConsoleUtils = require("devtools/client/webconsole/utils").Utils; /** * The scratchpad object handles the Scratchpad window functionality. */ var Scratchpad = { _instanceId: null, _initialWindowTitle: document.title, _dirty: false, /** * Check if provided string is a mode-line and, if it is, return an * object with its values. * * @param string aLine * @return string */ _scanModeLine: function SP__scanModeLine(aLine = "") { aLine = aLine.trim(); let obj = {}; let ch1 = aLine.charAt(0); let ch2 = aLine.charAt(1); if (ch1 !== "/" || (ch2 !== "*" && ch2 !== "/")) { return obj; } aLine = aLine .replace(/^\/\//, "") .replace(/^\/\*/, "") .replace(/\*\/$/, ""); aLine.split(",").forEach(pair => { let [key, val] = pair.split(":"); if (key && val) { obj[key.trim()] = val.trim(); } }); return obj; }, /** * Add the event listeners for popupshowing events. */ _setupPopupShowingListeners: function SP_setupPopupShowing() { let elementIDs = ["sp-menu_editpopup", "scratchpad-text-popup"]; for (let elementID of elementIDs) { let elem = document.getElementById(elementID); if (elem) { elem.addEventListener("popupshowing", function () { goUpdateGlobalEditMenuItems(); let commands = ["cmd_undo", "cmd_redo", "cmd_delete", "cmd_findAgain"]; commands.forEach(goUpdateCommand); }); } } }, /** * Add the event event listeners for command events. */ _setupCommandListeners: function SP_setupCommands() { let commands = { "cmd_find": () => { goDoCommand("cmd_find"); }, "cmd_findAgain": () => { goDoCommand("cmd_findAgain"); }, "cmd_gotoLine": () => { goDoCommand("cmd_gotoLine"); }, "sp-cmd-newWindow": () => { Scratchpad.openScratchpad(); }, "sp-cmd-openFile": () => { Scratchpad.openFile(); }, "sp-cmd-clearRecentFiles": () => { Scratchpad.clearRecentFiles(); }, "sp-cmd-save": () => { Scratchpad.saveFile(); }, "sp-cmd-saveas": () => { Scratchpad.saveFileAs(); }, "sp-cmd-revert": () => { Scratchpad.promptRevert(); }, "sp-cmd-close": () => { Scratchpad.close(); }, "sp-cmd-run": () => { Scratchpad.run(); }, "sp-cmd-inspect": () => { Scratchpad.inspect(); }, "sp-cmd-display": () => { Scratchpad.display(); }, "sp-cmd-pprint": () => { Scratchpad.prettyPrint(); }, "sp-cmd-contentContext": () => { Scratchpad.setContentContext(); }, "sp-cmd-browserContext": () => { Scratchpad.setBrowserContext(); }, "sp-cmd-reloadAndRun": () => { Scratchpad.reloadAndRun(); }, "sp-cmd-evalFunction": () => { Scratchpad.evalTopLevelFunction(); }, "sp-cmd-errorConsole": () => { Scratchpad.openErrorConsole(); }, "sp-cmd-webConsole": () => { Scratchpad.openWebConsole(); }, "sp-cmd-documentationLink": () => { Scratchpad.openDocumentationPage(); }, "sp-cmd-hideSidebar": () => { Scratchpad.sidebar.hide(); }, "sp-cmd-line-numbers": () => { Scratchpad.toggleEditorOption("lineNumbers", SHOW_LINE_NUMBERS); }, "sp-cmd-wrap-text": () => { Scratchpad.toggleEditorOption("lineWrapping", WRAP_TEXT); }, "sp-cmd-highlight-trailing-space": () => { Scratchpad.toggleEditorOption("showTrailingSpace", SHOW_TRAILING_SPACE); }, "sp-cmd-larger-font": () => { Scratchpad.increaseFontSize(); }, "sp-cmd-smaller-font": () => { Scratchpad.decreaseFontSize(); }, "sp-cmd-normal-font": () => { Scratchpad.normalFontSize(); }, }; for (let command in commands) { let elem = document.getElementById(command); if (elem) { elem.addEventListener("command", commands[command]); } } }, /** * Check or uncheck view menu items according to stored preferences. */ _updateViewMenuItems: function SP_updateViewMenuItems() { this._updateViewMenuItem(SHOW_LINE_NUMBERS, "sp-menu-line-numbers"); this._updateViewMenuItem(WRAP_TEXT, "sp-menu-word-wrap"); this._updateViewMenuItem(SHOW_TRAILING_SPACE, "sp-menu-highlight-trailing-space"); this._updateViewFontMenuItem(MINIMUM_FONT_SIZE, "sp-cmd-smaller-font"); this._updateViewFontMenuItem(MAXIMUM_FONT_SIZE, "sp-cmd-larger-font"); }, /** * Check or uncheck view menu item according to stored preferences. */ _updateViewMenuItem: function SP_updateViewMenuItem(preferenceName, menuId) { let checked = Services.prefs.getBoolPref(preferenceName); if (checked) { document.getElementById(menuId).setAttribute("checked", true); } else { document.getElementById(menuId).removeAttribute("checked"); } }, /** * Disable view menu item if the stored font size is equals to the given one. */ _updateViewFontMenuItem: function SP_updateViewFontMenuItem(fontSize, commandId) { let prefFontSize = Services.prefs.getIntPref(EDITOR_FONT_SIZE); if (prefFontSize === fontSize) { document.getElementById(commandId).setAttribute("disabled", true); } }, /** * The script execution context. This tells Scratchpad in which context the * script shall execute. * * Possible values: * - SCRATCHPAD_CONTEXT_CONTENT to execute code in the context of the current * tab content window object. * - SCRATCHPAD_CONTEXT_BROWSER to execute code in the context of the * currently active chrome window object. */ executionContext: SCRATCHPAD_CONTEXT_CONTENT, /** * Tells if this Scratchpad is initialized and ready for use. * @boolean * @see addObserver */ initialized: false, /** * Returns the 'dirty' state of this Scratchpad. */ get dirty() { let clean = this.editor && this.editor.isClean(); return this._dirty || !clean; }, /** * Sets the 'dirty' state of this Scratchpad. */ set dirty(aValue) { this._dirty = aValue; if (!aValue && this.editor) this.editor.setClean(); this._updateTitle(); }, /** * Retrieve the xul:notificationbox DOM element. It notifies the user when * the current code execution context is SCRATCHPAD_CONTEXT_BROWSER. */ get notificationBox() { return document.getElementById("scratchpad-notificationbox"); }, /** * Hide the menu bar. */ hideMenu: function SP_hideMenu() { document.getElementById("sp-menubar").style.display = "none"; }, /** * Show the menu bar. */ showMenu: function SP_showMenu() { document.getElementById("sp-menubar").style.display = ""; }, /** * Get the editor content, in the given range. If no range is given you get * the entire editor content. * * @param number [aStart=0] * Optional, start from the given offset. * @param number [aEnd=content char count] * Optional, end offset for the text you want. If this parameter is not * given, then the text returned goes until the end of the editor * content. * @return string * The text in the given range. */ getText: function SP_getText(aStart, aEnd) { var value = this.editor.getText(); return value.slice(aStart || 0, aEnd || value.length); }, /** * Set the filename in the scratchpad UI and object * * @param string aFilename * The new filename */ setFilename: function SP_setFilename(aFilename) { this.filename = aFilename; this._updateTitle(); }, /** * Update the Scratchpad window title based on the current state. * @private */ _updateTitle: function SP__updateTitle() { let title = this.filename || this._initialWindowTitle; if (this.dirty) title = "*" + title; document.title = title; }, /** * Get the current state of the scratchpad. Called by the * Scratchpad Manager for session storing. * * @return object * An object with 3 properties: filename, text, and * executionContext. */ getState: function SP_getState() { return { filename: this.filename, text: this.getText(), executionContext: this.executionContext, saved: !this.dirty }; }, /** * Set the filename and execution context using the given state. Called * when scratchpad is being restored from a previous session. * * @param object aState * An object with filename and executionContext properties. */ setState: function SP_setState(aState) { if (aState.filename) this.setFilename(aState.filename); this.dirty = !aState.saved; if (aState.executionContext == SCRATCHPAD_CONTEXT_BROWSER) this.setBrowserContext(); else this.setContentContext(); }, /** * Get the most recent main chrome browser window */ get browserWindow() { return Services.wm.getMostRecentWindow(gDevTools.chromeWindowType); }, /** * Get the gBrowser object of the most recent browser window. */ get gBrowser() { let recentWin = this.browserWindow; return recentWin ? recentWin.gBrowser : null; }, /** * Unique name for the current Scratchpad instance. Used to distinguish * Scratchpad windows between each other. See bug 661762. */ get uniqueName() { return "Scratchpad/" + this._instanceId; }, /** * Sidebar that contains the VariablesView for object inspection. */ get sidebar() { if (!this._sidebar) { this._sidebar = new ScratchpadSidebar(this); } return this._sidebar; }, /** * Replaces context of an editor with provided value (a string). * Note: this method is simply a shortcut to editor.setText. */ setText: function SP_setText(value) { return this.editor.setText(value); }, /** * Evaluate a string in the currently desired context, that is either the * chrome window or the tab content window object. * * @param string aString * The script you want to evaluate. * @return Promise * The promise for the script evaluation result. */ evaluate: function SP_evaluate(aString) { let connection; if (this.target) { connection = ScratchpadTarget.consoleFor(this.target); } else if (this.executionContext == SCRATCHPAD_CONTEXT_CONTENT) { connection = ScratchpadTab.consoleFor(this.gBrowser.selectedTab); } else { connection = ScratchpadWindow.consoleFor(this.browserWindow); } let evalOptions = { url: this.uniqueName }; return connection.then(({ debuggerClient, webConsoleClient }) => { let deferred = promise.defer(); webConsoleClient.evaluateJSAsync(aString, aResponse => { this.debuggerClient = debuggerClient; this.webConsoleClient = webConsoleClient; if (aResponse.error) { deferred.reject(aResponse); } else if (aResponse.exception !== null) { deferred.resolve([aString, aResponse]); } else { deferred.resolve([aString, undefined, aResponse.result]); } }, evalOptions); return deferred.promise; }); }, /** * Execute the selected text (if any) or the entire editor content in the * current context. * * @return Promise * The promise for the script evaluation result. */ execute: function SP_execute() { WebConsoleUtils.usageCount++; let selection = this.editor.getSelection() || this.getText(); return this.evaluate(selection); }, /** * Execute the selected text (if any) or the entire editor content in the * current context. * * @return Promise * The promise for the script evaluation result. */ run: function SP_run() { let deferred = promise.defer(); let reject = aReason => deferred.reject(aReason); this.execute().then(([aString, aError, aResult]) => { let resolve = () => deferred.resolve([aString, aError, aResult]); if (aError) { this.writeAsErrorComment(aError).then(resolve, reject); } else { this.editor.dropSelection(); resolve(); } }, reject); return deferred.promise; }, /** * Execute the selected text (if any) or the entire editor content in the * current context. The resulting object is inspected up in the sidebar. * * @return Promise * The promise for the script evaluation result. */ inspect: function SP_inspect() { let deferred = promise.defer(); let reject = aReason => deferred.reject(aReason); this.execute().then(([aString, aError, aResult]) => { let resolve = () => deferred.resolve([aString, aError, aResult]); if (aError) { this.writeAsErrorComment(aError).then(resolve, reject); } else { this.editor.dropSelection(); this.sidebar.open(aString, aResult).then(resolve, reject); } }, reject); return deferred.promise; }, /** * Reload the current page and execute the entire editor content when * the page finishes loading. Note that this operation should be available * only in the content context. * * @return Promise * The promise for the script evaluation result. */ reloadAndRun: function SP_reloadAndRun() { let deferred = promise.defer(); if (this.executionContext !== SCRATCHPAD_CONTEXT_CONTENT) { console.error(this.strings. GetStringFromName("scratchpadContext.invalid")); return; } let target = TargetFactory.forTab(this.gBrowser.selectedTab); target.once("navigate", () => { this.run().then(results => deferred.resolve(results)); }); target.makeRemote().then(() => target.activeTab.reload()); return deferred.promise; }, /** * Execute the selected text (if any) or the entire editor content in the * current context. The evaluation result is inserted into the editor after * the selected text, or at the end of the editor content if there is no * selected text. * * @return Promise * The promise for the script evaluation result. */ display: function SP_display() { let deferred = promise.defer(); let reject = aReason => deferred.reject(aReason); this.execute().then(([aString, aError, aResult]) => { let resolve = () => deferred.resolve([aString, aError, aResult]); if (aError) { this.writeAsErrorComment(aError).then(resolve, reject); } else if (VariablesView.isPrimitive({ value: aResult })) { this._writePrimitiveAsComment(aResult).then(resolve, reject); } else { let objectClient = new ObjectClient(this.debuggerClient, aResult); objectClient.getDisplayString(aResponse => { if (aResponse.error) { reportError("display", aResponse); reject(aResponse); } else { this.writeAsComment(aResponse.displayString); resolve(); } }); } }, reject); return deferred.promise; }, _prettyPrintWorker: null, /** * Get or create the worker that handles pretty printing. */ get prettyPrintWorker() { if (!this._prettyPrintWorker) { this._prettyPrintWorker = new DevToolsWorker( "resource://devtools/server/actors/pretty-print-worker.js", { name: "pretty-print", verbose: flags.wantLogging } ); } return this._prettyPrintWorker; }, /** * Pretty print the source text inside the scratchpad. * * @return Promise * A promise resolved with the pretty printed code, or rejected with * an error. */ prettyPrint: function SP_prettyPrint() { const uglyText = this.getText(); const tabsize = Services.prefs.getIntPref(TAB_SIZE); return this.prettyPrintWorker.performTask("pretty-print", { url: "(scratchpad)", indent: tabsize, source: uglyText }).then(data => { this.editor.setText(data.code); }).then(null, error => { this.writeAsErrorComment({ exception: error }); throw error; }); }, /** * Parse the text and return an AST. If we can't parse it, write an error * comment and return false. */ _parseText: function SP__parseText(aText) { try { return Reflect.parse(aText); } catch (e) { this.writeAsErrorComment({ exception: DevToolsUtils.safeErrorString(e) }); return false; } }, /** * Determine if the given AST node location contains the given cursor * position. * * @returns Boolean */ _containsCursor: function (aLoc, aCursorPos) { // Our line numbers are 1-based, while CodeMirror's are 0-based. const lineNumber = aCursorPos.line + 1; const columnNumber = aCursorPos.ch; if (aLoc.start.line <= lineNumber && aLoc.end.line >= lineNumber) { if (aLoc.start.line === aLoc.end.line) { return aLoc.start.column <= columnNumber && aLoc.end.column >= columnNumber; } if (aLoc.start.line == lineNumber) { return columnNumber >= aLoc.start.column; } if (aLoc.end.line == lineNumber) { return columnNumber <= aLoc.end.column; } return true; } return false; }, /** * Find the top level function AST node that the cursor is within. * * @returns Object|null */ _findTopLevelFunction: function SP__findTopLevelFunction(aAst, aCursorPos) { for (let statement of aAst.body) { switch (statement.type) { case "FunctionDeclaration": if (this._containsCursor(statement.loc, aCursorPos)) { return statement; } break; case "VariableDeclaration": for (let decl of statement.declarations) { if (!decl.init) { continue; } if ((decl.init.type == "FunctionExpression" || decl.init.type == "ArrowFunctionExpression") && this._containsCursor(decl.loc, aCursorPos)) { return decl; } } break; } } return null; }, /** * Get the source text associated with the given function statement. * * @param Object aFunction * @param String aFullText * @returns String */ _getFunctionText: function SP__getFunctionText(aFunction, aFullText) { let functionText = ""; // Initially set to 0, but incremented first thing in the loop below because // line numbers are 1 based, not 0 based. let lineNumber = 0; const { start, end } = aFunction.loc; const singleLine = start.line === end.line; for (let line of aFullText.split(/\n/g)) { lineNumber++; if (singleLine && start.line === lineNumber) { functionText = line.slice(start.column, end.column); break; } if (start.line === lineNumber) { functionText += line.slice(start.column) + "\n"; continue; } if (end.line === lineNumber) { functionText += line.slice(0, end.column); break; } if (start.line < lineNumber && end.line > lineNumber) { functionText += line + "\n"; } } return functionText; }, /** * Evaluate the top level function that the cursor is resting in. * * @returns Promise [text, error, result] */ evalTopLevelFunction: function SP_evalTopLevelFunction() { const text = this.getText(); const ast = this._parseText(text); if (!ast) { return promise.resolve([text, undefined, undefined]); } const cursorPos = this.editor.getCursor(); const funcStatement = this._findTopLevelFunction(ast, cursorPos); if (!funcStatement) { return promise.resolve([text, undefined, undefined]); } let functionText = this._getFunctionText(funcStatement, text); // TODO: This is a work around for bug 940086. It should be removed when // that is fixed. if (funcStatement.type == "FunctionDeclaration" && !functionText.startsWith("function ")) { functionText = "function " + functionText; funcStatement.loc.start.column -= 9; } // The decrement by one is because our line numbers are 1-based, while // CodeMirror's are 0-based. const from = { line: funcStatement.loc.start.line - 1, ch: funcStatement.loc.start.column }; const to = { line: funcStatement.loc.end.line - 1, ch: funcStatement.loc.end.column }; const marker = this.editor.markText(from, to, "eval-text"); setTimeout(() => marker.clear(), EVAL_FUNCTION_TIMEOUT); return this.evaluate(functionText); }, /** * Writes out a primitive value as a comment. This handles values which are * to be printed directly (number, string) as well as grips to values * (null, undefined, longString). * * @param any aValue * The value to print. * @return Promise * The promise that resolves after the value has been printed. */ _writePrimitiveAsComment: function SP__writePrimitiveAsComment(aValue) { let deferred = promise.defer(); if (aValue.type == "longString") { let client = this.webConsoleClient; client.longString(aValue).substring(0, aValue.length, aResponse => { if (aResponse.error) { reportError("display", aResponse); deferred.reject(aResponse); } else { deferred.resolve(aResponse.substring); } }); } else { deferred.resolve(aValue.type || aValue); } return deferred.promise.then(aComment => { this.writeAsComment(aComment); }); }, /** * Write out a value at the next line from the current insertion point. * The comment block will always be preceded by a newline character. * @param object aValue * The Object to write out as a string */ writeAsComment: function SP_writeAsComment(aValue) { let value = "\n/*\n" + aValue + "\n*/"; if (this.editor.somethingSelected()) { let from = this.editor.getCursor("end"); this.editor.replaceSelection(this.editor.getSelection() + value); let to = this.editor.getPosition(this.editor.getOffset(from) + value.length); this.editor.setSelection(from, to); return; } let text = this.editor.getText(); this.editor.setText(text + value); let [ from, to ] = this.editor.getPosition(text.length, (text + value).length); this.editor.setSelection(from, to); }, /** * Write out an error at the current insertion point as a block comment * @param object aValue * The error object to write out the message and stack trace. It must * contain an |exception| property with the actual error thrown, but it * will often be the entire response of an evaluateJS request. * @return Promise * The promise that indicates when writing the comment completes. */ writeAsErrorComment: function SP_writeAsErrorComment(aError) { let deferred = promise.defer(); if (VariablesView.isPrimitive({ value: aError.exception })) { let error = aError.exception; let type = error.type; if (type == "undefined" || type == "null" || type == "Infinity" || type == "-Infinity" || type == "NaN" || type == "-0") { deferred.resolve(type); } else if (type == "longString") { deferred.resolve(error.initial + "\u2026"); } else { deferred.resolve(error); } } else if ("preview" in aError.exception) { let error = aError.exception; let stack = this._constructErrorStack(error.preview); if (typeof aError.exceptionMessage == "string") { deferred.resolve(aError.exceptionMessage + stack); } else { deferred.resolve(stack); } } else { // If there is no preview information, we need to ask the server for more. let objectClient = new ObjectClient(this.debuggerClient, aError.exception); objectClient.getPrototypeAndProperties(aResponse => { if (aResponse.error) { deferred.reject(aResponse); return; } let { ownProperties, safeGetterValues } = aResponse; let error = Object.create(null); // Combine all the property descriptor/getter values into one object. for (let key of Object.keys(safeGetterValues)) { error[key] = safeGetterValues[key].getterValue; } for (let key of Object.keys(ownProperties)) { error[key] = ownProperties[key].value; } let stack = this._constructErrorStack(error); if (typeof error.message == "string") { deferred.resolve(error.message + stack); } else { objectClient.getDisplayString(aResponse => { if (aResponse.error) { deferred.reject(aResponse); } else if (typeof aResponse.displayString == "string") { deferred.resolve(aResponse.displayString + stack); } else { deferred.resolve(stack); } }); } }); } return deferred.promise.then(aMessage => { console.error(aMessage); this.writeAsComment("Exception: " + aMessage); }); }, /** * Assembles the best possible stack from the properties of the provided * error. */ _constructErrorStack(error) { let stack; if (typeof error.stack == "string" && error.stack) { stack = error.stack; } else if (typeof error.fileName == "string") { stack = "@" + error.fileName; if (typeof error.lineNumber == "number") { stack += ":" + error.lineNumber; } } else if (typeof error.filename == "string") { stack = "@" + error.filename; if (typeof error.lineNumber == "number") { stack += ":" + error.lineNumber; if (typeof error.columnNumber == "number") { stack += ":" + error.columnNumber; } } } else if (typeof error.lineNumber == "number") { stack = "@" + error.lineNumber; if (typeof error.columnNumber == "number") { stack += ":" + error.columnNumber; } } return stack ? "\n" + stack.replace(/\n$/, "") : ""; }, // Menu Operations /** * Open a new Scratchpad window. * * @return nsIWindow */ openScratchpad: function SP_openScratchpad() { return ScratchpadManager.openScratchpad(); }, /** * Export the textbox content to a file. * * @param nsILocalFile aFile * The file where you want to save the textbox content. * @param boolean aNoConfirmation * If the file already exists, ask for confirmation? * @param boolean aSilentError * True if you do not want to display an error when file save fails, * false otherwise. * @param function aCallback * Optional function you want to call when file save completes. It will * get the following arguments: * 1) the nsresult status code for the export operation. */ exportToFile: function SP_exportToFile(aFile, aNoConfirmation, aSilentError, aCallback) { if (!aNoConfirmation && aFile.exists() && !window.confirm(this.strings. GetStringFromName("export.fileOverwriteConfirmation"))) { return; } let encoder = new TextEncoder(); let buffer = encoder.encode(this.getText()); let writePromise = OS.File.writeAtomic(aFile.path, buffer, {tmpPath: aFile.path + ".tmp"}); writePromise.then(value => { if (aCallback) { aCallback.call(this, Components.results.NS_OK); } }, reason => { if (!aSilentError) { window.alert(this.strings.GetStringFromName("saveFile.failed")); } if (aCallback) { aCallback.call(this, Components.results.NS_ERROR_UNEXPECTED); } }); }, /** * Get a list of applicable charsets. * The best charset, defaulting to "UTF-8" * * @param string aBestCharset * @return array of strings */ _getApplicableCharsets: function SP__getApplicableCharsets(aBestCharset = "UTF-8") { let charsets = Services.prefs.getCharPref( FALLBACK_CHARSET_LIST).split(",").filter(function (value) { return value.length; }); charsets.unshift(aBestCharset); return charsets; }, /** * Get content converted to unicode, using a list of input charset to try. * * @param string aContent * @param array of string aCharsetArray * @return string */ _getUnicodeContent: function SP__getUnicodeContent(aContent, aCharsetArray) { let content = null, converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].createInstance(Ci.nsIScriptableUnicodeConverter), success = aCharsetArray.some(charset => { try { converter.charset = charset; content = converter.ConvertToUnicode(aContent); return true; } catch (e) { this.notificationBox.appendNotification( this.strings.formatStringFromName("importFromFile.convert.failed", [ charset ], 1), "file-import-convert-failed", null, this.notificationBox.PRIORITY_WARNING_HIGH, null); } }); return content; }, /** * Read the content of a file and put it into the textbox. * * @param nsILocalFile aFile * The file you want to save the textbox content into. * @param boolean aSilentError * True if you do not want to display an error when file load fails, * false otherwise. * @param function aCallback * Optional function you want to call when file load completes. It will * get the following arguments: * 1) the nsresult status code for the import operation. * 2) the data that was read from the file, if any. */ importFromFile: function SP_importFromFile(aFile, aSilentError, aCallback) { // Prevent file type detection. let channel = NetUtil.newChannel({ uri: NetUtil.newURI(aFile), loadingNode: window.document, securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_INHERITS, contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER}); channel.contentType = "application/javascript"; this.notificationBox.removeAllNotifications(false); NetUtil.asyncFetch(channel, (aInputStream, aStatus) => { let content = null; if (Components.isSuccessCode(aStatus)) { let charsets = this._getApplicableCharsets(); content = NetUtil.readInputStreamToString(aInputStream, aInputStream.available()); content = this._getUnicodeContent(content, charsets); if (!content) { let message = this.strings.formatStringFromName( "importFromFile.convert.failed", [ charsets.join(", ") ], 1); this.notificationBox.appendNotification( message, "file-import-convert-failed", null, this.notificationBox.PRIORITY_CRITICAL_MEDIUM, null); if (aCallback) { aCallback.call(this, aStatus, content); } return; } // Check to see if the first line is a mode-line comment. let line = content.split("\n")[0]; let modeline = this._scanModeLine(line); let chrome = Services.prefs.getBoolPref(DEVTOOLS_CHROME_ENABLED); if (chrome && modeline["-sp-context"] === "browser") { this.setBrowserContext(); } this.editor.setText(content); this.editor.clearHistory(); this.dirty = false; document.getElementById("sp-cmd-revert").setAttribute("disabled", true); } else if (!aSilentError) { window.alert(this.strings.GetStringFromName("openFile.failed")); } this.setFilename(aFile.path); this.setRecentFile(aFile); if (aCallback) { aCallback.call(this, aStatus, content); } }); }, /** * Open a file to edit in the Scratchpad. * * @param integer aIndex * Optional integer: clicked menuitem in the 'Open Recent'-menu. */ openFile: function SP_openFile(aIndex) { let promptCallback = aFile => { this.promptSave((aCloseFile, aSaved, aStatus) => { let shouldOpen = aCloseFile; if (aSaved && !Components.isSuccessCode(aStatus)) { shouldOpen = false; } if (shouldOpen) { let file; if (aFile) { file = aFile; } else { file = Components.classes["@mozilla.org/file/local;1"]. createInstance(Components.interfaces.nsILocalFile); let filePath = this.getRecentFiles()[aIndex]; file.initWithPath(filePath); } if (!file.exists()) { this.notificationBox.appendNotification( this.strings.GetStringFromName("fileNoLongerExists.notification"), "file-no-longer-exists", null, this.notificationBox.PRIORITY_WARNING_HIGH, null); this.clearFiles(aIndex, 1); return; } this.importFromFile(file, false); } }); }; if (aIndex > -1) { promptCallback(); } else { let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); fp.init(window, this.strings.GetStringFromName("openFile.title"), Ci.nsIFilePicker.modeOpen); fp.defaultString = ""; fp.appendFilter("JavaScript Files", "*.js; *.jsm; *.json"); fp.appendFilter("All Files", "*.*"); fp.open(aResult => { if (aResult != Ci.nsIFilePicker.returnCancel) { promptCallback(fp.file); } }); } }, /** * Get recent files. * * @return Array * File paths. */ getRecentFiles: function SP_getRecentFiles() { let branch = Services.prefs.getBranch("devtools.scratchpad."); let filePaths = []; // WARNING: Do not use getCharPref here, it doesn't play nicely with // Unicode strings. if (branch.prefHasUserValue("recentFilePaths")) { let data = branch.getComplexValue("recentFilePaths", Ci.nsISupportsString).data; filePaths = JSON.parse(data); } return filePaths; }, /** * Save a recent file in a JSON parsable string. * * @param nsILocalFile aFile * The nsILocalFile we want to save as a recent file. */ setRecentFile: function SP_setRecentFile(aFile) { let maxRecent = Services.prefs.getIntPref(PREF_RECENT_FILES_MAX); if (maxRecent < 1) { return; } let filePaths = this.getRecentFiles(); let filesCount = filePaths.length; let pathIndex = filePaths.indexOf(aFile.path); // We are already storing this file in the list of recent files. if (pathIndex > -1) { // If it's already the most recent file, we don't have to do anything. if (pathIndex === (filesCount - 1)) { // Updating the menu to clear the disabled state from the wrong menuitem // in rare cases when two or more Scratchpad windows are open and the // same file has been opened in two or more windows. this.populateRecentFilesMenu(); return; } // It is not the most recent file. Remove it from the list, we add it as // the most recent farther down. filePaths.splice(pathIndex, 1); } // If we are not storing the file and the 'recent files'-list is full, // remove the oldest file from the list. else if (filesCount === maxRecent) { filePaths.shift(); } filePaths.push(aFile.path); // WARNING: Do not use setCharPref here, it doesn't play nicely with // Unicode strings. let str = Cc["@mozilla.org/supports-string;1"] .createInstance(Ci.nsISupportsString); str.data = JSON.stringify(filePaths); let branch = Services.prefs.getBranch("devtools.scratchpad."); branch.setComplexValue("recentFilePaths", Ci.nsISupportsString, str); }, /** * Populates the 'Open Recent'-menu. */ populateRecentFilesMenu: function SP_populateRecentFilesMenu() { let maxRecent = Services.prefs.getIntPref(PREF_RECENT_FILES_MAX); let recentFilesMenu = document.getElementById("sp-open_recent-menu"); if (maxRecent < 1) { recentFilesMenu.setAttribute("hidden", true); return; } let recentFilesPopup = recentFilesMenu.firstChild; let filePaths = this.getRecentFiles(); let filename = this.getState().filename; recentFilesMenu.setAttribute("disabled", true); while (recentFilesPopup.hasChildNodes()) { recentFilesPopup.removeChild(recentFilesPopup.firstChild); } if (filePaths.length > 0) { recentFilesMenu.removeAttribute("disabled"); // Print out menuitems with the most recent file first. for (let i = filePaths.length - 1; i >= 0; --i) { let menuitem = document.createElement("menuitem"); menuitem.setAttribute("type", "radio"); menuitem.setAttribute("label", filePaths[i]); if (filePaths[i] === filename) { menuitem.setAttribute("checked", true); menuitem.setAttribute("disabled", true); } menuitem.addEventListener("command", Scratchpad.openFile.bind(Scratchpad, i)); recentFilesPopup.appendChild(menuitem); } recentFilesPopup.appendChild(document.createElement("menuseparator")); let clearItems = document.createElement("menuitem"); clearItems.setAttribute("id", "sp-menu-clear_recent"); clearItems.setAttribute("label", this.strings. GetStringFromName("clearRecentMenuItems.label")); clearItems.setAttribute("command", "sp-cmd-clearRecentFiles"); recentFilesPopup.appendChild(clearItems); } }, /** * Clear a range of files from the list. * * @param integer aIndex * Index of file in menu to remove. * @param integer aLength * Number of files from the index 'aIndex' to remove. */ clearFiles: function SP_clearFile(aIndex, aLength) { let filePaths = this.getRecentFiles(); filePaths.splice(aIndex, aLength); // WARNING: Do not use setCharPref here, it doesn't play nicely with // Unicode strings. let str = Cc["@mozilla.org/supports-string;1"] .createInstance(Ci.nsISupportsString); str.data = JSON.stringify(filePaths); let branch = Services.prefs.getBranch("devtools.scratchpad."); branch.setComplexValue("recentFilePaths", Ci.nsISupportsString, str); }, /** * Clear all recent files. */ clearRecentFiles: function SP_clearRecentFiles() { Services.prefs.clearUserPref("devtools.scratchpad.recentFilePaths"); }, /** * Handle changes to the 'PREF_RECENT_FILES_MAX'-preference. */ handleRecentFileMaxChange: function SP_handleRecentFileMaxChange() { let maxRecent = Services.prefs.getIntPref(PREF_RECENT_FILES_MAX); let menu = document.getElementById("sp-open_recent-menu"); // Hide the menu if the 'PREF_RECENT_FILES_MAX'-pref is set to zero or less. if (maxRecent < 1) { menu.setAttribute("hidden", true); } else { if (menu.hasAttribute("hidden")) { if (!menu.firstChild.hasChildNodes()) { this.populateRecentFilesMenu(); } menu.removeAttribute("hidden"); } let filePaths = this.getRecentFiles(); if (maxRecent < filePaths.length) { let diff = filePaths.length - maxRecent; this.clearFiles(0, diff); } } }, /** * Save the textbox content to the currently open file. * * @param function aCallback * Optional function you want to call when file is saved */ saveFile: function SP_saveFile(aCallback) { if (!this.filename) { return this.saveFileAs(aCallback); } let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile); file.initWithPath(this.filename); this.exportToFile(file, true, false, aStatus => { if (Components.isSuccessCode(aStatus)) { this.dirty = false; document.getElementById("sp-cmd-revert").setAttribute("disabled", true); this.setRecentFile(file); } if (aCallback) { aCallback(aStatus); } }); }, /** * Save the textbox content to a new file. * * @param function aCallback * Optional function you want to call when file is saved */ saveFileAs: function SP_saveFileAs(aCallback) { let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); let fpCallback = aResult => { if (aResult != Ci.nsIFilePicker.returnCancel) { this.setFilename(fp.file.path); this.exportToFile(fp.file, true, false, aStatus => { if (Components.isSuccessCode(aStatus)) { this.dirty = false; this.setRecentFile(fp.file); } if (aCallback) { aCallback(aStatus); } }); } }; fp.init(window, this.strings.GetStringFromName("saveFileAs"), Ci.nsIFilePicker.modeSave); fp.defaultString = "scratchpad.js"; fp.appendFilter("JavaScript Files", "*.js; *.jsm; *.json"); fp.appendFilter("All Files", "*.*"); fp.open(fpCallback); }, /** * Restore content from saved version of current file. * * @param function aCallback * Optional function you want to call when file is saved */ revertFile: function SP_revertFile(aCallback) { let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile); file.initWithPath(this.filename); if (!file.exists()) { return; } this.importFromFile(file, false, (aStatus, aContent) => { if (aCallback) { aCallback(aStatus); } }); }, /** * Prompt to revert scratchpad if it has unsaved changes. * * @param function aCallback * Optional function you want to call when file is saved. The callback * receives three arguments: * - aRevert (boolean) - tells if the file has been reverted. * - status (number) - the file revert status result (if the file was * saved). */ promptRevert: function SP_promptRervert(aCallback) { if (this.filename) { let ps = Services.prompt; let flags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_REVERT + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL; let button = ps.confirmEx(window, this.strings.GetStringFromName("confirmRevert.title"), this.strings.GetStringFromName("confirmRevert"), flags, null, null, null, null, {}); if (button == BUTTON_POSITION_CANCEL) { if (aCallback) { aCallback(false); } return; } if (button == BUTTON_POSITION_REVERT) { this.revertFile(aStatus => { if (aCallback) { aCallback(true, aStatus); } }); return; } } if (aCallback) { aCallback(false); } }, /** * Open the Error Console. */ openErrorConsole: function SP_openErrorConsole() { HUDService.toggleBrowserConsole(); }, /** * Open the Web Console. */ openWebConsole: function SP_openWebConsole() { let target = TargetFactory.forTab(this.gBrowser.selectedTab); gDevTools.showToolbox(target, "webconsole"); this.browserWindow.focus(); }, /** * Set the current execution context to be the active tab content window. */ setContentContext: function SP_setContentContext() { if (this.executionContext == SCRATCHPAD_CONTEXT_CONTENT) { return; } let content = document.getElementById("sp-menu-content"); document.getElementById("sp-menu-browser").removeAttribute("checked"); document.getElementById("sp-cmd-reloadAndRun").removeAttribute("disabled"); content.setAttribute("checked", true); this.executionContext = SCRATCHPAD_CONTEXT_CONTENT; this.notificationBox.removeAllNotifications(false); }, /** * Set the current execution context to be the most recent chrome window. */ setBrowserContext: function SP_setBrowserContext() { if (this.executionContext == SCRATCHPAD_CONTEXT_BROWSER) { return; } let browser = document.getElementById("sp-menu-browser"); let reloadAndRun = document.getElementById("sp-cmd-reloadAndRun"); document.getElementById("sp-menu-content").removeAttribute("checked"); reloadAndRun.setAttribute("disabled", true); browser.setAttribute("checked", true); this.executionContext = SCRATCHPAD_CONTEXT_BROWSER; this.notificationBox.appendNotification( this.strings.GetStringFromName("browserContext.notification"), SCRATCHPAD_CONTEXT_BROWSER, null, this.notificationBox.PRIORITY_WARNING_HIGH, null); }, /** * Gets the ID of the inner window of the given DOM window object. * * @param nsIDOMWindow aWindow * @return integer * the inner window ID */ getInnerWindowId: function SP_getInnerWindowId(aWindow) { return aWindow.QueryInterface(Ci.nsIInterfaceRequestor). getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID; }, updateStatusBar: function SP_updateStatusBar(aEventType) { var statusBarField = document.getElementById("statusbar-line-col"); let { line, ch } = this.editor.getCursor(); statusBarField.textContent = this.strings.formatStringFromName( "scratchpad.statusBarLineCol", [ line + 1, ch + 1], 2); }, /** * The Scratchpad window load event handler. This method * initializes the Scratchpad window and source editor. * * @param nsIDOMEvent aEvent */ onLoad: function SP_onLoad(aEvent) { if (aEvent.target != document) { return; } let chrome = Services.prefs.getBoolPref(DEVTOOLS_CHROME_ENABLED); if (chrome) { let environmentMenu = document.getElementById("sp-environment-menu"); let errorConsoleCommand = document.getElementById("sp-cmd-errorConsole"); let chromeContextCommand = document.getElementById("sp-cmd-browserContext"); environmentMenu.removeAttribute("hidden"); chromeContextCommand.removeAttribute("disabled"); errorConsoleCommand.removeAttribute("disabled"); } let initialText = this.strings.formatStringFromName( "scratchpadIntro1", [ShortcutUtils.prettifyShortcut(document.getElementById("sp-key-run"), true), ShortcutUtils.prettifyShortcut(document.getElementById("sp-key-inspect"), true), ShortcutUtils.prettifyShortcut(document.getElementById("sp-key-display"), true)], 3); let args = window.arguments; let state = null; if (args && args[0] instanceof Ci.nsIDialogParamBlock) { args = args[0]; this._instanceId = args.GetString(0); state = args.GetString(1) || null; if (state) { state = JSON.parse(state); this.setState(state); if ("text" in state) { initialText = state.text; } } } else { this._instanceId = ScratchpadManager.createUid(); } let config = { mode: Editor.modes.js, value: initialText, lineNumbers: Services.prefs.getBoolPref(SHOW_LINE_NUMBERS), contextMenu: "scratchpad-text-popup", showTrailingSpace: Services.prefs.getBoolPref(SHOW_TRAILING_SPACE), autocomplete: Services.prefs.getBoolPref(ENABLE_AUTOCOMPLETION), lineWrapping: Services.prefs.getBoolPref(WRAP_TEXT), }; this.editor = new Editor(config); let editorElement = document.querySelector("#scratchpad-editor"); this.editor.appendTo(editorElement).then(() => { var lines = initialText.split("\n"); this.editor.setFontSize(Services.prefs.getIntPref(EDITOR_FONT_SIZE)); this.editor.on("change", this._onChanged); // Keep a reference to the bound version for use in onUnload. this.updateStatusBar = Scratchpad.updateStatusBar.bind(this); this.editor.on("cursorActivity", this.updateStatusBar); let okstring = this.strings.GetStringFromName("selfxss.okstring"); let msg = this.strings.formatStringFromName("selfxss.msg", [okstring], 1); this._onPaste = WebConsoleUtils.pasteHandlerGen(this.editor.container.contentDocument.body, document.querySelector("#scratchpad-notificationbox"), msg, okstring); editorElement.addEventListener("paste", this._onPaste, true); editorElement.addEventListener("drop", this._onPaste); this.editor.on("saveRequested", () => this.saveFile()); this.editor.focus(); this.editor.setCursor({ line: lines.length, ch: lines.pop().length }); // Add the commands controller for the source-editor. this.editor.insertCommandsController(); if (state) this.dirty = !state.saved; this.initialized = true; this._triggerObservers("Ready"); this.populateRecentFilesMenu(); PreferenceObserver.init(); CloseObserver.init(); }).then(null, (err) => console.error(err)); this._setupCommandListeners(); this._updateViewMenuItems(); this._setupPopupShowingListeners(); }, /** * The Source Editor "change" event handler. This function updates the * Scratchpad window title to show an asterisk when there are unsaved changes. * * @private */ _onChanged: function SP__onChanged() { Scratchpad._updateTitle(); if (Scratchpad.filename) { if (Scratchpad.dirty) document.getElementById("sp-cmd-revert").removeAttribute("disabled"); else document.getElementById("sp-cmd-revert").setAttribute("disabled", true); } }, /** * Undo the last action of the user. */ undo: function SP_undo() { this.editor.undo(); }, /** * Redo the previously undone action. */ redo: function SP_redo() { this.editor.redo(); }, /** * The Scratchpad window unload event handler. This method unloads/destroys * the source editor. * * @param nsIDOMEvent aEvent */ onUnload: function SP_onUnload(aEvent) { if (aEvent.target != document) { return; } // This event is created only after user uses 'reload and run' feature. if (this._reloadAndRunEvent && this.gBrowser) { this.gBrowser.selectedBrowser.removeEventListener("load", this._reloadAndRunEvent, true); } PreferenceObserver.uninit(); CloseObserver.uninit(); if (this._onPaste) { let editorElement = document.querySelector("#scratchpad-editor"); editorElement.removeEventListener("paste", this._onPaste, true); editorElement.removeEventListener("drop", this._onPaste); this._onPaste = null; } this.editor.off("change", this._onChanged); this.editor.off("cursorActivity", this.updateStatusBar); this.editor.destroy(); this.editor = null; if (this._sidebar) { this._sidebar.destroy(); this._sidebar = null; } if (this._prettyPrintWorker) { this._prettyPrintWorker.destroy(); this._prettyPrintWorker = null; } scratchpadTargets = null; this.webConsoleClient = null; this.debuggerClient = null; this.initialized = false; }, /** * Prompt to save scratchpad if it has unsaved changes. * * @param function aCallback * Optional function you want to call when file is saved. The callback * receives three arguments: * - toClose (boolean) - tells if the window should be closed. * - saved (boolen) - tells if the file has been saved. * - status (number) - the file save status result (if the file was * saved). * @return boolean * Whether the window should be closed */ promptSave: function SP_promptSave(aCallback) { if (this.dirty) { let ps = Services.prompt; let flags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_SAVE + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL + ps.BUTTON_POS_2 * ps.BUTTON_TITLE_DONT_SAVE; let button = ps.confirmEx(window, this.strings.GetStringFromName("confirmClose.title"), this.strings.GetStringFromName("confirmClose"), flags, null, null, null, null, {}); if (button == BUTTON_POSITION_CANCEL) { if (aCallback) { aCallback(false, false); } return false; } if (button == BUTTON_POSITION_SAVE) { this.saveFile(aStatus => { if (aCallback) { aCallback(true, true, aStatus); } }); return true; } } if (aCallback) { aCallback(true, false); } return true; }, /** * Handler for window close event. Prompts to save scratchpad if * there are unsaved changes. * * @param nsIDOMEvent aEvent * @param function aCallback * Optional function you want to call when file is saved/closed. * Used mainly for tests. */ onClose: function SP_onClose(aEvent, aCallback) { aEvent.preventDefault(); this.close(aCallback); }, /** * Close the scratchpad window. Prompts before closing if the scratchpad * has unsaved changes. * * @param function aCallback * Optional function you want to call when file is saved */ close: function SP_close(aCallback) { let shouldClose; this.promptSave((aShouldClose, aSaved, aStatus) => { shouldClose = aShouldClose; if (aSaved && !Components.isSuccessCode(aStatus)) { shouldClose = false; } if (shouldClose) { window.close(); } if (aCallback) { aCallback(shouldClose); } }); return shouldClose; }, /** * Toggle a editor's boolean option. */ toggleEditorOption: function SP_toggleEditorOption(optionName, optionPreference) { let newOptionValue = !this.editor.getOption(optionName); this.editor.setOption(optionName, newOptionValue); Services.prefs.setBoolPref(optionPreference, newOptionValue); }, /** * Increase the editor's font size by 1 px. */ increaseFontSize: function SP_increaseFontSize() { let size = this.editor.getFontSize(); if (size < MAXIMUM_FONT_SIZE) { let newFontSize = size + 1; this.editor.setFontSize(newFontSize); Services.prefs.setIntPref(EDITOR_FONT_SIZE, newFontSize); if (newFontSize === MAXIMUM_FONT_SIZE) { document.getElementById("sp-cmd-larger-font").setAttribute("disabled", true); } document.getElementById("sp-cmd-smaller-font").removeAttribute("disabled"); } }, /** * Decrease the editor's font size by 1 px. */ decreaseFontSize: function SP_decreaseFontSize() { let size = this.editor.getFontSize(); if (size > MINIMUM_FONT_SIZE) { let newFontSize = size - 1; this.editor.setFontSize(newFontSize); Services.prefs.setIntPref(EDITOR_FONT_SIZE, newFontSize); if (newFontSize === MINIMUM_FONT_SIZE) { document.getElementById("sp-cmd-smaller-font").setAttribute("disabled", true); } } document.getElementById("sp-cmd-larger-font").removeAttribute("disabled"); }, /** * Restore the editor's original font size. */ normalFontSize: function SP_normalFontSize() { this.editor.setFontSize(NORMAL_FONT_SIZE); Services.prefs.setIntPref(EDITOR_FONT_SIZE, NORMAL_FONT_SIZE); document.getElementById("sp-cmd-larger-font").removeAttribute("disabled"); document.getElementById("sp-cmd-smaller-font").removeAttribute("disabled"); }, _observers: [], /** * Add an observer for Scratchpad events. * * The observer implements IScratchpadObserver := { * onReady: Called when the Scratchpad and its Editor are ready. * Arguments: (Scratchpad aScratchpad) * } * * All observer handlers are optional. * * @param IScratchpadObserver aObserver * @see removeObserver */ addObserver: function SP_addObserver(aObserver) { this._observers.push(aObserver); }, /** * Remove an observer for Scratchpad events. * * @param IScratchpadObserver aObserver * @see addObserver */ removeObserver: function SP_removeObserver(aObserver) { let index = this._observers.indexOf(aObserver); if (index != -1) { this._observers.splice(index, 1); } }, /** * Trigger named handlers in Scratchpad observers. * * @param string aName * Name of the handler to trigger. * @param Array aArgs * Optional array of arguments to pass to the observer(s). * @see addObserver */ _triggerObservers: function SP_triggerObservers(aName, aArgs) { // insert this Scratchpad instance as the first argument if (!aArgs) { aArgs = [this]; } else { aArgs.unshift(this); } // trigger all observers that implement this named handler for (let i = 0; i < this._observers.length; ++i) { let observer = this._observers[i]; let handler = observer["on" + aName]; if (handler) { handler.apply(observer, aArgs); } } }, /** * Opens the MDN documentation page for Scratchpad. */ openDocumentationPage: function SP_openDocumentationPage() { let url = this.strings.GetStringFromName("help.openDocumentationPage"); this.browserWindow.openUILinkIn(url,"tab"); this.browserWindow.focus(); }, }; /** * Represents the DebuggerClient connection to a specific tab as used by the * Scratchpad. * * @param object aTab * The tab to connect to. */ function ScratchpadTab(aTab) { this._tab = aTab; } var scratchpadTargets = new WeakMap(); /** * Returns the object containing the DebuggerClient and WebConsoleClient for a * given tab or window. * * @param object aSubject * The tab or window to obtain the connection for. * @return Promise * The promise for the connection information. */ ScratchpadTab.consoleFor = function consoleFor(aSubject) { if (!scratchpadTargets.has(aSubject)) { scratchpadTargets.set(aSubject, new this(aSubject)); } return scratchpadTargets.get(aSubject).connect(aSubject); }; ScratchpadTab.prototype = { /** * The promise for the connection. */ _connector: null, /** * Initialize a debugger client and connect it to the debugger server. * * @param object aSubject * The tab or window to obtain the connection for. * @return Promise * The promise for the result of connecting to this tab or window. */ connect: function ST_connect(aSubject) { if (this._connector) { return this._connector; } let deferred = promise.defer(); this._connector = deferred.promise; let connectTimer = setTimeout(() => { deferred.reject({ error: "timeout", message: Scratchpad.strings.GetStringFromName("connectionTimeout"), }); }, REMOTE_TIMEOUT); deferred.promise.then(() => clearTimeout(connectTimer)); this._attach(aSubject).then(aTarget => { let consoleActor = aTarget.form.consoleActor; let client = aTarget.client; client.attachConsole(consoleActor, [], (aResponse, aWebConsoleClient) => { if (aResponse.error) { reportError("attachConsole", aResponse); deferred.reject(aResponse); } else { deferred.resolve({ webConsoleClient: aWebConsoleClient, debuggerClient: client }); } }); }); return deferred.promise; }, /** * Attach to this tab. * * @param object aSubject * The tab or window to obtain the connection for. * @return Promise * The promise for the TabTarget for this tab. */ _attach: function ST__attach(aSubject) { let target = TargetFactory.forTab(this._tab); target.once("close", () => { if (scratchpadTargets) { scratchpadTargets.delete(aSubject); } }); return target.makeRemote().then(() => target); }, }; /** * Represents the DebuggerClient connection to a specific window as used by the * Scratchpad. */ function ScratchpadWindow() {} ScratchpadWindow.consoleFor = ScratchpadTab.consoleFor; ScratchpadWindow.prototype = Heritage.extend(ScratchpadTab.prototype, { /** * Attach to this window. * * @return Promise * The promise for the target for this window. */ _attach: function SW__attach() { if (!DebuggerServer.initialized) { DebuggerServer.init(); DebuggerServer.addBrowserActors(); } DebuggerServer.allowChromeProcess = true; let client = new DebuggerClient(DebuggerServer.connectPipe()); return client.connect() .then(() => client.getProcess()) .then(aResponse => { return { form: aResponse.form, client: client }; }); } }); function ScratchpadTarget(aTarget) { this._target = aTarget; } ScratchpadTarget.consoleFor = ScratchpadTab.consoleFor; ScratchpadTarget.prototype = Heritage.extend(ScratchpadTab.prototype, { _attach: function ST__attach() { if (this._target.isRemote) { return promise.resolve(this._target); } return this._target.makeRemote().then(() => this._target); } }); /** * Encapsulates management of the sidebar containing the VariablesView for * object inspection. */ function ScratchpadSidebar(aScratchpad) { // Make sure to decorate this object. ToolSidebar requires the parent // panel to support event (emit) API. EventEmitter.decorate(this); let ToolSidebar = require("devtools/client/framework/sidebar").ToolSidebar; let tabbox = document.querySelector("#scratchpad-sidebar"); this._sidebar = new ToolSidebar(tabbox, this, "scratchpad"); this._scratchpad = aScratchpad; } ScratchpadSidebar.prototype = { /* * The ToolSidebar for this sidebar. */ _sidebar: null, /* * The VariablesView for this sidebar. */ variablesView: null, /* * Whether the sidebar is currently shown. */ visible: false, /** * Open the sidebar, if not open already, and populate it with the properties * of the given object. * * @param string aString * The string that was evaluated. * @param object aObject * The object to inspect, which is the aEvalString evaluation result. * @return Promise * A promise that will resolve once the sidebar is open. */ open: function SS_open(aEvalString, aObject) { this.show(); let deferred = promise.defer(); let onTabReady = () => { if (this.variablesView) { this.variablesView.controller.releaseActors(); } else { let window = this._sidebar.getWindowForTab("variablesview"); let container = window.document.querySelector("#variables"); this.variablesView = new VariablesView(container, { searchEnabled: true, searchPlaceholder: this._scratchpad.strings .GetStringFromName("propertiesFilterPlaceholder") }); VariablesViewController.attach(this.variablesView, { getEnvironmentClient: aGrip => { return new EnvironmentClient(this._scratchpad.debuggerClient, aGrip); }, getObjectClient: aGrip => { return new ObjectClient(this._scratchpad.debuggerClient, aGrip); }, getLongStringClient: aActor => { return this._scratchpad.webConsoleClient.longString(aActor); }, releaseActor: aActor => { this._scratchpad.debuggerClient.release(aActor); } }); } this._update(aObject).then(() => deferred.resolve()); }; if (this._sidebar.getCurrentTabID() == "variablesview") { onTabReady(); } else { this._sidebar.once("variablesview-ready", onTabReady); this._sidebar.addTab("variablesview", VARIABLES_VIEW_URL, {selected: true}); } return deferred.promise; }, /** * Show the sidebar. */ show: function SS_show() { if (!this.visible) { this.visible = true; this._sidebar.show(); } }, /** * Hide the sidebar. */ hide: function SS_hide() { if (this.visible) { this.visible = false; this._sidebar.hide(); } }, /** * Destroy the sidebar. * * @return Promise * The promise that resolves when the sidebar is destroyed. */ destroy: function SS_destroy() { if (this.variablesView) { this.variablesView.controller.releaseActors(); this.variablesView = null; } return this._sidebar.destroy(); }, /** * Update the object currently inspected by the sidebar. * * @param any aValue * The JS value to inspect in the sidebar. * @return Promise * A promise that resolves when the update completes. */ _update: function SS__update(aValue) { let options, onlyEnumVisible; if (VariablesView.isPrimitive({ value: aValue })) { options = { rawObject: { value: aValue } }; onlyEnumVisible = true; } else { options = { objectActor: aValue }; onlyEnumVisible = false; } let view = this.variablesView; view.onlyEnumVisible = onlyEnumVisible; view.empty(); return view.controller.setSingleVariable(options).expanded; } }; /** * Report an error coming over the remote debugger protocol. * * @param string aAction * The name of the action or method that failed. * @param object aResponse * The response packet that contains the error. */ function reportError(aAction, aResponse) { console.error(aAction + " failed: " + aResponse.error + " " + aResponse.message); } /** * The PreferenceObserver listens for preference changes while Scratchpad is * running. */ var PreferenceObserver = { _initialized: false, init: function PO_init() { if (this._initialized) { return; } this.branch = Services.prefs.getBranch("devtools.scratchpad."); this.branch.addObserver("", this, false); this._initialized = true; }, observe: function PO_observe(aMessage, aTopic, aData) { if (aTopic != "nsPref:changed") { return; } if (aData == "recentFilesMax") { Scratchpad.handleRecentFileMaxChange(); } else if (aData == "recentFilePaths") { Scratchpad.populateRecentFilesMenu(); } }, uninit: function PO_uninit() { if (!this.branch) { return; } this.branch.removeObserver("", this); this.branch = null; } }; /** * The CloseObserver listens for the last browser window closing and attempts to * close the Scratchpad. */ var CloseObserver = { init: function CO_init() { Services.obs.addObserver(this, "browser-lastwindow-close-requested", false); }, observe: function CO_observe(aSubject) { if (Scratchpad.close()) { this.uninit(); } else { aSubject.QueryInterface(Ci.nsISupportsPRBool); aSubject.data = true; } }, uninit: function CO_uninit() { // Will throw exception if removeObserver is called twice. if (this._uninited) { return; } this._uninited = true; Services.obs.removeObserver(this, "browser-lastwindow-close-requested", false); }, }; XPCOMUtils.defineLazyGetter(Scratchpad, "strings", function () { return Services.strings.createBundle(SCRATCHPAD_L10N); }); addEventListener("load", Scratchpad.onLoad.bind(Scratchpad), false); addEventListener("unload", Scratchpad.onUnload.bind(Scratchpad), false); addEventListener("close", Scratchpad.onClose.bind(Scratchpad), false);