From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- devtools/shared/gcli/commands/addon.js | 320 +++ devtools/shared/gcli/commands/appcache.js | 186 ++ devtools/shared/gcli/commands/calllog.js | 219 ++ devtools/shared/gcli/commands/cmd.js | 178 ++ devtools/shared/gcli/commands/cookie.js | 300 +++ devtools/shared/gcli/commands/csscoverage.js | 201 ++ devtools/shared/gcli/commands/folder.js | 77 + devtools/shared/gcli/commands/highlight.js | 158 ++ devtools/shared/gcli/commands/index.js | 179 ++ devtools/shared/gcli/commands/inject.js | 86 + devtools/shared/gcli/commands/jsb.js | 134 ++ devtools/shared/gcli/commands/listen.js | 106 + devtools/shared/gcli/commands/mdn.js | 83 + devtools/shared/gcli/commands/measure.js | 112 + devtools/shared/gcli/commands/media.js | 56 + devtools/shared/gcli/commands/moz.build | 30 + devtools/shared/gcli/commands/pagemod.js | 276 +++ devtools/shared/gcli/commands/paintflashing.js | 201 ++ devtools/shared/gcli/commands/qsa.js | 24 + devtools/shared/gcli/commands/restart.js | 77 + devtools/shared/gcli/commands/rulers.js | 110 + devtools/shared/gcli/commands/screenshot.js | 579 +++++ devtools/shared/gcli/commands/security.js | 328 +++ devtools/shared/gcli/moz.build | 23 + devtools/shared/gcli/source/LICENSE | 202 ++ devtools/shared/gcli/source/docs/design.md | 102 + .../shared/gcli/source/docs/developing-gcli.md | 213 ++ devtools/shared/gcli/source/docs/index.md | 150 ++ devtools/shared/gcli/source/docs/running-tests.md | 60 + .../shared/gcli/source/docs/writing-commands.md | 757 +++++++ devtools/shared/gcli/source/docs/writing-tests.md | 20 + devtools/shared/gcli/source/docs/writing-types.md | 106 + devtools/shared/gcli/source/lib/gcli/cli.js | 2209 ++++++++++++++++++++ .../shared/gcli/source/lib/gcli/commands/clear.js | 59 + .../gcli/source/lib/gcli/commands/commands.js | 570 +++++ .../gcli/source/lib/gcli/commands/context.js | 62 + .../shared/gcli/source/lib/gcli/commands/help.js | 387 ++++ .../shared/gcli/source/lib/gcli/commands/mocks.js | 68 + .../shared/gcli/source/lib/gcli/commands/moz.build | 16 + .../shared/gcli/source/lib/gcli/commands/pref.js | 93 + .../gcli/source/lib/gcli/commands/preflist.js | 214 ++ .../shared/gcli/source/lib/gcli/commands/test.js | 215 ++ .../gcli/source/lib/gcli/connectors/connectors.js | 157 ++ .../gcli/source/lib/gcli/connectors/moz.build | 9 + .../gcli/source/lib/gcli/converters/basic.js | 94 + .../gcli/source/lib/gcli/converters/converters.js | 280 +++ .../shared/gcli/source/lib/gcli/converters/html.js | 47 + .../gcli/source/lib/gcli/converters/moz.build | 12 + .../gcli/source/lib/gcli/converters/terminal.js | 56 + .../shared/gcli/source/lib/gcli/fields/delegate.js | 96 + .../shared/gcli/source/lib/gcli/fields/fields.js | 245 +++ .../shared/gcli/source/lib/gcli/fields/moz.build | 11 + .../gcli/source/lib/gcli/fields/selection.js | 124 ++ devtools/shared/gcli/source/lib/gcli/index.js | 29 + devtools/shared/gcli/source/lib/gcli/l10n.js | 74 + .../gcli/source/lib/gcli/languages/command.html | 14 + .../gcli/source/lib/gcli/languages/command.js | 563 +++++ .../gcli/source/lib/gcli/languages/javascript.js | 86 + .../gcli/source/lib/gcli/languages/languages.js | 179 ++ .../gcli/source/lib/gcli/languages/moz.build | 12 + devtools/shared/gcli/source/lib/gcli/moz.build | 13 + .../shared/gcli/source/lib/gcli/mozui/completer.js | 151 ++ .../shared/gcli/source/lib/gcli/mozui/inputter.js | 657 ++++++ .../shared/gcli/source/lib/gcli/mozui/moz.build | 11 + .../shared/gcli/source/lib/gcli/mozui/tooltip.js | 298 +++ devtools/shared/gcli/source/lib/gcli/settings.js | 284 +++ devtools/shared/gcli/source/lib/gcli/system.js | 370 ++++ .../shared/gcli/source/lib/gcli/types/array.js | 80 + .../shared/gcli/source/lib/gcli/types/boolean.js | 62 + .../shared/gcli/source/lib/gcli/types/command.js | 255 +++ devtools/shared/gcli/source/lib/gcli/types/date.js | 248 +++ .../shared/gcli/source/lib/gcli/types/delegate.js | 158 ++ devtools/shared/gcli/source/lib/gcli/types/file.js | 96 + .../gcli/source/lib/gcli/types/fileparser.js | 19 + .../gcli/source/lib/gcli/types/javascript.js | 522 +++++ .../shared/gcli/source/lib/gcli/types/moz.build | 25 + devtools/shared/gcli/source/lib/gcli/types/node.js | 201 ++ .../shared/gcli/source/lib/gcli/types/number.js | 181 ++ .../shared/gcli/source/lib/gcli/types/resource.js | 270 +++ .../shared/gcli/source/lib/gcli/types/selection.js | 389 ++++ .../shared/gcli/source/lib/gcli/types/setting.js | 62 + .../shared/gcli/source/lib/gcli/types/string.js | 92 + .../shared/gcli/source/lib/gcli/types/types.js | 1146 ++++++++++ .../shared/gcli/source/lib/gcli/types/union.js | 117 ++ devtools/shared/gcli/source/lib/gcli/types/url.js | 86 + devtools/shared/gcli/source/lib/gcli/ui/focus.js | 403 ++++ devtools/shared/gcli/source/lib/gcli/ui/history.js | 71 + devtools/shared/gcli/source/lib/gcli/ui/intro.js | 90 + devtools/shared/gcli/source/lib/gcli/ui/menu.css | 69 + devtools/shared/gcli/source/lib/gcli/ui/menu.html | 20 + devtools/shared/gcli/source/lib/gcli/ui/menu.js | 328 +++ devtools/shared/gcli/source/lib/gcli/ui/moz.build | 15 + devtools/shared/gcli/source/lib/gcli/ui/view.js | 87 + .../gcli/source/lib/gcli/util/domtemplate.js | 20 + .../shared/gcli/source/lib/gcli/util/fileparser.js | 281 +++ .../shared/gcli/source/lib/gcli/util/filesystem.js | 130 ++ devtools/shared/gcli/source/lib/gcli/util/host.js | 230 ++ devtools/shared/gcli/source/lib/gcli/util/l10n.js | 80 + .../shared/gcli/source/lib/gcli/util/legacy.js | 147 ++ .../shared/gcli/source/lib/gcli/util/moz.build | 17 + devtools/shared/gcli/source/lib/gcli/util/prism.js | 361 ++++ devtools/shared/gcli/source/lib/gcli/util/spell.js | 197 ++ devtools/shared/gcli/source/lib/gcli/util/util.js | 685 ++++++ devtools/shared/gcli/templater.js | 602 ++++++ 104 files changed, 21260 insertions(+) create mode 100644 devtools/shared/gcli/commands/addon.js create mode 100644 devtools/shared/gcli/commands/appcache.js create mode 100644 devtools/shared/gcli/commands/calllog.js create mode 100644 devtools/shared/gcli/commands/cmd.js create mode 100644 devtools/shared/gcli/commands/cookie.js create mode 100644 devtools/shared/gcli/commands/csscoverage.js create mode 100644 devtools/shared/gcli/commands/folder.js create mode 100644 devtools/shared/gcli/commands/highlight.js create mode 100644 devtools/shared/gcli/commands/index.js create mode 100644 devtools/shared/gcli/commands/inject.js create mode 100644 devtools/shared/gcli/commands/jsb.js create mode 100644 devtools/shared/gcli/commands/listen.js create mode 100644 devtools/shared/gcli/commands/mdn.js create mode 100644 devtools/shared/gcli/commands/measure.js create mode 100644 devtools/shared/gcli/commands/media.js create mode 100644 devtools/shared/gcli/commands/moz.build create mode 100644 devtools/shared/gcli/commands/pagemod.js create mode 100644 devtools/shared/gcli/commands/paintflashing.js create mode 100644 devtools/shared/gcli/commands/qsa.js create mode 100644 devtools/shared/gcli/commands/restart.js create mode 100644 devtools/shared/gcli/commands/rulers.js create mode 100644 devtools/shared/gcli/commands/screenshot.js create mode 100644 devtools/shared/gcli/commands/security.js create mode 100644 devtools/shared/gcli/moz.build create mode 100644 devtools/shared/gcli/source/LICENSE create mode 100644 devtools/shared/gcli/source/docs/design.md create mode 100644 devtools/shared/gcli/source/docs/developing-gcli.md create mode 100644 devtools/shared/gcli/source/docs/index.md create mode 100644 devtools/shared/gcli/source/docs/running-tests.md create mode 100644 devtools/shared/gcli/source/docs/writing-commands.md create mode 100644 devtools/shared/gcli/source/docs/writing-tests.md create mode 100644 devtools/shared/gcli/source/docs/writing-types.md create mode 100644 devtools/shared/gcli/source/lib/gcli/cli.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/clear.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/commands.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/context.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/help.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/mocks.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/pref.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/preflist.js create mode 100644 devtools/shared/gcli/source/lib/gcli/commands/test.js create mode 100644 devtools/shared/gcli/source/lib/gcli/connectors/connectors.js create mode 100644 devtools/shared/gcli/source/lib/gcli/connectors/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/converters/basic.js create mode 100644 devtools/shared/gcli/source/lib/gcli/converters/converters.js create mode 100644 devtools/shared/gcli/source/lib/gcli/converters/html.js create mode 100644 devtools/shared/gcli/source/lib/gcli/converters/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/converters/terminal.js create mode 100644 devtools/shared/gcli/source/lib/gcli/fields/delegate.js create mode 100644 devtools/shared/gcli/source/lib/gcli/fields/fields.js create mode 100644 devtools/shared/gcli/source/lib/gcli/fields/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/fields/selection.js create mode 100644 devtools/shared/gcli/source/lib/gcli/index.js create mode 100644 devtools/shared/gcli/source/lib/gcli/l10n.js create mode 100644 devtools/shared/gcli/source/lib/gcli/languages/command.html create mode 100644 devtools/shared/gcli/source/lib/gcli/languages/command.js create mode 100644 devtools/shared/gcli/source/lib/gcli/languages/javascript.js create mode 100644 devtools/shared/gcli/source/lib/gcli/languages/languages.js create mode 100644 devtools/shared/gcli/source/lib/gcli/languages/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/mozui/completer.js create mode 100644 devtools/shared/gcli/source/lib/gcli/mozui/inputter.js create mode 100644 devtools/shared/gcli/source/lib/gcli/mozui/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/mozui/tooltip.js create mode 100644 devtools/shared/gcli/source/lib/gcli/settings.js create mode 100644 devtools/shared/gcli/source/lib/gcli/system.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/array.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/boolean.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/command.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/date.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/delegate.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/file.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/fileparser.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/javascript.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/types/node.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/number.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/resource.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/selection.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/setting.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/string.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/types.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/union.js create mode 100644 devtools/shared/gcli/source/lib/gcli/types/url.js create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/focus.js create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/history.js create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/intro.js create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/menu.css create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/menu.html create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/menu.js create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/ui/view.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/domtemplate.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/fileparser.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/filesystem.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/host.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/l10n.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/legacy.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/moz.build create mode 100644 devtools/shared/gcli/source/lib/gcli/util/prism.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/spell.js create mode 100644 devtools/shared/gcli/source/lib/gcli/util/util.js create mode 100644 devtools/shared/gcli/templater.js (limited to 'devtools/shared/gcli') diff --git a/devtools/shared/gcli/commands/addon.js b/devtools/shared/gcli/commands/addon.js new file mode 100644 index 000000000..9a38142a3 --- /dev/null +++ b/devtools/shared/gcli/commands/addon.js @@ -0,0 +1,320 @@ +/* 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"; + +/** + * You can't require the AddonManager in a child process, but GCLI wants to + * check for 'items' in all processes, so we return empty array if the + * AddonManager is not available + */ +function getAddonManager() { + try { + return { + AddonManager: require("resource://gre/modules/AddonManager.jsm").AddonManager, + addonManagerActive: true + }; + } + catch (ex) { + // Fake up an AddonManager just enough to let the file load + return { + AddonManager: { + getAllAddons() {}, + getAddonsByTypes() {} + }, + addonManagerActive: false + }; + } +} + +const { Cc, Ci, Cu } = require("chrome"); +const { AddonManager, addonManagerActive } = getAddonManager(); +const l10n = require("gcli/l10n"); + +const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService) + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + +/** + * Takes a function that uses a callback as its last parameter, and returns a + * new function that returns a promise instead. + * This should probably live in async-util + */ +const promiseify = function(scope, functionWithLastParamCallback) { + return (...args) => { + return new Promise(resolve => { + args.push((...results) => { + resolve(results.length > 1 ? results : results[0]); + }); + functionWithLastParamCallback.apply(scope, args); + }); + } +}; + +// Convert callback based functions to promise based ones +const getAllAddons = promiseify(AddonManager, AddonManager.getAllAddons); +const getAddonsByTypes = promiseify(AddonManager, AddonManager.getAddonsByTypes); + +/** + * Return a string array containing the pending operations on an addon + */ +function pendingOperations(addon) { + let allOperations = [ + "PENDING_ENABLE", "PENDING_DISABLE", "PENDING_UNINSTALL", + "PENDING_INSTALL", "PENDING_UPGRADE" + ]; + return allOperations.reduce(function(operations, opName) { + return addon.pendingOperations & AddonManager[opName] ? + operations.concat(opName) : + operations; + }, []); +} + +var items = [ + { + item: "type", + name: "addon", + parent: "selection", + stringifyProperty: "name", + cacheable: true, + constructor: function() { + // Tell GCLI to clear the cache of addons when one is added or removed + let listener = { + onInstalled: addon => { this.clearCache(); }, + onUninstalled: addon => { this.clearCache(); }, + }; + AddonManager.addAddonListener(listener); + }, + lookup: function() { + return getAllAddons().then(addons => { + return addons.map(addon => { + let name = addon.name + " " + addon.version; + name = name.trim().replace(/\s/g, "_"); + return { name: name, value: addon }; + }); + }); + } + }, + { + name: "addon", + description: l10n.lookup("addonDesc") + }, + { + name: "addon list", + description: l10n.lookup("addonListDesc"), + returnType: "addonsInfo", + params: [{ + name: "type", + type: { + name: "selection", + data: [ "dictionary", "extension", "locale", "plugin", "theme", "all" ] + }, + defaultValue: "all", + description: l10n.lookup("addonListTypeDesc") + }], + exec: function(args, context) { + let types = (args.type === "all") ? null : [ args.type ]; + return getAddonsByTypes(types).then(addons => { + addons = addons.map(function(addon) { + return { + name: addon.name, + version: addon.version, + isActive: addon.isActive, + pendingOperations: pendingOperations(addon) + }; + }); + return { addons: addons, type: args.type }; + }); + } + }, + { + item: "converter", + from: "addonsInfo", + to: "view", + exec: function(addonsInfo, context) { + if (!addonsInfo.addons.length) { + return context.createView({ + html: "

${message}

", + data: { message: l10n.lookup("addonNoneOfType") } + }); + } + + let headerLookups = { + "dictionary": "addonListDictionaryHeading", + "extension": "addonListExtensionHeading", + "locale": "addonListLocaleHeading", + "plugin": "addonListPluginHeading", + "theme": "addonListThemeHeading", + "all": "addonListAllHeading" + }; + let header = l10n.lookup(headerLookups[addonsInfo.type] || + "addonListUnknownHeading"); + + let operationLookups = { + "PENDING_ENABLE": "addonPendingEnable", + "PENDING_DISABLE": "addonPendingDisable", + "PENDING_UNINSTALL": "addonPendingUninstall", + "PENDING_INSTALL": "addonPendingInstall", + "PENDING_UPGRADE": "addonPendingUpgrade" + }; + function lookupOperation(opName) { + let lookupName = operationLookups[opName]; + return lookupName ? l10n.lookup(lookupName) : opName; + } + + function arrangeAddons(addons) { + let enabledAddons = []; + let disabledAddons = []; + addons.forEach(function(addon) { + if (addon.isActive) { + enabledAddons.push(addon); + } else { + disabledAddons.push(addon); + } + }); + + function compareAddonNames(nameA, nameB) { + return String.localeCompare(nameA.name, nameB.name); + } + enabledAddons.sort(compareAddonNames); + disabledAddons.sort(compareAddonNames); + + return enabledAddons.concat(disabledAddons); + } + + function isActiveForToggle(addon) { + return (addon.isActive && ~~addon.pendingOperations.indexOf("PENDING_DISABLE")); + } + + return context.createView({ + html: + "" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + "
${header}
${addon.name} ${addon.version}${addon.pendingOperations}" + + " ${addon.toggleActionMessage}" + + "
", + data: { + header: header, + addons: arrangeAddons(addonsInfo.addons).map(function(addon) { + return { + name: addon.name, + label: addon.name.replace(/\s/g, "_") + + (addon.version ? "_" + addon.version : ""), + status: addon.isActive ? "enabled" : "disabled", + version: addon.version, + pendingOperations: addon.pendingOperations.length ? + (" (" + l10n.lookup("addonPending") + ": " + + addon.pendingOperations.map(lookupOperation).join(", ") + + ")") : + "", + toggleActionName: isActiveForToggle(addon) ? "disable": "enable", + toggleActionMessage: isActiveForToggle(addon) ? + l10n.lookup("addonListOutDisable") : + l10n.lookup("addonListOutEnable") + }; + }), + onclick: context.update, + ondblclick: context.updateExec + } + }); + } + }, + { + item: "command", + runAt: "client", + name: "addon enable", + description: l10n.lookup("addonEnableDesc"), + params: [ + { + name: "addon", + type: "addon", + description: l10n.lookup("addonNameDesc") + } + ], + exec: function(args, context) { + let name = (args.addon.name + " " + args.addon.version).trim(); + if (args.addon.userDisabled) { + args.addon.userDisabled = false; + return l10n.lookupFormat("addonEnabled", [ name ]); + } + + return l10n.lookupFormat("addonAlreadyEnabled", [ name ]); + } + }, + { + item: "command", + runAt: "client", + name: "addon disable", + description: l10n.lookup("addonDisableDesc"), + params: [ + { + name: "addon", + type: "addon", + description: l10n.lookup("addonNameDesc") + } + ], + exec: function(args, context) { + // If the addon is not disabled or is set to "click to play" then + // disable it. Otherwise display the message "Add-on is already + // disabled." + let name = (args.addon.name + " " + args.addon.version).trim(); + if (!args.addon.userDisabled || + args.addon.userDisabled === AddonManager.STATE_ASK_TO_ACTIVATE) { + args.addon.userDisabled = true; + return l10n.lookupFormat("addonDisabled", [ name ]); + } + + return l10n.lookupFormat("addonAlreadyDisabled", [ name ]); + } + }, + { + item: "command", + runAt: "client", + name: "addon ctp", + description: l10n.lookup("addonCtpDesc"), + params: [ + { + name: "addon", + type: "addon", + description: l10n.lookup("addonNameDesc") + } + ], + exec: function(args, context) { + let name = (args.addon.name + " " + args.addon.version).trim(); + if (args.addon.type !== "plugin") { + return l10n.lookupFormat("addonCantCtp", [ name ]); + } + + if (!args.addon.userDisabled || + args.addon.userDisabled === true) { + args.addon.userDisabled = AddonManager.STATE_ASK_TO_ACTIVATE; + + if (args.addon.userDisabled !== AddonManager.STATE_ASK_TO_ACTIVATE) { + // Some plugins (e.g. OpenH264 shipped with Firefox) cannot be set to + // click-to-play. Handle this. + + return l10n.lookupFormat("addonNoCtp", [ name ]); + } + + return l10n.lookupFormat("addonCtp", [ name ]); + } + + return l10n.lookupFormat("addonAlreadyCtp", [ name ]); + } + } +]; + +exports.items = addonManagerActive ? items : []; diff --git a/devtools/shared/gcli/commands/appcache.js b/devtools/shared/gcli/commands/appcache.js new file mode 100644 index 000000000..0789fb5a0 --- /dev/null +++ b/devtools/shared/gcli/commands/appcache.js @@ -0,0 +1,186 @@ +/* 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 l10n = require("gcli/l10n"); + +loader.lazyImporter(this, "AppCacheUtils", "resource://devtools/client/shared/AppCacheUtils.jsm"); + +exports.items = [ + { + item: "command", + name: "appcache", + description: l10n.lookup("appCacheDesc") + }, + { + item: "command", + runAt: "server", + name: "appcache validate", + description: l10n.lookup("appCacheValidateDesc"), + manual: l10n.lookup("appCacheValidateManual"), + returnType: "appcacheerrors", + params: [{ + group: "options", + params: [ + { + type: "string", + name: "uri", + description: l10n.lookup("appCacheValidateUriDesc"), + defaultValue: null, + } + ] + }], + exec: function(args, context) { + let utils; + let deferred = context.defer(); + + if (args.uri) { + utils = new AppCacheUtils(args.uri); + } else { + utils = new AppCacheUtils(context.environment.document); + } + + utils.validateManifest().then(function(errors) { + deferred.resolve([errors, utils.manifestURI || "-"]); + }); + + return deferred.promise; + } + }, + { + item: "converter", + from: "appcacheerrors", + to: "view", + exec: function([errors, manifestURI], context) { + if (errors.length == 0) { + return context.createView({ + html: "" + l10n.lookup("appCacheValidatedSuccessfully") + "" + }); + } + + return context.createView({ + html: + "
" + + "

Manifest URI: ${manifestURI}

" + + "
    " + + "
  1. ${error.msg}
  2. " + + "
" + + "
", + data: { + errors: errors, + manifestURI: manifestURI + } + }); + } + }, + { + item: "command", + runAt: "server", + name: "appcache clear", + description: l10n.lookup("appCacheClearDesc"), + manual: l10n.lookup("appCacheClearManual"), + exec: function(args, context) { + let utils = new AppCacheUtils(args.uri); + utils.clearAll(); + + return l10n.lookup("appCacheClearCleared"); + } + }, + { + item: "command", + runAt: "server", + name: "appcache list", + description: l10n.lookup("appCacheListDesc"), + manual: l10n.lookup("appCacheListManual"), + returnType: "appcacheentries", + params: [{ + group: "options", + params: [ + { + type: "string", + name: "search", + description: l10n.lookup("appCacheListSearchDesc"), + defaultValue: null, + }, + ] + }], + exec: function(args, context) { + let utils = new AppCacheUtils(); + return utils.listEntries(args.search); + } + }, + { + item: "converter", + from: "appcacheentries", + to: "view", + exec: function(entries, context) { + return context.createView({ + html: "" + + "", + data: { + entries: entries, + onclick: context.update, + ondblclick: context.updateExec + } + }); + } + }, + { + item: "command", + runAt: "server", + name: "appcache viewentry", + description: l10n.lookup("appCacheViewEntryDesc"), + manual: l10n.lookup("appCacheViewEntryManual"), + params: [ + { + type: "string", + name: "key", + description: l10n.lookup("appCacheViewEntryKey"), + defaultValue: null, + } + ], + exec: function(args, context) { + let utils = new AppCacheUtils(); + return utils.viewEntry(args.key); + } + } +]; diff --git a/devtools/shared/gcli/commands/calllog.js b/devtools/shared/gcli/commands/calllog.js new file mode 100644 index 000000000..c0f21aeab --- /dev/null +++ b/devtools/shared/gcli/commands/calllog.js @@ -0,0 +1,219 @@ +/* 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 { Cu } = require("chrome"); +const l10n = require("gcli/l10n"); +const gcli = require("gcli/index"); +const Debugger = require("Debugger"); + +loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); +loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true); + +var debuggers = []; +var chromeDebuggers = []; +var sandboxes = []; + +exports.items = [ + { + name: "calllog", + description: l10n.lookup("calllogDesc") + }, + { + item: "command", + runAt: "client", + name: "calllog start", + description: l10n.lookup("calllogStartDesc"), + + exec: function(args, context) { + let contentWindow = context.environment.window; + + let dbg = new Debugger(contentWindow); + dbg.onEnterFrame = function(frame) { + // BUG 773652 - Make the output from the GCLI calllog command nicer + contentWindow.console.log("Method call: " + this.callDescription(frame)); + }.bind(this); + + debuggers.push(dbg); + + let gBrowser = context.environment.chromeDocument.defaultView.gBrowser; + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "webconsole"); + + return l10n.lookup("calllogStartReply"); + }, + + callDescription: function(frame) { + let name = ""; + if (frame.callee.name) { + name = frame.callee.name; + } + else { + let desc = frame.callee.getOwnPropertyDescriptor("displayName"); + if (desc && desc.value && typeof desc.value == "string") { + name = desc.value; + } + } + + let args = frame.arguments.map(this.valueToString).join(", "); + return name + "(" + args + ")"; + }, + + valueToString: function(value) { + if (typeof value !== "object" || value === null) { + return uneval(value); + } + return "[object " + value.class + "]"; + } + }, + { + item: "command", + runAt: "client", + name: "calllog stop", + description: l10n.lookup("calllogStopDesc"), + + exec: function(args, context) { + let numDebuggers = debuggers.length; + if (numDebuggers == 0) { + return l10n.lookup("calllogStopNoLogging"); + } + + for (let dbg of debuggers) { + dbg.onEnterFrame = undefined; + } + debuggers = []; + + return l10n.lookupFormat("calllogStopReply", [ numDebuggers ]); + } + }, + { + item: "command", + runAt: "client", + name: "calllog chromestart", + description: l10n.lookup("calllogChromeStartDesc"), + get hidden() { + return gcli.hiddenByChromePref(); + }, + params: [ + { + name: "sourceType", + type: { + name: "selection", + data: ["content-variable", "chrome-variable", "jsm", "javascript"] + } + }, + { + name: "source", + type: "string", + description: l10n.lookup("calllogChromeSourceTypeDesc"), + manual: l10n.lookup("calllogChromeSourceTypeManual"), + } + ], + exec: function(args, context) { + let globalObj; + let contentWindow = context.environment.window; + + if (args.sourceType == "jsm") { + try { + globalObj = Cu.import(args.source, {}); + } catch (e) { + return l10n.lookup("callLogChromeInvalidJSM"); + } + } else if (args.sourceType == "content-variable") { + if (args.source in contentWindow) { + globalObj = Cu.getGlobalForObject(contentWindow[args.source]); + } else { + throw new Error(l10n.lookup("callLogChromeVarNotFoundContent")); + } + } else if (args.sourceType == "chrome-variable") { + let chromeWin = context.environment.chromeDocument.defaultView; + if (args.source in chromeWin) { + globalObj = Cu.getGlobalForObject(chromeWin[args.source]); + } else { + return l10n.lookup("callLogChromeVarNotFoundChrome"); + } + } else { + let chromeWin = context.environment.chromeDocument.defaultView; + let sandbox = new Cu.Sandbox(chromeWin, + { + sandboxPrototype: chromeWin, + wantXrays: false, + sandboxName: "gcli-cmd-calllog-chrome" + }); + let returnVal; + try { + returnVal = Cu.evalInSandbox(args.source, sandbox, "ECMAv5"); + sandboxes.push(sandbox); + } catch(e) { + // We need to save the message before cleaning up else e contains a dead + // object. + let msg = l10n.lookup("callLogChromeEvalException") + ": " + e; + Cu.nukeSandbox(sandbox); + return msg; + } + + if (typeof returnVal == "undefined") { + return l10n.lookup("callLogChromeEvalNeedsObject"); + } + + globalObj = Cu.getGlobalForObject(returnVal); + } + + let dbg = new Debugger(globalObj); + chromeDebuggers.push(dbg); + + dbg.onEnterFrame = function(frame) { + // BUG 773652 - Make the output from the GCLI calllog command nicer + contentWindow.console.log(l10n.lookup("callLogChromeMethodCall") + + ": " + this.callDescription(frame)); + }.bind(this); + + let gBrowser = context.environment.chromeDocument.defaultView.gBrowser; + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "webconsole"); + + return l10n.lookup("calllogChromeStartReply"); + }, + + valueToString: function(value) { + if (typeof value !== "object" || value === null) + return uneval(value); + return "[object " + value.class + "]"; + }, + + callDescription: function(frame) { + let name = frame.callee.name || l10n.lookup("callLogChromeAnonFunction"); + let args = frame.arguments.map(this.valueToString).join(", "); + return name + "(" + args + ")"; + } + }, + { + item: "command", + runAt: "client", + name: "calllog chromestop", + description: l10n.lookup("calllogChromeStopDesc"), + get hidden() { + return gcli.hiddenByChromePref(); + }, + exec: function(args, context) { + let numDebuggers = chromeDebuggers.length; + if (numDebuggers == 0) { + return l10n.lookup("calllogChromeStopNoLogging"); + } + + for (let dbg of chromeDebuggers) { + dbg.onEnterFrame = undefined; + dbg.enabled = false; + } + for (let sandbox of sandboxes) { + Cu.nukeSandbox(sandbox); + } + chromeDebuggers = []; + sandboxes = []; + + return l10n.lookupFormat("calllogChromeStopReply", [ numDebuggers ]); + } + } +]; diff --git a/devtools/shared/gcli/commands/cmd.js b/devtools/shared/gcli/commands/cmd.js new file mode 100644 index 000000000..1777ed960 --- /dev/null +++ b/devtools/shared/gcli/commands/cmd.js @@ -0,0 +1,178 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); + +const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {}); +const { TextEncoder, TextDecoder } = Cu.import('resource://gre/modules/commonjs/toolkit/loader.js', {}); +const gcli = require("gcli/index"); +const l10n = require("gcli/l10n"); + +loader.lazyGetter(this, "prefBranch", function() { + let prefService = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService); + return prefService.getBranch(null).QueryInterface(Ci.nsIPrefBranch2); +}); + +loader.lazyGetter(this, "supportsString", function() { + return Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); +}); + +loader.lazyImporter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); + +const PREF_DIR = "devtools.commands.dir"; + +/** + * Load all the .mozcmd files in the directory pointed to by PREF_DIR + * @return A promise of an array of items suitable for gcli.addItems or + * using in gcli.addItemsByModule + */ +function loadItemsFromMozDir() { + let dirName = prefBranch.getComplexValue(PREF_DIR, + Ci.nsISupportsString).data.trim(); + if (dirName == "") { + return Promise.resolve([]); + } + + // replaces ~ with the home directory path in unix and windows + if (dirName.indexOf("~") == 0) { + let dirService = Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIProperties); + let homeDirFile = dirService.get("Home", Ci.nsIFile); + let homeDir = homeDirFile.path; + dirName = dirName.substr(1); + dirName = homeDir + dirName; + } + + // statPromise resolves to nothing if dirName is a directory, or it + // rejects with an error message otherwise + let statPromise = OS.File.stat(dirName); + statPromise = statPromise.then( + function onSuccess(stat) { + if (!stat.isDir) { + throw new Error("'" + dirName + "' is not a directory."); + } + }, + function onFailure(reason) { + if (reason instanceof OS.File.Error && reason.becauseNoSuchFile) { + throw new Error("'" + dirName + "' does not exist."); + } else { + throw reason; + } + } + ); + + // We need to return (a promise of) an array of items from the *.mozcmd + // files in dirName (which we can assume to be a valid directory now) + return statPromise.then(() => { + let itemPromises = []; + + let iterator = new OS.File.DirectoryIterator(dirName); + let iterPromise = iterator.forEach(entry => { + if (entry.name.match(/.*\.mozcmd$/) && !entry.isDir) { + itemPromises.push(loadCommandFile(entry)); + } + }); + + return iterPromise.then(() => { + iterator.close(); + return Promise.all(itemPromises).then((itemsArray) => { + return itemsArray.reduce((prev, curr) => { + return prev.concat(curr); + }, []); + }); + }, reason => { iterator.close(); throw reason; }); + }); +} + +exports.mozDirLoader = function(name) { + return loadItemsFromMozDir().then(items => { + return { items: items }; + }); +}; + +/** + * Load the commands from a single file + * @param OS.File.DirectoryIterator.Entry entry The DirectoryIterator + * Entry of the file containing the commands that we should read + */ +function loadCommandFile(entry) { + let readPromise = OS.File.read(entry.path); + return readPromise = readPromise.then(array => { + let decoder = new TextDecoder(); + let source = decoder.decode(array); + var principal = Cc["@mozilla.org/systemprincipal;1"] + .createInstance(Ci.nsIPrincipal); + + let sandbox = new Cu.Sandbox(principal, { + sandboxName: entry.path + }); + let data = Cu.evalInSandbox(source, sandbox, "1.8", entry.name, 1); + + if (!Array.isArray(data)) { + console.error("Command file '" + entry.name + "' does not have top level array."); + return; + } + + return data; + }); +} + +exports.items = [ + { + name: "cmd", + get hidden() { + return !prefBranch.prefHasUserValue(PREF_DIR); + }, + description: l10n.lookup("cmdDesc") + }, + { + item: "command", + runAt: "client", + name: "cmd refresh", + description: l10n.lookup("cmdRefreshDesc"), + get hidden() { + return !prefBranch.prefHasUserValue(PREF_DIR); + }, + exec: function(args, context) { + gcli.load(); + + let dirName = prefBranch.getComplexValue(PREF_DIR, + Ci.nsISupportsString).data.trim(); + return l10n.lookupFormat("cmdStatus3", [ dirName ]); + } + }, + { + item: "command", + runAt: "client", + name: "cmd setdir", + description: l10n.lookup("cmdSetdirDesc"), + manual: l10n.lookup("cmdSetdirManual3"), + params: [ + { + name: "directory", + description: l10n.lookup("cmdSetdirDirectoryDesc"), + type: { + name: "file", + filetype: "directory", + existing: "yes" + }, + defaultValue: null + } + ], + returnType: "string", + get hidden() { + return true; // !prefBranch.prefHasUserValue(PREF_DIR); + }, + exec: function(args, context) { + supportsString.data = args.directory; + prefBranch.setComplexValue(PREF_DIR, Ci.nsISupportsString, supportsString); + + gcli.load(); + + return l10n.lookupFormat("cmdStatus3", [ args.directory ]); + } + } +]; diff --git a/devtools/shared/gcli/commands/cookie.js b/devtools/shared/gcli/commands/cookie.js new file mode 100644 index 000000000..f1680042f --- /dev/null +++ b/devtools/shared/gcli/commands/cookie.js @@ -0,0 +1,300 @@ +/* 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"; + +/** + * XXX: bug 1221488 is required to make these commands run on the server. + * If we want these commands to run on remote devices/connections, they need to + * run on the server (runAt=server). Unfortunately, cookie commands not only + * need to run on the server, they also need to access to the parent process to + * retrieve and manipulate cookies via nsICookieManager2. + * However, server-running commands have no way of accessing the parent process + * for now. + * + * So, because these cookie commands, as of today, only run in the developer + * toolbar (the gcli command bar), and because this toolbar is only available on + * a local Firefox desktop tab (not in webide or the browser toolbox), we can + * make the commands run on the client. + * This way, they'll always run in the parent process. + */ + +const { Ci, Cc } = require("chrome"); +const l10n = require("gcli/l10n"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "cookieMgr", function() { + return Cc["@mozilla.org/cookiemanager;1"].getService(Ci.nsICookieManager2); +}); + +/** + * Check host value and remove port part as it is not used + * for storing cookies. + * + * Parameter will usually be `new URL(context.environment.target.url).host` + */ +function sanitizeHost(host) { + if (host == null || host == "") { + throw new Error(l10n.lookup("cookieListOutNonePage")); + } + return host.split(":")[0]; +} + +/** + * The cookie 'expires' value needs converting into something more readable. + * + * And the unit of expires is sec, the unit that in argument of Date() needs + * millisecond. + */ +function translateExpires(expires) { + if (expires == 0) { + return l10n.lookup("cookieListOutSession"); + } + + let expires_msec = expires * 1000; + + return (new Date(expires_msec)).toLocaleString(); +} + +/** + * Check if a given cookie matches a given host + */ +function isCookieAtHost(cookie, host) { + if (cookie.host == null) { + return host == null; + } + if (cookie.host.startsWith(".")) { + return ("." + host).endsWith(cookie.host); + } + if (cookie.host === "") { + return host.startsWith("file://" + cookie.path); + } + return cookie.host == host; +} + +exports.items = [ + { + name: "cookie", + description: l10n.lookup("cookieDesc"), + manual: l10n.lookup("cookieManual") + }, + { + item: "command", + runAt: "client", + name: "cookie list", + description: l10n.lookup("cookieListDesc"), + manual: l10n.lookup("cookieListManual"), + returnType: "cookies", + exec: function(args, context) { + if (context.environment.target.isRemote) { + throw new Error("The cookie gcli commands only work in a local tab, " + + "see bug 1221488"); + } + let host = new URL(context.environment.target.url).host; + let contentWindow = context.environment.window; + host = sanitizeHost(host); + let enm = cookieMgr.getCookiesFromHost(host, contentWindow.document. + nodePrincipal. + originAttributes); + + let cookies = []; + while (enm.hasMoreElements()) { + let cookie = enm.getNext().QueryInterface(Ci.nsICookie); + if (isCookieAtHost(cookie, host)) { + cookies.push({ + host: cookie.host, + name: cookie.name, + value: cookie.value, + path: cookie.path, + expires: cookie.expires, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameDomain: cookie.sameDomain + }); + } + } + + return cookies; + } + }, + { + item: "command", + runAt: "client", + name: "cookie remove", + description: l10n.lookup("cookieRemoveDesc"), + manual: l10n.lookup("cookieRemoveManual"), + params: [ + { + name: "name", + type: "string", + description: l10n.lookup("cookieRemoveKeyDesc"), + } + ], + exec: function(args, context) { + if (context.environment.target.isRemote) { + throw new Error("The cookie gcli commands only work in a local tab, " + + "see bug 1221488"); + } + let host = new URL(context.environment.target.url).host; + let contentWindow = context.environment.window; + host = sanitizeHost(host); + let enm = cookieMgr.getCookiesFromHost(host, contentWindow.document. + nodePrincipal. + originAttributes); + + while (enm.hasMoreElements()) { + let cookie = enm.getNext().QueryInterface(Ci.nsICookie); + if (isCookieAtHost(cookie, host)) { + if (cookie.name == args.name) { + cookieMgr.remove(cookie.host, cookie.name, cookie.path, + false, cookie.originAttributes); + } + } + } + } + }, + { + item: "converter", + from: "cookies", + to: "view", + exec: function(cookies, context) { + if (cookies.length == 0) { + let host = new URL(context.environment.target.url).host; + host = sanitizeHost(host); + let msg = l10n.lookupFormat("cookieListOutNoneHost", [ host ]); + return context.createView({ html: "" + msg + "" }); + } + + for (let cookie of cookies) { + cookie.expires = translateExpires(cookie.expires); + + let noAttrs = !cookie.secure && !cookie.httpOnly && !cookie.sameDomain; + cookie.attrs = (cookie.secure ? "secure" : " ") + + (cookie.httpOnly ? "httpOnly" : " ") + + (cookie.sameDomain ? "sameDomain" : " ") + + (noAttrs ? l10n.lookup("cookieListOutNone") : " "); + } + + return context.createView({ + html: + "", + data: { + options: { allowEval: true }, + cookies: cookies, + onclick: context.update, + ondblclick: context.updateExec + } + }); + } + }, + { + item: "command", + runAt: "client", + name: "cookie set", + description: l10n.lookup("cookieSetDesc"), + manual: l10n.lookup("cookieSetManual"), + params: [ + { + name: "name", + type: "string", + description: l10n.lookup("cookieSetKeyDesc") + }, + { + name: "value", + type: "string", + description: l10n.lookup("cookieSetValueDesc") + }, + { + group: l10n.lookup("cookieSetOptionsDesc"), + params: [ + { + name: "path", + type: { name: "string", allowBlank: true }, + defaultValue: "/", + description: l10n.lookup("cookieSetPathDesc") + }, + { + name: "domain", + type: "string", + defaultValue: null, + description: l10n.lookup("cookieSetDomainDesc") + }, + { + name: "secure", + type: "boolean", + description: l10n.lookup("cookieSetSecureDesc") + }, + { + name: "httpOnly", + type: "boolean", + description: l10n.lookup("cookieSetHttpOnlyDesc") + }, + { + name: "session", + type: "boolean", + description: l10n.lookup("cookieSetSessionDesc") + }, + { + name: "expires", + type: "string", + defaultValue: "Jan 17, 2038", + description: l10n.lookup("cookieSetExpiresDesc") + }, + ] + } + ], + exec: function(args, context) { + if (context.environment.target.isRemote) { + throw new Error("The cookie gcli commands only work in a local tab, " + + "see bug 1221488"); + } + let host = new URL(context.environment.target.url).host; + host = sanitizeHost(host); + let time = Date.parse(args.expires) / 1000; + let contentWindow = context.environment.window; + cookieMgr.add(args.domain ? "." + args.domain : host, + args.path ? args.path : "/", + args.name, + args.value, + args.secure, + args.httpOnly, + args.session, + time, + contentWindow.document. + nodePrincipal. + originAttributes); + } + } +]; diff --git a/devtools/shared/gcli/commands/csscoverage.js b/devtools/shared/gcli/commands/csscoverage.js new file mode 100644 index 000000000..ebbf0baca --- /dev/null +++ b/devtools/shared/gcli/commands/csscoverage.js @@ -0,0 +1,201 @@ +/* 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 { Cc, Ci } = require("chrome"); + +const domtemplate = require("gcli/util/domtemplate"); +const csscoverage = require("devtools/shared/fronts/csscoverage"); +const l10n = csscoverage.l10n; + +loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); + +loader.lazyImporter(this, "Chart", "resource://devtools/client/shared/widgets/Chart.jsm"); + +/** + * The commands/converters for GCLI + */ +exports.items = [ + { + name: "csscoverage", + hidden: true, + description: l10n.lookup("csscoverageDesc"), + }, + { + item: "command", + runAt: "client", + name: "csscoverage start", + hidden: true, + description: l10n.lookup("csscoverageStartDesc2"), + params: [ + { + name: "noreload", + type: "boolean", + description: l10n.lookup("csscoverageStartNoReloadDesc"), + manual: l10n.lookup("csscoverageStartNoReloadManual") + } + ], + exec: function*(args, context) { + let usage = yield csscoverage.getUsage(context.environment.target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + yield usage.start(context.environment.chromeWindow, + context.environment.target, args.noreload); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage stop", + hidden: true, + description: l10n.lookup("csscoverageStopDesc2"), + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + yield usage.stop(); + yield gDevTools.showToolbox(target, "styleeditor"); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage oneshot", + hidden: true, + description: l10n.lookup("csscoverageOneShotDesc2"), + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + yield usage.oneshot(); + yield gDevTools.showToolbox(target, "styleeditor"); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage toggle", + hidden: true, + description: l10n.lookup("csscoverageToggleDesc2"), + state: { + isChecked: function(target) { + return csscoverage.getUsage(target).then(usage => { + return usage.isRunning(); + }); + }, + onChange: function(target, handler) { + csscoverage.getUsage(target).then(usage => { + this.handler = ev => { handler("state-change", ev); }; + usage.on("state-change", this.handler); + }); + }, + offChange: function(target, handler) { + csscoverage.getUsage(target).then(usage => { + usage.off("state-change", this.handler); + this.handler = undefined; + }); + }, + }, + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + + yield usage.toggle(context.environment.chromeWindow, + context.environment.target); + } + }, + { + item: "command", + runAt: "client", + name: "csscoverage report", + hidden: true, + description: l10n.lookup("csscoverageReportDesc2"), + exec: function*(args, context) { + let usage = yield csscoverage.getUsage(context.environment.target); + if (usage == null) { + throw new Error(l10n.lookup("csscoverageNoRemoteError")); + } + + return { + isTypedData: true, + type: "csscoveragePageReport", + data: yield usage.createPageReport() + }; + } + }, + { + item: "converter", + from: "csscoveragePageReport", + to: "dom", + exec: function*(csscoveragePageReport, context) { + let target = context.environment.target; + + let toolbox = yield gDevTools.showToolbox(target, "styleeditor"); + let panel = toolbox.getCurrentPanel(); + + let host = panel._panelDoc.querySelector(".csscoverage-report"); + let templ = panel._panelDoc.querySelector(".csscoverage-template"); + + templ = templ.cloneNode(true); + templ.hidden = false; + + let data = { + preload: csscoveragePageReport.preload, + unused: csscoveragePageReport.unused, + summary: csscoveragePageReport.summary, + onback: () => { + // The back button clears and hides .csscoverage-report + while (host.hasChildNodes()) { + host.removeChild(host.firstChild); + } + host.hidden = true; + } + }; + + let addOnClick = rule => { + rule.onclick = () => { + panel.selectStyleSheet(rule.url, rule.start.line); + }; + }; + + data.preload.forEach(page => { + page.rules.forEach(addOnClick); + }); + data.unused.forEach(page => { + page.rules.forEach(addOnClick); + }); + + let options = { allowEval: true, stack: "styleeditor.xul" }; + domtemplate.template(templ, data, options); + + while (templ.hasChildNodes()) { + host.appendChild(templ.firstChild); + } + + // Create a new chart. + let container = host.querySelector(".csscoverage-report-chart"); + let chart = Chart.PieTable(panel._panelDoc, { + diameter: 200, // px + title: "CSS Usage", + data: [ + { size: data.summary.preload, label: "Used Preload" }, + { size: data.summary.used, label: "Used" }, + { size: data.summary.unused, label: "Unused" } + ] + }); + container.appendChild(chart.node); + + host.hidden = false; + } + } +]; diff --git a/devtools/shared/gcli/commands/folder.js b/devtools/shared/gcli/commands/folder.js new file mode 100644 index 000000000..22a51420d --- /dev/null +++ b/devtools/shared/gcli/commands/folder.js @@ -0,0 +1,77 @@ +/* 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 { Cc, Ci, Cu, CC } = require("chrome"); +const Services = require("Services"); +const l10n = require("gcli/l10n"); +const dirService = Cc["@mozilla.org/file/directory_service;1"] + .getService(Ci.nsIProperties); + +function showFolder(aPath) { + let nsLocalFile = CC("@mozilla.org/file/local;1", "nsILocalFile", + "initWithPath"); + + try { + let file = new nsLocalFile(aPath); + + if (file.exists()) { + file.reveal(); + return l10n.lookupFormat("folderOpenDirResult", [aPath]); + } else { + return l10n.lookup("folderInvalidPath"); + } + } catch (e) { + return l10n.lookup("folderInvalidPath"); + } +} + +exports.items = [ + { + name: "folder", + description: l10n.lookup("folderDesc") + }, + { + item: "command", + runAt: "client", + name: "folder open", + description: l10n.lookup("folderOpenDesc"), + params: [ + { + name: "path", + type: { name: "string", allowBlank: true }, + defaultValue: "~", + description: l10n.lookup("folderOpenDir") + } + ], + returnType: "string", + exec: function(args, context) { + let dirName = args.path; + + // replaces ~ with the home directory path in unix and windows + if (dirName.indexOf("~") == 0) { + let homeDirFile = dirService.get("Home", Ci.nsIFile); + let homeDir = homeDirFile.path; + dirName = dirName.substr(1); + dirName = homeDir + dirName; + } + + return showFolder(dirName); + } + }, + { + item: "command", + runAt: "client", + name: "folder openprofile", + description: l10n.lookup("folderOpenProfileDesc"), + returnType: "string", + exec: function(args, context) { + // Get the profile directory. + let currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileDir = currProfD.path; + return showFolder(profileDir); + } + } +]; diff --git a/devtools/shared/gcli/commands/highlight.js b/devtools/shared/gcli/commands/highlight.js new file mode 100644 index 000000000..cc2353b3b --- /dev/null +++ b/devtools/shared/gcli/commands/highlight.js @@ -0,0 +1,158 @@ +/* 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 l10n = require("gcli/l10n"); +require("devtools/server/actors/inspector"); +const { + BoxModelHighlighter, + HighlighterEnvironment +} = require("devtools/server/actors/highlighters"); + +const {PluralForm} = require("devtools/shared/plural-form"); +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/shared/locales/gclicommands.properties"); + +// How many maximum nodes can be highlighted in parallel +const MAX_HIGHLIGHTED_ELEMENTS = 100; + +// Store the environment object used to create highlighters so it can be +// destroyed later. +var highlighterEnv; + +// Stores the highlighters instances so they can be destroyed later. +// also export them so tests can access those and assert they got created +// correctly. +exports.highlighters = []; + +/** + * Destroy all existing highlighters + */ +function unhighlightAll() { + for (let highlighter of exports.highlighters) { + highlighter.destroy(); + } + exports.highlighters.length = 0; + + if (highlighterEnv) { + highlighterEnv.destroy(); + highlighterEnv = null; + } +} + +exports.items = [ + { + item: "command", + runAt: "server", + name: "highlight", + description: l10n.lookup("highlightDesc"), + manual: l10n.lookup("highlightManual"), + params: [ + { + name: "selector", + type: "nodelist", + description: l10n.lookup("highlightSelectorDesc"), + manual: l10n.lookup("highlightSelectorManual") + }, + { + group: l10n.lookup("highlightOptionsDesc"), + params: [ + { + name: "hideguides", + type: "boolean", + description: l10n.lookup("highlightHideGuidesDesc"), + manual: l10n.lookup("highlightHideGuidesManual") + }, + { + name: "showinfobar", + type: "boolean", + description: l10n.lookup("highlightShowInfoBarDesc"), + manual: l10n.lookup("highlightShowInfoBarManual") + }, + { + name: "showall", + type: "boolean", + description: l10n.lookup("highlightShowAllDesc"), + manual: l10n.lookup("highlightShowAllManual") + }, + { + name: "region", + type: { + name: "selection", + data: ["content", "padding", "border", "margin"] + }, + description: l10n.lookup("highlightRegionDesc"), + manual: l10n.lookup("highlightRegionManual"), + defaultValue: "border" + }, + { + name: "fill", + type: "string", + description: l10n.lookup("highlightFillDesc"), + manual: l10n.lookup("highlightFillManual"), + defaultValue: null + }, + { + name: "keep", + type: "boolean", + description: l10n.lookup("highlightKeepDesc"), + manual: l10n.lookup("highlightKeepManual") + } + ] + } + ], + exec: function(args, context) { + // Remove all existing highlighters unless told otherwise + if (!args.keep) { + unhighlightAll(); + } + + let env = context.environment; + highlighterEnv = new HighlighterEnvironment(); + highlighterEnv.initFromWindow(env.window); + + // Unhighlight on navigate + highlighterEnv.once("will-navigate", unhighlightAll); + + let i = 0; + for (let node of args.selector) { + if (!args.showall && i >= MAX_HIGHLIGHTED_ELEMENTS) { + break; + } + + let highlighter = new BoxModelHighlighter(highlighterEnv); + if (args.fill) { + highlighter.regionFill[args.region] = args.fill; + } + highlighter.show(node, { + region: args.region, + hideInfoBar: !args.showinfobar, + hideGuides: args.hideguides, + showOnly: args.region + }); + exports.highlighters.push(highlighter); + i++; + } + + let highlightText = L10N.getStr("highlightOutputConfirm2"); + let output = PluralForm.get(args.selector.length, highlightText) + .replace("%1$S", args.selector.length); + if (args.selector.length > i) { + output = l10n.lookupFormat("highlightOutputMaxReached", + ["" + args.selector.length, "" + i]); + } + + return output; + } + }, + { + item: "command", + runAt: "server", + name: "unhighlight", + description: l10n.lookup("unhighlightDesc"), + manual: l10n.lookup("unhighlightManual"), + exec: unhighlightAll + } +]; diff --git a/devtools/shared/gcli/commands/index.js b/devtools/shared/gcli/commands/index.js new file mode 100644 index 000000000..8fe77482e --- /dev/null +++ b/devtools/shared/gcli/commands/index.js @@ -0,0 +1,179 @@ +/* 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 { createSystem, connectFront, disconnectFront } = require("gcli/system"); +const { GcliFront } = require("devtools/shared/fronts/gcli"); + +/** + * This is the basic list of modules that should be loaded into each + * requisition instance whether server side or client side + */ +exports.baseModules = [ + "gcli/types/delegate", + "gcli/types/selection", + "gcli/types/array", + + "gcli/types/boolean", + "gcli/types/command", + "gcli/types/date", + "gcli/types/file", + "gcli/types/javascript", + "gcli/types/node", + "gcli/types/number", + "gcli/types/resource", + "gcli/types/setting", + "gcli/types/string", + "gcli/types/union", + "gcli/types/url", + + "gcli/fields/fields", + "gcli/fields/delegate", + "gcli/fields/selection", + + "gcli/ui/focus", + "gcli/ui/intro", + + "gcli/converters/converters", + "gcli/converters/basic", + "gcli/converters/terminal", + + "gcli/languages/command", + "gcli/languages/javascript", + + "gcli/commands/clear", + "gcli/commands/context", + "gcli/commands/help", + "gcli/commands/pref", +]; + +/** + * Some commands belong to a tool (see getToolModules). This is a list of the + * modules that are *not* owned by a tool. + */ +exports.devtoolsModules = [ + "devtools/shared/gcli/commands/addon", + "devtools/shared/gcli/commands/appcache", + "devtools/shared/gcli/commands/calllog", + "devtools/shared/gcli/commands/cmd", + "devtools/shared/gcli/commands/cookie", + "devtools/shared/gcli/commands/csscoverage", + "devtools/shared/gcli/commands/folder", + "devtools/shared/gcli/commands/highlight", + "devtools/shared/gcli/commands/inject", + "devtools/shared/gcli/commands/jsb", + "devtools/shared/gcli/commands/listen", + "devtools/shared/gcli/commands/mdn", + "devtools/shared/gcli/commands/measure", + "devtools/shared/gcli/commands/media", + "devtools/shared/gcli/commands/pagemod", + "devtools/shared/gcli/commands/paintflashing", + "devtools/shared/gcli/commands/qsa", + "devtools/shared/gcli/commands/restart", + "devtools/shared/gcli/commands/rulers", + "devtools/shared/gcli/commands/screenshot", + "devtools/shared/gcli/commands/security", +]; + +/** + * Register commands from tools with 'command: [ "some/module" ]' definitions. + * The map/reduce incantation squashes the array of arrays to a single array. + */ +try { + const { defaultTools } = require("devtools/client/definitions"); + exports.devtoolsToolModules = defaultTools.map(def => def.commands || []) + .reduce((prev, curr) => prev.concat(curr), []); +} catch (e) { + // "devtools/client/definitions" is only accessible from Firefox + exports.devtoolsToolModules = []; +} + +/** + * Register commands from toolbox buttons with 'command: [ "some/module" ]' + * definitions. The map/reduce incantation squashes the array of arrays to a + * single array. + */ +try { + const { ToolboxButtons } = require("devtools/client/definitions"); + exports.devtoolsButtonModules = ToolboxButtons.map(def => def.commands || []) + .reduce((prev, curr) => prev.concat(curr), []); +} catch (e) { + // "devtools/client/definitions" is only accessible from Firefox + exports.devtoolsButtonModules = []; +} + +/** + * Add modules to a system for use in a content process (but don't call load) + */ +exports.addAllItemsByModule = function(system) { + system.addItemsByModule(exports.baseModules, { delayedLoad: true }); + system.addItemsByModule(exports.devtoolsModules, { delayedLoad: true }); + system.addItemsByModule(exports.devtoolsToolModules, { delayedLoad: true }); + system.addItemsByModule(exports.devtoolsButtonModules, { delayedLoad: true }); + + const { mozDirLoader } = require("devtools/shared/gcli/commands/cmd"); + system.addItemsByModule("mozcmd", { delayedLoad: true, loader: mozDirLoader }); +}; + +/** + * This is WeakMap where Links is an object that looks like + * { refs: number, promise: Promise, front: GcliFront } + */ +var linksForTarget = new WeakMap(); + +/** + * The toolbox uses the following properties on a command to allow it to be + * added to the toolbox toolbar + */ +var customProperties = [ "buttonId", "buttonClass", "tooltipText" ]; + +/** + * Create a system which connects to a GCLI in a remote target + * @return Promise for the given target + */ +exports.getSystem = function(target) { + const existingLinks = linksForTarget.get(target); + if (existingLinks != null) { + existingLinks.refs++; + return existingLinks.promise; + } + + const system = createSystem({ location: "client" }); + + exports.addAllItemsByModule(system); + + // Load the client system + const links = { + refs: 1, + system, + promise: system.load().then(() => { + return GcliFront.create(target).then(front => { + links.front = front; + return connectFront(system, front, customProperties).then(() => system); + }); + }) + }; + + linksForTarget.set(target, links); + return links.promise; +}; + +/** + * Someone that called getSystem doesn't need it any more, so decrement the + * count of users of the system for that target, and destroy if needed + */ +exports.releaseSystem = function(target) { + const links = linksForTarget.get(target); + if (links == null) { + throw new Error("releaseSystem called for unknown target"); + } + + links.refs--; + if (links.refs === 0) { + disconnectFront(links.system, links.front); + links.system.destroy(); + linksForTarget.delete(target); + } +}; diff --git a/devtools/shared/gcli/commands/inject.js b/devtools/shared/gcli/commands/inject.js new file mode 100644 index 000000000..85e995eed --- /dev/null +++ b/devtools/shared/gcli/commands/inject.js @@ -0,0 +1,86 @@ +/* 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 Services = require("Services"); +const { listenOnce } = require("devtools/shared/async-utils"); +const l10n = require("gcli/l10n"); + +exports.items = [ + { + item: "command", + runAt: "server", + name: "inject", + description: l10n.lookup("injectDesc"), + manual: l10n.lookup("injectManual2"), + params: [{ + name: "library", + type: { + name: "union", + alternatives: [ + { + name: "selection", + lookup: [ + { + name: "jQuery", + value: { + name: "jQuery", + src: Services.prefs.getCharPref("devtools.gcli.jquerySrc") + } + }, + { + name: "lodash", + value: { + name: "lodash", + src: Services.prefs.getCharPref("devtools.gcli.lodashSrc") + } + }, + { + name: "underscore", + value: { + name: "underscore", + src: Services.prefs.getCharPref("devtools.gcli.underscoreSrc") + } + } + ] + }, + { + name: "url" + } + ] + }, + description: l10n.lookup("injectLibraryDesc") + }], + exec: function*(args, context) { + let document = context.environment.document; + let library = args.library; + let name = (library.type === "selection") ? + library.selection.name : library.url; + let src = (library.type === "selection") ? + library.selection.src : library.url; + + if (context.environment.window.location.protocol == "https:") { + src = src.replace(/^http:/, "https:"); + } + + try { + // Check if URI is valid + Services.io.newURI(src, null, null); + } catch(e) { + return l10n.lookupFormat("injectFailed", [name]); + } + + let newSource = document.createElement("script"); + newSource.setAttribute("src", src); + + let loadPromise = listenOnce(newSource, "load"); + document.head.appendChild(newSource); + + yield loadPromise; + + return l10n.lookupFormat("injectLoaded", [name]); + } + } +]; diff --git a/devtools/shared/gcli/commands/jsb.js b/devtools/shared/gcli/commands/jsb.js new file mode 100644 index 000000000..b56e079d2 --- /dev/null +++ b/devtools/shared/gcli/commands/jsb.js @@ -0,0 +1,134 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const l10n = require("gcli/l10n"); +const XMLHttpRequest = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]; + +loader.lazyImporter(this, "Preferences", "resource://gre/modules/Preferences.jsm"); +loader.lazyImporter(this, "ScratchpadManager", "resource://devtools/client/scratchpad/scratchpad-manager.jsm"); + +loader.lazyRequireGetter(this, "beautify", "devtools/shared/jsbeautify/beautify"); + +exports.items = [ + { + item: "command", + runAt: "client", + name: "jsb", + description: l10n.lookup("jsbDesc"), + returnValue:"string", + params: [ + { + name: "url", + type: "string", + description: l10n.lookup("jsbUrlDesc") + }, + { + group: l10n.lookup("jsbOptionsDesc"), + params: [ + { + name: "indentSize", + type: "number", + description: l10n.lookup("jsbIndentSizeDesc"), + manual: l10n.lookup("jsbIndentSizeManual"), + defaultValue: Preferences.get("devtools.editor.tabsize", 2), + }, + { + name: "indentChar", + type: { + name: "selection", + lookup: [ + { name: "space", value: " " }, + { name: "tab", value: "\t" } + ] + }, + description: l10n.lookup("jsbIndentCharDesc"), + manual: l10n.lookup("jsbIndentCharManual"), + defaultValue: " ", + }, + { + name: "doNotPreserveNewlines", + type: "boolean", + description: l10n.lookup("jsbDoNotPreserveNewlinesDesc") + }, + { + name: "preserveMaxNewlines", + type: "number", + description: l10n.lookup("jsbPreserveMaxNewlinesDesc"), + manual: l10n.lookup("jsbPreserveMaxNewlinesManual"), + defaultValue: -1 + }, + { + name: "jslintHappy", + type: "boolean", + description: l10n.lookup("jsbJslintHappyDesc"), + manual: l10n.lookup("jsbJslintHappyManual") + }, + { + name: "braceStyle", + type: { + name: "selection", + data: ["collapse", "expand", "end-expand", "expand-strict"] + }, + description: l10n.lookup("jsbBraceStyleDesc2"), + manual: l10n.lookup("jsbBraceStyleManual2"), + defaultValue: "collapse" + }, + { + name: "noSpaceBeforeConditional", + type: "boolean", + description: l10n.lookup("jsbNoSpaceBeforeConditionalDesc") + }, + { + name: "unescapeStrings", + type: "boolean", + description: l10n.lookup("jsbUnescapeStringsDesc"), + manual: l10n.lookup("jsbUnescapeStringsManual") + } + ] + } + ], + exec: function(args, context) { + let opts = { + indent_size: args.indentSize, + indent_char: args.indentChar, + preserve_newlines: !args.doNotPreserveNewlines, + max_preserve_newlines: args.preserveMaxNewlines == -1 ? + undefined : args.preserveMaxNewlines, + jslint_happy: args.jslintHappy, + brace_style: args.braceStyle, + space_before_conditional: !args.noSpaceBeforeConditional, + unescape_strings: args.unescapeStrings + }; + + let xhr = new XMLHttpRequest(); + + let deferred = context.defer(); + + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + if (xhr.status == 200 || xhr.status == 0) { + let result = beautify.js(xhr.responseText, opts); + + ScratchpadManager.openScratchpad({text: result}); + + deferred.resolve(); + } else { + deferred.reject("Unable to load page to beautify: " + args.url + " " + + xhr.status + " " + xhr.statusText); + } + }; + } + try { + xhr.open("GET", args.url, true); + xhr.send(null); + } catch(e) { + return l10n.lookup("jsbInvalidURL"); + } + return deferred.promise; + } + } +]; diff --git a/devtools/shared/gcli/commands/listen.js b/devtools/shared/gcli/commands/listen.js new file mode 100644 index 000000000..7878577fb --- /dev/null +++ b/devtools/shared/gcli/commands/listen.js @@ -0,0 +1,106 @@ +/* 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 { Cc, Ci } = require("chrome"); +const Services = require("Services"); +const l10n = require("gcli/l10n"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "DevToolsLoader", + "resource://devtools/shared/Loader.jsm"); + +const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService) + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + +XPCOMUtils.defineLazyGetter(this, "debuggerServer", () => { + // Create a separate loader instance, so that we can be sure to receive + // a separate instance of the DebuggingServer from the rest of the + // devtools. This allows us to safely use the tools against even the + // actors and DebuggingServer itself, especially since we can mark + // serverLoader as invisible to the debugger (unlike the usual loader + // settings). + let serverLoader = new DevToolsLoader(); + serverLoader.invisibleToDebugger = true; + let { DebuggerServer: debuggerServer } = serverLoader.require("devtools/server/main"); + debuggerServer.init(); + debuggerServer.addBrowserActors(); + debuggerServer.allowChromeProcess = !l10n.hiddenByChromePref(); + return debuggerServer; +}); + +exports.items = [ + { + item: "command", + runAt: "client", + name: "listen", + description: l10n.lookup("listenDesc"), + manual: l10n.lookupFormat("listenManual2", [ BRAND_SHORT_NAME ]), + params: [ + { + name: "port", + type: "number", + get defaultValue() { + return Services.prefs.getIntPref("devtools.debugger.remote-port"); + }, + description: l10n.lookup("listenPortDesc"), + }, + { + name: "protocol", + get defaultValue() { + let webSocket = Services.prefs + .getBoolPref("devtools.debugger.remote-websocket"); + let protocol; + if (webSocket === true) { + protocol = "websocket"; + } else { + protocol = "mozilla-rdp"; + } + return protocol; + }, + type: { + name: "selection", + data: [ "mozilla-rdp", "websocket"], + }, + description: l10n.lookup("listenProtocolDesc"), + }, + ], + exec: function (args, context) { + var listener = debuggerServer.createListener(); + if (!listener) { + throw new Error(l10n.lookup("listenDisabledOutput")); + } + + let webSocket = false; + if (args.protocol === "websocket") { + webSocket = true; + } else if (args.protocol === "mozilla-rdp") { + webSocket = false; + } + + listener.portOrPath = args.port; + listener.webSocket = webSocket; + listener.open(); + + if (debuggerServer.initialized) { + return l10n.lookupFormat("listenInitOutput", [ "" + args.port ]); + } + + return l10n.lookup("listenNoInitOutput"); + }, + }, + { + item: "command", + runAt: "client", + name: "unlisten", + description: l10n.lookup("unlistenDesc"), + manual: l10n.lookup("unlistenManual"), + exec: function (args, context) { + debuggerServer.closeAllListeners(); + return l10n.lookup("unlistenOutput"); + } + } +]; diff --git a/devtools/shared/gcli/commands/mdn.js b/devtools/shared/gcli/commands/mdn.js new file mode 100644 index 000000000..57e582e40 --- /dev/null +++ b/devtools/shared/gcli/commands/mdn.js @@ -0,0 +1,83 @@ +/* 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 l10n = require("gcli/l10n"); + +var MdnDocsWidget; +try { + MdnDocsWidget = require("devtools/client/shared/widgets/MdnDocsWidget"); +} catch (e) { + // DevTools MdnDocsWidget only available in Firefox Desktop +} + +exports.items = [{ + name: "mdn", + description: l10n.lookup("mdnDesc") +}, { + item: "command", + runAt: "client", + name: "mdn css", + description: l10n.lookup("mdnCssDesc"), + returnType: "cssPropertyOutput", + params: [{ + name: "property", + type: { name: "string" }, + defaultValue: null, + description: l10n.lookup("mdnCssProp") + }], + exec: function(args) { + if (!MdnDocsWidget) { + return null; + } + + return MdnDocsWidget.getCssDocs(args.property).then(result => { + return { + data: result, + url: MdnDocsWidget.PAGE_LINK_URL + args.property, + property: args.property + }; + }, error => { + return { error, property: args.property }; + }); + } +}, { + item: "converter", + from: "cssPropertyOutput", + to: "dom", + exec: function(result, context) { + let propertyName = result.property; + + let document = context.document; + let root = document.createElement("div"); + + if (result.error) { + // The css property specified doesn't exist. + root.appendChild(document.createTextNode( + l10n.lookupFormat("mdnCssPropertyNotFound", [ propertyName ]) + + " (" + result.error + ")")); + } else { + let title = document.createElement("h2"); + title.textContent = propertyName; + root.appendChild(title); + + let link = document.createElement("p"); + link.classList.add("gcli-mdn-url"); + link.textContent = l10n.lookup("mdnCssVisitPage"); + root.appendChild(link); + + link.addEventListener("click", () => { + let mainWindow = context.environment.chromeWindow; + mainWindow.openUILinkIn(result.url, "tab"); + }); + + let summary = document.createElement("p"); + summary.textContent = result.data.summary; + root.appendChild(summary); + } + + return root; + } +}]; diff --git a/devtools/shared/gcli/commands/measure.js b/devtools/shared/gcli/commands/measure.js new file mode 100644 index 000000000..7f6233a95 --- /dev/null +++ b/devtools/shared/gcli/commands/measure.js @@ -0,0 +1,112 @@ +/* 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/. */ + /* globals getOuterId, getBrowserForTab */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const eventEmitter = new EventEmitter(); +const events = require("sdk/event/core"); + +loader.lazyRequireGetter(this, "getOuterId", "sdk/window/utils", true); +loader.lazyRequireGetter(this, "getBrowserForTab", "sdk/tabs/utils", true); + +const l10n = require("gcli/l10n"); +require("devtools/server/actors/inspector"); +const { MeasuringToolHighlighter, HighlighterEnvironment } = + require("devtools/server/actors/highlighters"); + +const highlighters = new WeakMap(); +const visibleHighlighters = new Set(); + +const isCheckedFor = (tab) => + tab ? visibleHighlighters.has(getBrowserForTab(tab).outerWindowID) : false; + +exports.items = [ + // The client measure command is used to maintain the toolbar button state + // only and redirects to the server command to actually toggle the measuring + // tool (see `measure_server` below). + { + name: "measure", + runAt: "client", + description: l10n.lookup("measureDesc"), + manual: l10n.lookup("measureManual"), + buttonId: "command-button-measure", + buttonClass: "command-button command-button-invertable", + tooltipText: l10n.lookup("measureTooltip"), + state: { + isChecked: ({_tab}) => isCheckedFor(_tab), + onChange: (target, handler) => eventEmitter.on("changed", handler), + offChange: (target, handler) => eventEmitter.off("changed", handler) + }, + exec: function*(args, context) { + let { target } = context.environment; + + // Pipe the call to the server command. + let response = yield context.updateExec("measure_server"); + let { visible, id } = response.data; + + if (visible) { + visibleHighlighters.add(id); + } else { + visibleHighlighters.delete(id); + } + + eventEmitter.emit("changed", { target }); + + // Toggle off the button when the page navigates because the measuring + // tool is removed automatically by the MeasuringToolHighlighter on the + // server then. + let onNavigate = () => { + visibleHighlighters.delete(id); + eventEmitter.emit("changed", { target }); + }; + target.off("will-navigate", onNavigate); + target.once("will-navigate", onNavigate); + } + }, + // The server measure command is hidden by default, it's just used by the + // client command. + { + name: "measure_server", + runAt: "server", + hidden: true, + returnType: "highlighterVisibility", + exec: function(args, context) { + let env = context.environment; + let { document } = env; + let id = getOuterId(env.window); + + // Calling the command again after the measuring tool has been shown once, + // hides it. + if (highlighters.has(document)) { + let { highlighter } = highlighters.get(document); + highlighter.destroy(); + return {visible: false, id}; + } + + // Otherwise, display the measuring tool. + let environment = new HighlighterEnvironment(); + environment.initFromWindow(env.window); + let highlighter = new MeasuringToolHighlighter(environment); + + // Store the instance of the measuring tool highlighter for this document + // so we can hide it later. + highlighters.set(document, { highlighter, environment }); + + // Listen to the highlighter's destroy event which may happen if the + // window is refreshed or closed with the measuring tool shown. + events.once(highlighter, "destroy", () => { + if (highlighters.has(document)) { + let { environment } = highlighters.get(document); + environment.destroy(); + highlighters.delete(document); + } + }); + + highlighter.show(); + return {visible: true, id}; + } + } +]; diff --git a/devtools/shared/gcli/commands/media.js b/devtools/shared/gcli/commands/media.js new file mode 100644 index 000000000..908e9eb5e --- /dev/null +++ b/devtools/shared/gcli/commands/media.js @@ -0,0 +1,56 @@ +/* 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 {Ci} = require("chrome"); +const l10n = require("gcli/l10n"); + +function getContentViewer(context) { + let {window} = context.environment; + return window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .contentViewer; +} + +exports.items = [ + { + name: "media", + description: l10n.lookup("mediaDesc") + }, + { + item: "command", + runAt: "server", + name: "media emulate", + description: l10n.lookup("mediaEmulateDesc"), + manual: l10n.lookup("mediaEmulateManual"), + params: [ + { + name: "type", + description: l10n.lookup("mediaEmulateType"), + type: { + name: "selection", + data: [ + "braille", "embossed", "handheld", "print", "projection", + "screen", "speech", "tty", "tv" + ] + } + } + ], + exec: function(args, context) { + let contentViewer = getContentViewer(context); + contentViewer.emulateMedium(args.type); + } + }, + { + item: "command", + runAt: "server", + name: "media reset", + description: l10n.lookup("mediaResetDesc"), + exec: function(args, context) { + let contentViewer = getContentViewer(context); + contentViewer.stopEmulatingMedium(); + } + } +]; diff --git a/devtools/shared/gcli/commands/moz.build b/devtools/shared/gcli/commands/moz.build new file mode 100644 index 000000000..e5f3fe91c --- /dev/null +++ b/devtools/shared/gcli/commands/moz.build @@ -0,0 +1,30 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'addon.js', + 'appcache.js', + 'calllog.js', + 'cmd.js', + 'cookie.js', + 'csscoverage.js', + 'folder.js', + 'highlight.js', + 'index.js', + 'inject.js', + 'jsb.js', + 'listen.js', + 'mdn.js', + 'measure.js', + 'media.js', + 'pagemod.js', + 'paintflashing.js', + 'qsa.js', + 'restart.js', + 'rulers.js', + 'screenshot.js', + 'security.js', +) diff --git a/devtools/shared/gcli/commands/pagemod.js b/devtools/shared/gcli/commands/pagemod.js new file mode 100644 index 000000000..184ab1ea3 --- /dev/null +++ b/devtools/shared/gcli/commands/pagemod.js @@ -0,0 +1,276 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const l10n = require("gcli/l10n"); + +exports.items = [ + { + name: "pagemod", + description: l10n.lookup("pagemodDesc"), + }, + { + item: "command", + runAt: "server", + name: "pagemod replace", + description: l10n.lookup("pagemodReplaceDesc"), + params: [ + { + name: "search", + type: "string", + description: l10n.lookup("pagemodReplaceSearchDesc"), + }, + { + name: "replace", + type: "string", + description: l10n.lookup("pagemodReplaceReplaceDesc"), + }, + { + name: "ignoreCase", + type: "boolean", + description: l10n.lookup("pagemodReplaceIgnoreCaseDesc"), + }, + { + name: "selector", + type: "string", + description: l10n.lookup("pagemodReplaceSelectorDesc"), + defaultValue: "*:not(script):not(style):not(embed):not(object):not(frame):not(iframe):not(frameset)", + }, + { + name: "root", + type: "node", + description: l10n.lookup("pagemodReplaceRootDesc"), + defaultValue: null, + }, + { + name: "attrOnly", + type: "boolean", + description: l10n.lookup("pagemodReplaceAttrOnlyDesc"), + }, + { + name: "contentOnly", + type: "boolean", + description: l10n.lookup("pagemodReplaceContentOnlyDesc"), + }, + { + name: "attributes", + type: "string", + description: l10n.lookup("pagemodReplaceAttributesDesc"), + defaultValue: null, + }, + ], + // Make a given string safe to use in a regular expression. + escapeRegex: function(aString) { + return aString.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + }, + exec: function(args, context) { + let searchTextNodes = !args.attrOnly; + let searchAttributes = !args.contentOnly; + let regexOptions = args.ignoreCase ? "ig" : "g"; + let search = new RegExp(this.escapeRegex(args.search), regexOptions); + let attributeRegex = null; + if (args.attributes) { + attributeRegex = new RegExp(args.attributes, regexOptions); + } + + let root = args.root || context.environment.document; + let elements = root.querySelectorAll(args.selector); + elements = Array.prototype.slice.call(elements); + + let replacedTextNodes = 0; + let replacedAttributes = 0; + + function replaceAttribute() { + replacedAttributes++; + return args.replace; + } + function replaceTextNode() { + replacedTextNodes++; + return args.replace; + } + + for (let i = 0; i < elements.length; i++) { + let element = elements[i]; + if (searchTextNodes) { + for (let y = 0; y < element.childNodes.length; y++) { + let node = element.childNodes[y]; + if (node.nodeType == node.TEXT_NODE) { + node.textContent = node.textContent.replace(search, replaceTextNode); + } + } + } + + if (searchAttributes) { + if (!element.attributes) { + continue; + } + for (let y = 0; y < element.attributes.length; y++) { + let attr = element.attributes[y]; + if (!attributeRegex || attributeRegex.test(attr.name)) { + attr.value = attr.value.replace(search, replaceAttribute); + } + } + } + } + + return l10n.lookupFormat("pagemodReplaceResult", + [elements.length, replacedTextNodes, + replacedAttributes]); + } + }, + { + name: "pagemod remove", + description: l10n.lookup("pagemodRemoveDesc"), + }, + { + item: "command", + runAt: "server", + name: "pagemod remove element", + description: l10n.lookup("pagemodRemoveElementDesc"), + params: [ + { + name: "search", + type: "string", + description: l10n.lookup("pagemodRemoveElementSearchDesc"), + }, + { + name: "root", + type: "node", + description: l10n.lookup("pagemodRemoveElementRootDesc"), + defaultValue: null, + }, + { + name: "stripOnly", + type: "boolean", + description: l10n.lookup("pagemodRemoveElementStripOnlyDesc"), + }, + { + name: "ifEmptyOnly", + type: "boolean", + description: l10n.lookup("pagemodRemoveElementIfEmptyOnlyDesc"), + }, + ], + exec: function(args, context) { + let root = args.root || context.environment.document; + let elements = Array.prototype.slice.call(root.querySelectorAll(args.search)); + + let removed = 0; + for (let i = 0; i < elements.length; i++) { + let element = elements[i]; + let parentNode = element.parentNode; + if (!parentNode || !element.removeChild) { + continue; + } + if (args.stripOnly) { + while (element.hasChildNodes()) { + parentNode.insertBefore(element.childNodes[0], element); + } + } + if (!args.ifEmptyOnly || !element.hasChildNodes()) { + element.parentNode.removeChild(element); + removed++; + } + } + + return l10n.lookupFormat("pagemodRemoveElementResultMatchedAndRemovedElements", + [elements.length, removed]); + } + }, + { + item: "command", + runAt: "server", + name: "pagemod remove attribute", + description: l10n.lookup("pagemodRemoveAttributeDesc"), + params: [ + { + name: "searchAttributes", + type: "string", + description: l10n.lookup("pagemodRemoveAttributeSearchAttributesDesc"), + }, + { + name: "searchElements", + type: "string", + description: l10n.lookup("pagemodRemoveAttributeSearchElementsDesc"), + }, + { + name: "root", + type: "node", + description: l10n.lookup("pagemodRemoveAttributeRootDesc"), + defaultValue: null, + }, + { + name: "ignoreCase", + type: "boolean", + description: l10n.lookup("pagemodRemoveAttributeIgnoreCaseDesc"), + }, + ], + exec: function(args, context) { + let root = args.root || context.environment.document; + let regexOptions = args.ignoreCase ? "ig" : "g"; + let attributeRegex = new RegExp(args.searchAttributes, regexOptions); + let elements = root.querySelectorAll(args.searchElements); + elements = Array.prototype.slice.call(elements); + + let removed = 0; + for (let i = 0; i < elements.length; i++) { + let element = elements[i]; + if (!element.attributes) { + continue; + } + + var attrs = Array.prototype.slice.call(element.attributes); + for (let y = 0; y < attrs.length; y++) { + let attr = attrs[y]; + if (attributeRegex.test(attr.name)) { + element.removeAttribute(attr.name); + removed++; + } + } + } + + return l10n.lookupFormat("pagemodRemoveAttributeResult", + [elements.length, removed]); + } + }, + // This command allows the user to export the page to HTML after DOM changes + { + name: "export", + description: l10n.lookup("exportDesc"), + }, + { + item: "command", + runAt: "server", + name: "export html", + description: l10n.lookup("exportHtmlDesc"), + params: [ + { + name: "destination", + type: { + name: "selection", + data: [ "window", "stdout", "clipboard" ] + }, + defaultValue: "window" + } + ], + exec: function(args, context) { + let html = context.environment.document.documentElement.outerHTML; + if (args.destination === "stdout") { + return html; + } + + if (args.desination === "clipboard") { + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper); + clipboard.copyString(url); + return ''; + } + + let url = "data:text/plain;charset=utf8," + encodeURIComponent(html); + context.environment.window.open(url); + return ''; + } + } +]; diff --git a/devtools/shared/gcli/commands/paintflashing.js b/devtools/shared/gcli/commands/paintflashing.js new file mode 100644 index 000000000..7e21911a7 --- /dev/null +++ b/devtools/shared/gcli/commands/paintflashing.js @@ -0,0 +1,201 @@ +/* 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 { Ci } = require("chrome"); +loader.lazyRequireGetter(this, "getOuterId", "sdk/window/utils", true); +loader.lazyRequireGetter(this, "getBrowserForTab", "sdk/tabs/utils", true); + +var telemetry; +try { + const Telemetry = require("devtools/client/shared/telemetry"); + telemetry = new Telemetry(); +} catch(e) { + // DevTools Telemetry module only available in Firefox +} + +const EventEmitter = require("devtools/shared/event-emitter"); +const eventEmitter = new EventEmitter(); + +const gcli = require("gcli/index"); +const l10n = require("gcli/l10n"); + +const enabledPaintFlashing = new Set(); + +const isCheckedFor = (tab) => + tab ? enabledPaintFlashing.has(getBrowserForTab(tab).outerWindowID) : false; + +/** + * Fire events and telemetry when paintFlashing happens + */ +function onPaintFlashingChanged(target, state) { + const { flashing, id } = state; + + if (flashing) { + enabledPaintFlashing.add(id); + } else { + enabledPaintFlashing.delete(id); + } + + eventEmitter.emit("changed", { target: target }); + function fireChange() { + eventEmitter.emit("changed", { target: target }); + } + + target.off("navigate", fireChange); + target.once("navigate", fireChange); + + if (!telemetry) { + return; + } + if (flashing) { + telemetry.toolOpened("paintflashing"); + } else { + telemetry.toolClosed("paintflashing"); + } +} + +/** + * Alter the paintFlashing state of a window and report on the new value. + * This works with chrome or content windows. + * + * This is a bizarre method that you could argue should be broken up into + * separate getter and setter functions, however keeping it as one helps + * to simplify the commands below. + * + * @param state {string} One of: + * - "on" which does window.paintFlashing = true + * - "off" which does window.paintFlashing = false + * - "toggle" which does window.paintFlashing = !window.paintFlashing + * - "query" which does nothing + * @return The new value of the window.paintFlashing flag + */ +function setPaintFlashing(window, state) { + const winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + + if (!["on", "off", "toggle", "query"].includes(state)) { + throw new Error(`Unsupported state: ${state}`); + } + + if (state === "on") { + winUtils.paintFlashing = true; + } else if (state === "off") { + winUtils.paintFlashing = false; + } else if (state === "toggle") { + winUtils.paintFlashing = !winUtils.paintFlashing; + } + + return winUtils.paintFlashing; +} + +exports.items = [ + { + name: "paintflashing", + description: l10n.lookup("paintflashingDesc") + }, + { + item: "command", + runAt: "client", + name: "paintflashing on", + description: l10n.lookup("paintflashingOnDesc"), + manual: l10n.lookup("paintflashingManual"), + params: [{ + group: "options", + params: [ + { + type: "boolean", + name: "chrome", + get hidden() { + return gcli.hiddenByChromePref(); + }, + description: l10n.lookup("paintflashingChromeDesc"), + } + ] + }], + exec: function*(args, context) { + if (!args.chrome) { + const output = yield context.updateExec("paintflashing_server --state on"); + + onPaintFlashingChanged(context.environment.target, output.data); + } else { + setPaintFlashing(context.environment.chromeWindow, "on"); + } + } + }, + { + item: "command", + runAt: "client", + name: "paintflashing off", + description: l10n.lookup("paintflashingOffDesc"), + manual: l10n.lookup("paintflashingManual"), + params: [{ + group: "options", + params: [ + { + type: "boolean", + name: "chrome", + get hidden() { + return gcli.hiddenByChromePref(); + }, + description: l10n.lookup("paintflashingChromeDesc"), + } + ] + }], + exec: function*(args, context) { + if (!args.chrome) { + const output = yield context.updateExec("paintflashing_server --state off"); + + onPaintFlashingChanged(context.environment.target, output.data); + } else { + setPaintFlashing(context.environment.chromeWindow, "off"); + } + } + }, + { + item: "command", + runAt: "client", + name: "paintflashing toggle", + hidden: true, + buttonId: "command-button-paintflashing", + buttonClass: "command-button command-button-invertable", + state: { + isChecked: ({_tab}) => isCheckedFor(_tab), + onChange: (_, handler) => eventEmitter.on("changed", handler), + offChange: (_, handler) => eventEmitter.off("changed", handler), + }, + tooltipText: l10n.lookup("paintflashingTooltip"), + description: l10n.lookup("paintflashingToggleDesc"), + manual: l10n.lookup("paintflashingManual"), + exec: function*(args, context) { + const output = yield context.updateExec("paintflashing_server --state toggle"); + + onPaintFlashingChanged(context.environment.target, output.data); + } + }, + { + item: "command", + runAt: "server", + name: "paintflashing_server", + hidden: true, + params: [ + { + name: "state", + type: { + name: "selection", + data: [ "on", "off", "toggle", "query" ] + } + }, + ], + returnType: "paintFlashingState", + exec: function(args, context) { + let { window } = context.environment; + let id = getOuterId(window); + let flashing = setPaintFlashing(window, args.state); + + return { flashing, id }; + } + } +]; diff --git a/devtools/shared/gcli/commands/qsa.js b/devtools/shared/gcli/commands/qsa.js new file mode 100644 index 000000000..939991f18 --- /dev/null +++ b/devtools/shared/gcli/commands/qsa.js @@ -0,0 +1,24 @@ +/* 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 l10n = require("gcli/l10n"); + +exports.items = [ + { + item: "command", + runAt: "server", + name: "qsa", + description: l10n.lookup("qsaDesc"), + params: [{ + name: "query", + type: "nodelist", + description: l10n.lookup("qsaQueryDesc") + }], + exec: function(args, context) { + return args.query.length; + } + } +]; diff --git a/devtools/shared/gcli/commands/restart.js b/devtools/shared/gcli/commands/restart.js new file mode 100644 index 000000000..cf0e688d3 --- /dev/null +++ b/devtools/shared/gcli/commands/restart.js @@ -0,0 +1,77 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); +const l10n = require("gcli/l10n"); +const Services = require("Services"); + +const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService) + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + +/** + * Restart command + * + * @param boolean nocache + * Disables loading content from cache upon restart. + * + * Examples : + * >> restart + * - restarts browser immediately + * >> restart --nocache + * - restarts immediately and starts Firefox without using cache + */ +exports.items = [ + { + item: "command", + runAt: "client", + name: "restart", + description: l10n.lookupFormat("restartBrowserDesc", [ BRAND_SHORT_NAME ]), + params: [{ + group: l10n.lookup("restartBrowserGroupOptions"), + params: [ + { + name: "nocache", + type: "boolean", + description: l10n.lookup("restartBrowserNocacheDesc") + }, + { + name: "safemode", + type: "boolean", + description: l10n.lookup("restartBrowserSafemodeDesc") + } + ] + }], + returnType: "string", + exec: function Restart(args, context) { + let canceled = Cc["@mozilla.org/supports-PRBool;1"] + .createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(canceled, "quit-application-requested", "restart"); + if (canceled.data) { + return l10n.lookup("restartBrowserRequestCancelled"); + } + + // disable loading content from cache. + if (args.nocache) { + Services.appinfo.invalidateCachesOnRestart(); + } + + const appStartup = Cc["@mozilla.org/toolkit/app-startup;1"] + .getService(Ci.nsIAppStartup); + + if (args.safemode) { + // restart in safemode + appStartup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + } else { + // restart normally + appStartup.quit(Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart); + } + + return l10n.lookupFormat("restartBrowserRestarting", [ BRAND_SHORT_NAME ]); + } + } +]; diff --git a/devtools/shared/gcli/commands/rulers.js b/devtools/shared/gcli/commands/rulers.js new file mode 100644 index 000000000..121e975bc --- /dev/null +++ b/devtools/shared/gcli/commands/rulers.js @@ -0,0 +1,110 @@ +/* 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/. */ +/* globals getBrowserForTab */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const eventEmitter = new EventEmitter(); +const events = require("sdk/event/core"); +loader.lazyRequireGetter(this, "getOuterId", "sdk/window/utils", true); +loader.lazyRequireGetter(this, "getBrowserForTab", "sdk/tabs/utils", true); + +const l10n = require("gcli/l10n"); +require("devtools/server/actors/inspector"); +const { RulersHighlighter, HighlighterEnvironment } = + require("devtools/server/actors/highlighters"); + +const highlighters = new WeakMap(); +const visibleHighlighters = new Set(); + +const isCheckedFor = (tab) => + tab ? visibleHighlighters.has(getBrowserForTab(tab).outerWindowID) : false; + +exports.items = [ + // The client rulers command is used to maintain the toolbar button state only + // and redirects to the server command to actually toggle the rulers (see + // rulers_server below). + { + name: "rulers", + runAt: "client", + description: l10n.lookup("rulersDesc"), + manual: l10n.lookup("rulersManual"), + buttonId: "command-button-rulers", + buttonClass: "command-button command-button-invertable", + tooltipText: l10n.lookup("rulersTooltip"), + state: { + isChecked: ({_tab}) => isCheckedFor(_tab), + onChange: (target, handler) => eventEmitter.on("changed", handler), + offChange: (target, handler) => eventEmitter.off("changed", handler) + }, + exec: function*(args, context) { + let { target } = context.environment; + + // Pipe the call to the server command. + let response = yield context.updateExec("rulers_server"); + let { visible, id } = response.data; + + if (visible) { + visibleHighlighters.add(id); + } else { + visibleHighlighters.delete(id); + } + + eventEmitter.emit("changed", { target }); + + // Toggle off the button when the page navigates because the rulers are + // removed automatically by the RulersHighlighter on the server then. + let onNavigate = () => { + visibleHighlighters.delete(id); + eventEmitter.emit("changed", { target }); + }; + target.off("will-navigate", onNavigate); + target.once("will-navigate", onNavigate); + } + }, + // The server rulers command is hidden by default, it's just used by the + // client command. + { + name: "rulers_server", + runAt: "server", + hidden: true, + returnType: "highlighterVisibility", + exec: function(args, context) { + let env = context.environment; + let { document } = env; + let id = getOuterId(env.window); + + // Calling the command again after the rulers have been shown once hides + // them. + if (highlighters.has(document)) { + let { highlighter } = highlighters.get(document); + highlighter.destroy(); + return {visible: false, id}; + } + + // Otherwise, display the rulers. + let environment = new HighlighterEnvironment(); + environment.initFromWindow(env.window); + let highlighter = new RulersHighlighter(environment); + + // Store the instance of the rulers highlighter for this document so we + // can hide it later. + highlighters.set(document, { highlighter, environment }); + + // Listen to the highlighter's destroy event which may happen if the + // window is refreshed or closed with the rulers shown. + events.once(highlighter, "destroy", () => { + if (highlighters.has(document)) { + let { environment } = highlighters.get(document); + environment.destroy(); + highlighters.delete(document); + } + }); + + highlighter.show(); + return {visible: true, id}; + } + } +]; diff --git a/devtools/shared/gcli/commands/screenshot.js b/devtools/shared/gcli/commands/screenshot.js new file mode 100644 index 000000000..e2f38b6d9 --- /dev/null +++ b/devtools/shared/gcli/commands/screenshot.js @@ -0,0 +1,579 @@ +/* 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 { Cc, Ci, Cr, Cu } = require("chrome"); +const l10n = require("gcli/l10n"); +const Services = require("Services"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); +const { getRect } = require("devtools/shared/layout/utils"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const { Task } = require("devtools/shared/task"); + +loader.lazyImporter(this, "Downloads", "resource://gre/modules/Downloads.jsm"); +loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm"); +loader.lazyImporter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm"); +loader.lazyImporter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService) + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShortName"); + +// String used as an indication to generate default file name in the following +// format: "Screen Shot yyyy-mm-dd at HH.MM.SS.png" +const FILENAME_DEFAULT_VALUE = " "; + +/* + * There are 2 commands and 1 converter here. The 2 commands are nearly + * identical except that one runs on the client and one in the server. + * + * The server command is hidden, and is designed to be called from the client + * command. + */ + +/** + * Both commands have the same initial filename parameter + */ +const filenameParam = { + name: "filename", + type: { + name: "file", + filetype: "file", + existing: "maybe", + }, + defaultValue: FILENAME_DEFAULT_VALUE, + description: l10n.lookup("screenshotFilenameDesc"), + manual: l10n.lookup("screenshotFilenameManual") +}; + +/** + * Both commands have the same set of standard optional parameters + */ +const standardParams = { + group: l10n.lookup("screenshotGroupOptions"), + params: [ + { + name: "clipboard", + type: "boolean", + description: l10n.lookup("screenshotClipboardDesc"), + manual: l10n.lookup("screenshotClipboardManual") + }, + { + name: "imgur", + type: "boolean", + description: l10n.lookup("screenshotImgurDesc"), + manual: l10n.lookup("screenshotImgurManual") + }, + { + name: "delay", + type: { name: "number", min: 0 }, + defaultValue: 0, + description: l10n.lookup("screenshotDelayDesc"), + manual: l10n.lookup("screenshotDelayManual") + }, + { + name: "dpr", + type: { name: "number", min: 0, allowFloat: true }, + defaultValue: 0, + description: l10n.lookup("screenshotDPRDesc"), + manual: l10n.lookup("screenshotDPRManual") + }, + { + name: "fullpage", + type: "boolean", + description: l10n.lookup("screenshotFullPageDesc"), + manual: l10n.lookup("screenshotFullPageManual") + }, + { + name: "selector", + type: "node", + defaultValue: null, + description: l10n.lookup("inspectNodeDesc"), + manual: l10n.lookup("inspectNodeManual") + } + ] +}; + +exports.items = [ + { + /** + * Format an 'imageSummary' (as output by the screenshot command). + * An 'imageSummary' is a simple JSON object that looks like this: + * + * { + * destinations: [ "..." ], // Required array of descriptions of the + * // locations of the result image (the command + * // can have multiple outputs) + * data: "...", // Optional Base64 encoded image data + * width:1024, height:768, // Dimensions of the image data, required + * // if data != null + * filename: "...", // If set, clicking the image will open the + * // folder containing the given file + * href: "...", // If set, clicking the image will open the + * // link in a new tab + * } + */ + item: "converter", + from: "imageSummary", + to: "dom", + exec: function(imageSummary, context) { + const document = context.document; + const root = document.createElement("div"); + + // Add a line to the result for each destination + imageSummary.destinations.forEach(destination => { + const title = document.createElement("div"); + title.textContent = destination; + root.appendChild(title); + }); + + // Add the thumbnail image + if (imageSummary.data != null) { + const image = context.document.createElement("div"); + const previewHeight = parseInt(256 * imageSummary.height / imageSummary.width); + const style = "" + + "width: 256px;" + + "height: " + previewHeight + "px;" + + "max-height: 256px;" + + "background-image: url('" + imageSummary.data + "');" + + "background-size: 256px " + previewHeight + "px;" + + "margin: 4px;" + + "display: block;"; + image.setAttribute("style", style); + root.appendChild(image); + } + + // Click handler + if (imageSummary.href || imageSummary.filename) { + root.style.cursor = "pointer"; + root.addEventListener("click", () => { + if (imageSummary.href) { + let mainWindow = context.environment.chromeWindow; + mainWindow.openUILinkIn(imageSummary.href, "tab"); + } else if (imageSummary.filename) { + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile); + file.initWithPath(imageSummary.filename); + file.reveal(); + } + }); + } + + return root; + } + }, + { + item: "command", + runAt: "client", + name: "screenshot", + description: l10n.lookup("screenshotDesc"), + manual: l10n.lookup("screenshotManual"), + returnType: "imageSummary", + buttonId: "command-button-screenshot", + buttonClass: "command-button command-button-invertable", + tooltipText: l10n.lookup("screenshotTooltipPage"), + params: [ + filenameParam, + standardParams, + ], + exec: function (args, context) { + // Re-execute the command on the server + const command = context.typed.replace(/^screenshot/, "screenshot_server"); + let capture = context.updateExec(command).then(output => { + return output.error ? Promise.reject(output.data) : output.data; + }); + + simulateCameraEffect(context.environment.chromeDocument, "shutter"); + return capture.then(saveScreenshot.bind(null, args, context)); + }, + }, + { + item: "command", + runAt: "server", + name: "screenshot_server", + hidden: true, + returnType: "imageSummary", + params: [ filenameParam, standardParams ], + exec: function (args, context) { + return captureScreenshot(args, context.environment.document); + }, + } +]; + +/** + * This function is called to simulate camera effects + */ +function simulateCameraEffect(document, effect) { + let window = document.defaultView; + if (effect === "shutter") { + const audioCamera = new window.Audio("resource://devtools/client/themes/audio/shutter.wav"); + audioCamera.play(); + } + if (effect == "flash") { + const frames = Cu.cloneInto({ opacity: [ 0, 1 ] }, window); + document.documentElement.animate(frames, 500); + } +} + +/** + * This function simply handles the --delay argument before calling + * createScreenshotData + */ +function captureScreenshot(args, document) { + if (args.delay > 0) { + return new Promise((resolve, reject) => { + document.defaultView.setTimeout(() => { + createScreenshotData(document, args).then(resolve, reject); + }, args.delay * 1000); + }); + } + else { + return createScreenshotData(document, args); + } +} + +/** + * There are several possible destinations for the screenshot, SKIP is used + * in saveScreenshot() whenever one of them is not used + */ +const SKIP = Promise.resolve(); + +/** + * Save the captured screenshot to one of several destinations. + */ +function saveScreenshot(args, context, reply) { + const fileNeeded = args.filename != FILENAME_DEFAULT_VALUE || + (!args.imgur && !args.clipboard); + + return Promise.all([ + args.clipboard ? saveToClipboard(context, reply) : SKIP, + args.imgur ? uploadToImgur(reply) : SKIP, + fileNeeded ? saveToFile(context, reply) : SKIP, + ]).then(() => reply); +} + +/** + * This does the dirty work of creating a base64 string out of an + * area of the browser window + */ +function createScreenshotData(document, args) { + const window = document.defaultView; + let left = 0; + let top = 0; + let width; + let height; + const currentX = window.scrollX; + const currentY = window.scrollY; + + let filename = getFilename(args.filename); + + if (args.fullpage) { + // Bug 961832: GCLI screenshot shows fixed position element in wrong + // position if we don't scroll to top + window.scrollTo(0,0); + width = window.innerWidth + window.scrollMaxX - window.scrollMinX; + height = window.innerHeight + window.scrollMaxY - window.scrollMinY; + filename = filename.replace(".png", "-fullpage.png"); + } + else if (args.selector) { + ({ top, left, width, height } = getRect(window, args.selector, window)); + } + else { + left = window.scrollX; + top = window.scrollY; + width = window.innerWidth; + height = window.innerHeight; + } + + // Only adjust for scrollbars when considering the full window + if (!args.selector) { + const winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + const scrollbarHeight = {}; + const scrollbarWidth = {}; + winUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight); + width -= scrollbarWidth.value; + height -= scrollbarHeight.value; + } + + const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + const ctx = canvas.getContext("2d"); + const ratio = args.dpr ? args.dpr : window.devicePixelRatio; + canvas.width = width * ratio; + canvas.height = height * ratio; + ctx.scale(ratio, ratio); + ctx.drawWindow(window, left, top, width, height, "#fff"); + const data = canvas.toDataURL("image/png", ""); + + // See comment above on bug 961832 + if (args.fullpage) { + window.scrollTo(currentX, currentY); + } + + simulateCameraEffect(document, "flash"); + + return Promise.resolve({ + destinations: [], + data: data, + height: height, + width: width, + filename: filename, + }); +} + +/** + * We may have a filename specified in args, or we might have to generate + * one. + */ +function getFilename(defaultName) { + // Create a name for the file if not present + if (defaultName != FILENAME_DEFAULT_VALUE) { + return defaultName; + } + + const date = new Date(); + let dateString = date.getFullYear() + "-" + (date.getMonth() + 1) + + "-" + date.getDate(); + dateString = dateString.split("-").map(function(part) { + if (part.length == 1) { + part = "0" + part; + } + return part; + }).join("-"); + + const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0]; + return l10n.lookupFormat("screenshotGeneratedFilename", + [ dateString, timeString ]) + ".png"; +} + +/** + * Save the image data to the clipboard. This returns a promise, so it can + * be treated exactly like imgur / file processing, but it's really sync + * for now. + */ +function saveToClipboard(context, reply) { + try { + const channel = NetUtil.newChannel({ + uri: reply.data, + loadUsingSystemPrincipal: true, + contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE + }); + const input = channel.open2(); + + const loadContext = context.environment.chromeWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsILoadContext); + + const imgTools = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools); + + const container = {}; + imgTools.decodeImageData(input, channel.contentType, container); + + const wrapped = Cc["@mozilla.org/supports-interface-pointer;1"] + .createInstance(Ci.nsISupportsInterfacePointer); + wrapped.data = container.value; + + const trans = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + trans.init(loadContext); + trans.addDataFlavor(channel.contentType); + trans.setTransferData(channel.contentType, wrapped, -1); + + const clip = Cc["@mozilla.org/widget/clipboard;1"] + .getService(Ci.nsIClipboard); + clip.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard); + + reply.destinations.push(l10n.lookup("screenshotCopied")); + } + catch (ex) { + console.error(ex); + reply.destinations.push(l10n.lookup("screenshotErrorCopying")); + } + + return Promise.resolve(); +} + +/** + * Upload screenshot data to Imgur, returning a promise of a URL (as a string) + */ +function uploadToImgur(reply) { + return new Promise((resolve, reject) => { + const xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + const fd = Cc["@mozilla.org/files/formdata;1"] + .createInstance(Ci.nsIDOMFormData); + fd.append("image", reply.data.split(",")[1]); + fd.append("type", "base64"); + fd.append("title", reply.filename); + + const postURL = Services.prefs.getCharPref("devtools.gcli.imgurUploadURL"); + const clientID = "Client-ID " + Services.prefs.getCharPref("devtools.gcli.imgurClientID"); + + xhr.open("POST", postURL); + xhr.setRequestHeader("Authorization", clientID); + xhr.send(fd); + xhr.responseType = "json"; + + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + if (xhr.status == 200) { + reply.href = xhr.response.data.link; + reply.destinations.push(l10n.lookupFormat("screenshotImgurUploaded", + [ reply.href ])); + } else { + reply.destinations.push(l10n.lookup("screenshotImgurError")); + } + + resolve(); + } + }; + }); +} + +/** + * Progress listener that forwards calls to a transfer object. + * + * This is used below in saveToFile to forward progress updates from the + * nsIWebBrowserPersist object that does the actual saving to the nsITransfer + * which just represents the operation for the Download Manager. This keeps the + * Download Manager updated on saving progress and completion, so that it gives + * visual feedback from the downloads toolbar button when the save is done. + * + * It also allows the browser window to show auth prompts if needed (should not + * be needed for saving screenshots). + * + * This code is borrowed directly from contentAreaUtils.js. + */ +function DownloadListener(win, transfer) { + this.window = win; + this.transfer = transfer; + + // For most method calls, forward to the transfer object. + for (let name in transfer) { + if (name != "QueryInterface" && + name != "onStateChange") { + this[name] = (...args) => transfer[name].apply(transfer, args); + } + } + + // Allow saveToFile to await completion for error handling + this._completedDeferred = defer(); + this.completed = this._completedDeferred.promise; +} + +DownloadListener.prototype = { + QueryInterface: function(iid) { + if (iid.equals(Ci.nsIInterfaceRequestor) || + iid.equals(Ci.nsIWebProgressListener) || + iid.equals(Ci.nsIWebProgressListener2) || + iid.equals(Ci.nsISupports)) { + return this; + } + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + getInterface: function(iid) { + if (iid.equals(Ci.nsIAuthPrompt) || + iid.equals(Ci.nsIAuthPrompt2)) { + let ww = Cc["@mozilla.org/embedcomp/window-watcher;1"] + .getService(Ci.nsIPromptFactory); + return ww.getPrompt(this.window, iid); + } + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + onStateChange: function(webProgress, request, state, status) { + // Check if the download has completed + if ((state & Ci.nsIWebProgressListener.STATE_STOP) && + (state & Ci.nsIWebProgressListener.STATE_IS_NETWORK)) { + if (status == Cr.NS_OK) { + this._completedDeferred.resolve(); + } else { + this._completedDeferred.reject(); + } + } + + this.transfer.onStateChange.apply(this.transfer, arguments); + } +}; + +/** + * Save the screenshot data to disk, returning a promise which is resolved on + * completion. + */ +var saveToFile = Task.async(function*(context, reply) { + let document = context.environment.chromeDocument; + let window = context.environment.chromeWindow; + + // Check there is a .png extension to filename + if (!reply.filename.match(/.png$/i)) { + reply.filename += ".png"; + } + + let downloadsDir = yield Downloads.getPreferredDownloadsDirectory(); + let downloadsDirExists = yield OS.File.exists(downloadsDir); + if (downloadsDirExists) { + // If filename is absolute, it will override the downloads directory and + // still be applied as expected. + reply.filename = OS.Path.join(downloadsDir, reply.filename); + } + + let sourceURI = Services.io.newURI(reply.data, null, null); + let targetFile = new FileUtils.File(reply.filename); + let targetFileURI = Services.io.newFileURI(targetFile); + + // Create download and track its progress. + // This is adapted from saveURL in contentAreaUtils.js, but simplified greatly + // and modified to allow saving to arbitrary paths on disk. Using these + // objects as opposed to just writing with OS.File allows us to tie into the + // download manager to record a download entry and to get visual feedback from + // the downloads toolbar button when the save is done. + const nsIWBP = Ci.nsIWebBrowserPersist; + const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES | + nsIWBP.PERSIST_FLAGS_FORCE_ALLOW_COOKIES | + nsIWBP.PERSIST_FLAGS_BYPASS_CACHE | + nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION; + let isPrivate = + PrivateBrowsingUtils.isContentWindowPrivate(document.defaultView); + let persist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] + .createInstance(Ci.nsIWebBrowserPersist); + persist.persistFlags = flags; + let tr = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer); + tr.init(sourceURI, + targetFileURI, + "", + null, + null, + null, + persist, + isPrivate); + let listener = new DownloadListener(window, tr); + persist.progressListener = listener; + persist.savePrivacyAwareURI(sourceURI, + null, + document.documentURIObject, + Ci.nsIHttpChannel + .REFERRER_POLICY_NO_REFERRER_WHEN_DOWNGRADE, + null, + null, + targetFileURI, + isPrivate); + + try { + // Await successful completion of the save via the listener + yield listener.completed; + reply.destinations.push(l10n.lookup("screenshotSavedToFile") + + ` "${reply.filename}"`); + } catch (ex) { + console.error(ex); + reply.destinations.push(l10n.lookup("screenshotErrorSavingToFile") + " " + + reply.filename); + } +}); diff --git a/devtools/shared/gcli/commands/security.js b/devtools/shared/gcli/commands/security.js new file mode 100644 index 000000000..eeed03c61 --- /dev/null +++ b/devtools/shared/gcli/commands/security.js @@ -0,0 +1,328 @@ +/* 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/. */ + +/** + * The Security devtool supports the following arguments: + * * Security CSP + * Provides feedback about the current CSP + * + * * Security referrer + * Provides information about the current referrer policy + */ + +"use strict"; + +const { Cc, Ci, Cu, CC } = require("chrome"); +const l10n = require("gcli/l10n"); +const CSP = Cc["@mozilla.org/cspcontext;1"].getService(Ci.nsIContentSecurityPolicy); + +const GOOD_IMG_SRC = "chrome://browser/content/gcli_sec_good.svg"; +const MOD_IMG_SRC = "chrome://browser/content/gcli_sec_moderate.svg"; +const BAD_IMG_SRC = "chrome://browser/content/gcli_sec_bad.svg"; + + +// special handling within policy +const POLICY_REPORT_ONLY = "report-only" + +// special handling of directives +const DIR_UPGRADE_INSECURE = "upgrade-insecure-requests"; +const DIR_BLOCK_ALL_MIXED_CONTENT = "block-all-mixed-content"; + +// special handling of sources +const SRC_UNSAFE_INLINE = "'unsafe-inline'"; +const SRC_UNSAFE_EVAL = "'unsafe-eval'"; + +const WILDCARD_MSG = l10n.lookup("securityCSPRemWildCard"); +const XSS_WARNING_MSG = l10n.lookup("securityCSPPotentialXSS"); +const NO_CSP_ON_PAGE_MSG = l10n.lookup("securityCSPNoCSPOnPage"); +const CONTENT_SECURITY_POLICY_MSG = l10n.lookup("securityCSPHeaderOnPage"); +const CONTENT_SECURITY_POLICY_REPORT_ONLY_MSG = l10n.lookup("securityCSPROHeaderOnPage"); + +const NEXT_URI_HEADER = l10n.lookup("securityReferrerNextURI"); +const CALCULATED_REFERRER_HEADER = l10n.lookup("securityReferrerCalculatedReferrer"); +/* The official names from the W3C Referrer Policy Draft http://www.w3.org/TR/referrer-policy/ */ +const REFERRER_POLICY_NAMES = [ "None When Downgrade (default)", "None", "Origin Only", "Origin When Cross-Origin", "Unsafe URL" ]; + +exports.items = [ + { + // --- General Security information + name: "security", + description: l10n.lookup("securityPrivacyDesc"), + manual: l10n.lookup("securityManual") + }, + { + // --- CSP specific Security information + item: "command", + runAt: "server", + name: "security csp", + description: l10n.lookup("securityCSPDesc"), + manual: l10n.lookup("securityCSPManual"), + returnType: "securityCSPInfo", + exec: function(args, context) { + + var cspJSON = context.environment.document.nodePrincipal.cspJSON; + var cspOBJ = JSON.parse(cspJSON); + + var outPolicies = []; + + var policies = cspOBJ["csp-policies"]; + + // loop over all the different policies + for (var csp in policies) { + var curPolicy = policies[csp]; + + // loop over all the directive-values within that policy + var outDirectives = []; + var outHeader = CONTENT_SECURITY_POLICY_MSG; + for (var dir in curPolicy) { + var curDir = curPolicy[dir]; + + // when iterating properties within the obj we might also + // encounter the 'report-only' flag, which is not a csp directive. + if (dir === POLICY_REPORT_ONLY) { + outHeader = curPolicy[POLICY_REPORT_ONLY] === true ? + CONTENT_SECURITY_POLICY_REPORT_ONLY_MSG : + CONTENT_SECURITY_POLICY_MSG; + continue; + } + + // loop over all the directive-sources within that directive + var outSrcs = []; + + // special case handling for the directives + // upgrade-insecure-requests and block-all-mixed-content + // which do not include any srcs + if (dir === DIR_UPGRADE_INSECURE || + dir === DIR_BLOCK_ALL_MIXED_CONTENT) { + outSrcs.push({ + icon: GOOD_IMG_SRC, + src: "", // no src + desc: "" // no description + }); + } + + for (var src in curDir) { + var curSrc = curDir[src]; + + // the default icon and descritpion of the directive-src + var outIcon = GOOD_IMG_SRC; + var outDesc = ""; + + if (curSrc.indexOf("*") > -1) { + outIcon = MOD_IMG_SRC; + outDesc = WILDCARD_MSG; + } + if (curSrc == SRC_UNSAFE_INLINE || curSrc == SRC_UNSAFE_EVAL) { + outIcon = BAD_IMG_SRC; + outDesc = XSS_WARNING_MSG; + } + outSrcs.push({ + icon: outIcon, + src: curSrc, + desc: outDesc + }); + } + // append info about that directive to the directives array + outDirectives.push({ + dirValue: dir, + dirSrc: outSrcs + }); + } + // append info about the policy to the policies array + outPolicies.push({ + header: outHeader, + directives: outDirectives + }); + } + return outPolicies; + } + }, + { + item: "converter", + from: "securityCSPInfo", + to: "view", + exec: function(cspInfo, context) { + var url = context.environment.target.url; + + if (cspInfo.length == 0) { + return context.createView({ + html: + "" + + " " + + " " + + " " + + " " + + "
" + NO_CSP_ON_PAGE_MSG + " " + url + "
"}); + } + + return context.createView({ + html: + "" + + // iterate all policies + " " + + " " + + " " + + "
${csp.header} " + url + "

" + + " " + + // >> iterate all directives + " " + + " " + + " " + + " " + + "
${dir.dirValue} " + + " " + + // >> >> iterate all srs + " " + + " " + + " " + + " " + + " " + + "
${src.src} ${src.desc}
" + + "
" + + "
", + data: { + cspinfo: cspInfo, + } + }); + } + }, + { + // --- Referrer Policy specific Security information + item: "command", + runAt: "server", + name: "security referrer", + description: l10n.lookup("securityReferrerPolicyDesc"), + manual: l10n.lookup("securityReferrerPolicyManual"), + returnType: "securityReferrerPolicyInfo", + exec: function(args, context) { + var doc = context.environment.document; + + var referrerPolicy = doc.referrerPolicy; + + var pageURI = doc.documentURIObject; + var sameDomainReferrer = ""; + var otherDomainReferrer = ""; + var downgradeReferrer = ""; + var otherDowngradeReferrer = ""; + var origin = pageURI.prePath; + + switch (referrerPolicy) { + case Ci.nsIHttpChannel.REFERRER_POLICY_NO_REFERRER: + // sends no referrer + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = "(no referrer)"; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_ORIGIN: + // only sends the origin of the referring URL + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = origin; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_ORIGIN_WHEN_XORIGIN: + // same as default, but reduced to ORIGIN when cross-origin. + sameDomainReferrer = pageURI.spec; + otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = origin; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_UNSAFE_URL: + // always sends the referrer, even on downgrade. + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = pageURI.spec; + break; + case Ci.nsIHttpChannel.REFERRER_POLICY_NO_REFERRER_WHEN_DOWNGRADE: + // default state, doesn't send referrer from https->http + sameDomainReferrer = otherDomainReferrer = pageURI.spec; + downgradeReferrer = otherDowngradeReferrer = "(no referrer)"; + break; + default: + // this is a new referrer policy which we do not know about + sameDomainReferrer + = otherDomainReferrer + = downgradeReferrer + = otherDowngradeReferrer + = "(unknown Referrer Policy)"; + break; + } + + var sameDomainUri = origin + "/*"; + + var referrerUrls = [ + // add the referrer uri 'referrer' we would send when visiting 'uri' + { + uri: pageURI.scheme+'://example.com/', + referrer: otherDomainReferrer, + description: l10n.lookup('securityReferrerPolicyOtherDomain')}, + { + uri: sameDomainUri, + referrer: sameDomainReferrer, + description: l10n.lookup('securityReferrerPolicySameDomain')} + ]; + + if (pageURI.schemeIs('https')) { + // add the referrer we would send on downgrading http->https + if (sameDomainReferrer != downgradeReferrer) { + referrerUrls.push({ + uri: "http://"+pageURI.hostPort+"/*", + referrer: downgradeReferrer, + description: + l10n.lookup('securityReferrerPolicySameDomainDowngrade') + }); + } + if (otherDomainReferrer != otherDowngradeReferrer) { + referrerUrls.push({ + uri: "http://example.com/", + referrer: otherDowngradeReferrer, + description: + l10n.lookup('securityReferrerPolicyOtherDomainDowngrade') + }); + } + } + + return { + header: l10n.lookupFormat("securityReferrerPolicyReportHeader", + [pageURI.spec]), + policyName: REFERRER_POLICY_NAMES[referrerPolicy], + urls: referrerUrls + } + } + }, + { + item: "converter", + from: "securityReferrerPolicyInfo", + to: "view", + exec: function(referrerPolicyInfo, context) { + return context.createView({ + html: + "
" + + " ${rpi.header}
" + + " ${rpi.policyName}
" + + " " + + " " + + " " + + " " + + " " + + // iterate all policies + " " + + " " + + " " + + " " + + "
" + NEXT_URI_HEADER + " " + CALCULATED_REFERRER_HEADER + "
${nextURI.description} (e.g., ${nextURI.uri}) ${nextURI.referrer}
" + + "
", + data: { + rpi: referrerPolicyInfo, + } + }); + } + } +]; diff --git a/devtools/shared/gcli/moz.build b/devtools/shared/gcli/moz.build new file mode 100644 index 000000000..c1015569f --- /dev/null +++ b/devtools/shared/gcli/moz.build @@ -0,0 +1,23 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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 += [ + 'commands', + 'source/lib/gcli', + 'source/lib/gcli/connectors', + 'source/lib/gcli/converters', + 'source/lib/gcli/commands', + 'source/lib/gcli/fields', + 'source/lib/gcli/languages', + 'source/lib/gcli/mozui', + 'source/lib/gcli/types', + 'source/lib/gcli/ui', + 'source/lib/gcli/util', +] + +DevToolsModules( + 'templater.js' +) diff --git a/devtools/shared/gcli/source/LICENSE b/devtools/shared/gcli/source/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/devtools/shared/gcli/source/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/devtools/shared/gcli/source/docs/design.md b/devtools/shared/gcli/source/docs/design.md new file mode 100644 index 000000000..5e3c0a2f3 --- /dev/null +++ b/devtools/shared/gcli/source/docs/design.md @@ -0,0 +1,102 @@ + +# The Design of GCLI + +## Design Goals + +GCLI should be: + +- primarily for technical users. +- as fast as a traditional CLI. It should be possible to put your head down, + and look at the keyboard and use GCLI 'blind' at full speed without making + mistakes. +- principled about the way it encourages people to build commands. There is + benefit from unifying the underlying concepts. +- automatically helpful. + +GCLI should not attempt to: + +- convert existing GUI users to a CLI. +- use natural language input. The closest we should get to natural language is + thinking of commands as ```verb noun --adjective```. +- gain a touch based interface. Whilst it's possible (even probable) that touch + can provide further benefits to command line users, that can wait while we + catch up with 1985. +- slavishly follow the syntax of existing commands, predictability is more + important. +- be a programming language. Shell scripts are mini programming languages but + we have JavaScript sat just next door. It's better to integrate than compete. + + +## Design Challenges + +What has changed since 1970 that might cause us to make changes to the design +of the command line? + + +### Connection limitations + +Unix pre-dates the Internet and treats almost everything as a file. Since the +Internet it could be more useful to use URIs as ways to identify sources of data. + + +### Memory limitations + +Modern computers have something like 6 orders of magnitude more memory than the +PDP-7 on which Unix was developed. Innovations like stdin/stdout and pipes are +ways to connect systems without long-term storage of the results. The ability +to store results for some time (potentially in more than one format) +significantly reduces the need for these concepts. We should make the results +of past commands addressable for re-use at a later time. + +There are a number of possible policies for eviction of items from the history. +We should investigate options other than a simple stack. + + +### Multi-tasking limitations + +Multi-tasking was a problem in 1970; the problem was getting a computer to do +many jobs on 1 core. Today the problem is getting a computer to do one job on +many cores. However we're stuck with this legacy in 2 ways. Firstly that the +default is to force everything to wait until the previous job is finished, but +more importantly that output from parallel jobs frequently collides + + $ find / -ctime 5d -print & + $ find / -uid 0 -print & + // good luck working out what came from where + + $ tail -f logfile.txt & + $ vi main.c + // have a nice time editing that file + +GCLI should allow commands to be asynchronous and will provide UI elements to +inform the user of job completion. It will also keep asynchronous command +output contained within it's own display area. + + +### Output limitations + +The PDP-7 had a teletype. There is something like 4 orders of magnitude more +information that can be displayed on a modern display than a 80x24 character +based console. We can use this flexibility to provide better help to the user +in entering their command. + +The additional display richness can also allow interaction with result output. +Command output can include links to follow-up commands, and even ask for +additional input. (e.g. "your search returned zero results do you want to try +again with a different search string") + +There is no reason why output must be static. For example, it could be +informative to see the results of an "ls" command alter given changes made by +subsequent commands. (It should be noted that there are times when historical +information is important too) + + +### Integration limitations + +In 1970, command execution meant retrieving a program from storage, and running +it. This required minimal interaction between the command line processor and +the program being run, and was good for resource constrained systems. +This lack of interaction resulted in the processing of command line arguments +being done everywhere, when the task was better suited to command line. +We should provide metadata about the commands being run, to allow the command +line to process, interpret and provide help on the input. diff --git a/devtools/shared/gcli/source/docs/developing-gcli.md b/devtools/shared/gcli/source/docs/developing-gcli.md new file mode 100644 index 000000000..113712655 --- /dev/null +++ b/devtools/shared/gcli/source/docs/developing-gcli.md @@ -0,0 +1,213 @@ + +# Developing GCLI + +## About the code + +The majority of the GCLI source is stored in the ``lib`` directory. + +The ``docs`` directory contains documentation. +The ``scripts`` directory contains RequireJS that GCLI uses. +The ``build`` directory contains files used when creating builds. +The ``mozilla`` directory contains the mercurial patch queue of patches to apply +to mozilla-central. +The ``selenium-tests`` directory contains selenium web-page integration tests. + +The source in the ``lib`` directory is split into 4 sections: + +- ``lib/demo`` contains commands used in the demo page. It is not needed except + for demo purposes. +- ``lib/test`` contains a small test harness for testing GCLI. +- ``lib/gclitest`` contains tests that run in the test harness +- ``lib/gcli`` contains the actual meat + +GCLI is split into a UI portion and a Model/Controller portion. + + +## The GCLI Model + +The heart of GCLI is a ``Requisition``, which is an AST for the input. A +``Requisition`` is a command that we'd like to execute, and we're filling out +all the inputs required to execute the command. + +A ``Requisition`` has a ``Command`` that is to be executed. Each Command has a +number of ``Parameter``s, each of which has a name and a type as detailed +above. + +As you type, your input is split into ``Argument``s, which are then assigned to +``Parameter``s using ``Assignment``s. Each ``Assignment`` has a ``Conversion`` +which stores the input argument along with the value that is was converted into +according to the type of the parameter. + +There are special assignments called ``CommandAssignment`` which the +``Requisition`` uses to link to the command to execute, and +``UnassignedAssignment``used to store arguments that do not have a parameter +to be assigned to. + + +## The GCLI UI + +There are several components of the GCLI UI. Each can have a script portion, +some template HTML and a CSS file. The template HTML is processed by +``domtemplate`` before use. + +DomTemplate is fully documented in [it's own repository] +(https://github.com/joewalker/domtemplate). + +The components are: + +- ``Inputter`` controls the input field, processing special keyboard events and + making sure that it stays in sync with the Requisition. +- ``Completer`` updates a div that is located behind the input field and used + to display completion advice and hint highlights. It is stored in + completer.js. +- ``Display`` is responsible for containing the popup hints that are displayed + above the command line. Typically Display contains a Hinter and a RequestsView + although these are not both required. Display itself is optional, and isn't + planned for use in the first release of GCLI in Firefox. +- ``Hinter`` Is used to display input hints. It shows either a Menu or an + ArgFetch component depending on the state of the Requisition +- ``Menu`` is used initially to select the command to be executed. It can act + somewhat like the Start menu on windows. +- ``ArgFetch`` Once the command to be executed has been selected, ArgFetch + shows a 'dialog' allowing the user to enter the parameters to the selected + command. +- ``RequestsView`` Contains a set of ``RequestView`` components, each of which + displays a command that has been invoked. RequestsView is a poor name, and + should better be called ReportView + +ArgFetch displays a number of Fields. There are fields for most of the Types +discussed earlier. See 'Writing Fields' above for more information. + + +## Testing + +GCLI contains 2 test suites: + +- JS level testing is run with the ``test`` command. The tests are located in + ``lib/gclitest`` and they use the test runner in ``lib/test``. This is fairly + comprehensive, however it does not do UI level testing. + If writing a new test it needs to be registered in ``lib/gclitest/index``. + For an example of how to write tests, see ``lib/gclitest/testSplit.js``. + The test functions are implemented in ``lib/test/assert``. +- Browser integration tests are included in ``browser_webconsole_gcli_*.js``, + in ``toolkit/components/console/hudservice/tests/browser``. These are + run with the rest of the Mozilla test suite. + + +## Coding Conventions + +The coding conventions for the GCLI project come from the Bespin/Skywriter and +Ace projects. They are roughly [Crockford] +(http://javascript.crockford.com/code.html) with a few exceptions and +additions: + +* ``var`` does not need to be at the top of each function, we'd like to move + to ``let`` when it's generally available, and ``let`` doesn't have the same + semantic twists as ``var``. + +* Strings are generally enclosed in single quotes. + +* ``eval`` is to be avoided, but we don't declare it evil. + +The [Google JavaScript conventions] +(https://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml) are +more detailed, we tend to deviate in: + +* Custom exceptions: We generally just use ``throw new Error('message');`` + +* Multi-level prototype hierarchies: Allowed; we don't have ``goog.inherits()`` + +* ``else`` begins on a line by itself: + + if (thing) { + doThis(); + } + else { + doThat(); + } + + +## Startup + +Internally GCLI modules have ``startup()``/``shutdown()`` functions which are +called on module init from the top level ``index.js`` of that 'package'. + +In order to initialize a package all that is needed is to require the package +index (e.g. ``require('package/index')``). + +The ``shutdown()`` function was useful when GCLI was used in Bespin as part of +dynamic registration/de-registration. It is not known if this feature will be +useful in the future. So it has not been entirely removed, it may be at some +future date. + + +## Running the Unit Tests + +Start the GCLI static server: + + cd path/to/gcli + node gcli.js + +Now point your browser to http://localhost:9999/localtest.html. When the page +loads the tests will be automatically run outputting to the console, or you can +enter the ``test`` command to run the unit tests. + + +## Contributing Code + +Please could you do the following to help minimize the amount of rework that we +do: + +1. Check the unit tests run correctly (see **Running the Unit Tests** above) +2. Check the code follows the style guide. At a minimum it should look like the + code around it. For more detailed notes, see **Coding Conventions** above +3. Help me review your work by using good commit comments. Which means 2 things + * Well formatted messages, i.e. 50 char summary including bug tag, followed + by a blank line followed by a more in-depth message wrapped to 72 chars + per line. This is basically the format used by the Linux Kernel. See the + [commit log](https://github.com/joewalker/gcli/commits/master) for + examples. The be extra helpful, please use the "shortdesc-BUGNUM: " if + possible which also helps in reviews. + * Commit your changes as a story. Make it easy for me to understand the + changes that you've made. +4. Sign your work. To improve tracking of who did what, we follow the sign-off + procedure used in the Linux Kernel. + The sign-off is a simple line at the end of the explanation for the + patch, which certifies that you wrote it or otherwise have the right to + pass it on as an open-source patch. The rules are pretty simple: if you + can certify the below: + + Developer's Certificate of Origin 1.1 + + By making a contribution to this project, I certify that: + + (a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + + (b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + + (c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + + (d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. + + then you just add a line saying + + Signed-off-by: Random J Developer + + using your real name (sorry, no pseudonyms or anonymous contributions.) + +Thanks for wanting to contribute code. + diff --git a/devtools/shared/gcli/source/docs/index.md b/devtools/shared/gcli/source/docs/index.md new file mode 100644 index 000000000..dce112e6d --- /dev/null +++ b/devtools/shared/gcli/source/docs/index.md @@ -0,0 +1,150 @@ + +# About GCLI + +## GCLI is a Graphical Command Line Interpreter. + +GCLI is a command line for modern computers. When command lines were invented, +computers were resource-limited, disconnected systems with slow multi-tasking +and poor displays. The design of the Unix CLI made sense in 1970, but over 40 +years on, considering the pace of change, there are many improvements we can +make. + +CLIs generally suffer from poor discoverability; It's hard when faced with a +blank command line to work out what to do. As a result the majority of programs +today use purely graphical user interfaces, however in doing so, they lose some +of the benefits of CLIs. CLIs are still used because generally, in the hands of +a skilled user they are faster, and have a wider range of available options. + +GCLI attempts to get the best of the GUI world and the CLI world to produce +something that is both easy to use and learn as well as fast and powerful. + +GCLI has a type system to help ensure that users are inputting valid commands +and to enable us to provide sensible context sensitive help. GCLI provides +integration with JavaScript rather than being an alternative (like CoffeeScript). + + +## History + +GCLI was born as part of the +[Bespin](http://ajaxian.com/archives/canvas-for-a-text-editor) project and was +[discussed at the time](http://j.mp/bespin-cli). The command line component +survived the rename of Bepsin to Skywriter and the merger with Ace, got a name +of it's own (Cockpit) which didn't last long before the project was named GCLI. +It is now being used in the Firefox's web console where it doesn't have a +separate identity but it's still called GCLI outside of Firefox. It is also +used in [Eclipse Orion](http://www.eclipse.org/orion/). + + +## Environments + +GCLI is designed to work in a number of environments: + +1. As a component of Firefox developer tools. +2. As an adjunct to Orion/Ace and other online editors. +3. As a plugin to any web-page wishing to provide its own set of commands. +4. As part of a standalone web browser extension with it's own set of commands. + + +## Related Pages + +Other sources of GCLI documentation: + +- [Writing Commands](writing-commands.md) +- [Writing Types](writing-types.md) +- [Developing GCLI](developing-gcli.md) +- [Writing Tests](writing-tests.md) / [Running Tests](running-tests.md) +- [The Design of GCLI](design.md) +- Source + - The most up-to-date source is in [this Github repository](https://github.com/joewalker/gcli/). + - When a feature is 'done' it's merged into the [Mozilla clone](https://github.com/mozilla/gcli/). + - From which it flows into [Mozilla Central](https://hg.mozilla.org/mozilla-central/file/tip/devtools/client/commandline). +- [Demo of GCLI](http://mozilla.github.com/gcli/) with an arbitrary set of demo + commands +- Other Documentation + - [Embedding docs](https://github.com/mozilla/gcli/blob/master/docs/index.md) + - [Status page](http://mozilla.github.com/devtools/2011/status.html#gcli) + + +## Accessibility + +GCLI uses ARIA roles to guide a screen-reader as to the important sections to +voice. We welcome [feedback on how these roles are implemented](https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Developer+Tools:+Graphic+Commandline+and+Toolbar&rep_platform=All&op_sys=All&short_desc=GCLI). + +The command line uses TAB as a method of completing current input, this +prevents use of TAB for keyboard navigation. Instead of using TAB to move to +the next field you can use F6. In addition to F6, ALT+TAB, CTRL+TAB, META+TAB +make an attempt to move the focus on. How well this works depends on your +OS/browser combination. + + +## Embedding GCLI + +There are 3 basic steps in using GCLI in your system. + +1. Import a GCLI JavaScript file. + For serious use of GCLI you are likely to be creating a custom build (see + below) however if you just want to have a quick play, you can use + ``gcli-uncompressed.js`` from [the gh-pages branch of GCLI] + (https://github.com/mozilla/gcli/tree/gh-pages) + Just place the following wherever you place your script files. + + + +2. Having imported GCLI, we need to tell it where to display. The simplest + method is to include an elements with the id of ``gcli-input`` and + ``gcli-display``. + + +
+ +3. Tell GCLI what commands to make available. See the sections on Writing + Commands, Writing Types and Writing Fields for more information. + + GCLI uses the CommonJS AMD format for it's files, so a 'require' statement + is needed to get started. + + require([ 'gcli/index' ], function(gcli) { + gcli.add(...); // Register custom commands/types/etc + gcli.createTerminal(); // Create a user interface + }); + + The createTerminal() function takes an ``options`` objects which allows + customization. At the current time the documentation of these object is left + to the source. + + +## Backwards Compatibility + +The goals of the GCLI project are: + +- Aim for very good backwards compatibility with code required from an + 'index' module. This means we will not break code without a cycle of + deprecation warnings. + + There are currently 3 'index' modules: + - gcli/index (all you need to get started with GCLI) + - demo/index (a number of demo commands) + - gclitest/index (GCLI test suite) + + Code from these modules uses the module pattern to prevent access to internal + functions, so in essence, if you can get to it from an index module, you + should be ok. + +- We try to avoid needless change to other modules, however we don't make any + promises, and don't provide a deprecation cycle. + + Code from other modules uses classes rather than modules, so member variables + are exposed. Many classes mark private members using the `_underscorePrefix` + pattern. Particular care should be taken if access is needed to a private + member. + + +## Creating Custom Builds + +GCLI uses [DryIce](https://github.com/mozilla/dryice) to create custom builds. +If dryice is installed (``npm install .``) then you can create a built +version of GCLI simply using ``node gcli.js standard``. DryIce supplies a custom +module loader to replace RequireJS for built applications. + +The build will be output to the ``built`` directory. The directory will be +created if it doesn't exist. diff --git a/devtools/shared/gcli/source/docs/running-tests.md b/devtools/shared/gcli/source/docs/running-tests.md new file mode 100644 index 000000000..378c40837 --- /dev/null +++ b/devtools/shared/gcli/source/docs/running-tests.md @@ -0,0 +1,60 @@ + +# Running Tests + +GCLI has a test suite that can be run in a number of different environments. +Some of the tests don't work in all environments. These should be automatically +skipped when not applicable. + + +## Web + +Running a limited set of test from the web is the easiest. Simply load +'localtest.html' and the unit tests should be run automatically, with results +displayed on the console. Tests can be re-run using the 'test' command. + +It also creates a function 'testCommands()' to be run at a JS prompt, which +enables the test commands for debugging purposes. + + +## Firefox + +GCLI's test suite integrates with Mochitest and runs automatically on each test +run. Dryice packages the tests to format them for the Firefox build system. + +For more information about running Mochitest on Firefox (including GCLI) see +[the MDN, Mochitest docs](https://developer.mozilla.org/en/Mochitest) + + +# Node + +Running the test suite under node can be done as follows: + + $ node gcli.js test + +Or, using the `test` command: + + $ node gcli.js + Serving GCLI to http://localhost:9999/ + This is also a limited GCLI prompt. + Type 'help' for a list of commands, CTRL+C twice to exit: + : test + + testCli: Pass (funcs=9, checks=208) + testCompletion: Pass (funcs=1, checks=139) + testExec: Pass (funcs=1, checks=133) + testHistory: Pass (funcs=3, checks=13) + .... + + Summary: Pass (951 checks) + + +# Travis CI + +GCLI check-ins are automatically tested by [Travis CI](https://travis-ci.org/joewalker/gcli). + + +# Test Case Generation + +GCLI can generate test cases automagically. Load ```localtest.html```, type a +command to be tested into GCLI, and the press F2. GCLI will output to the +console a template test case for the entered command. diff --git a/devtools/shared/gcli/source/docs/writing-commands.md b/devtools/shared/gcli/source/docs/writing-commands.md new file mode 100644 index 000000000..e73050279 --- /dev/null +++ b/devtools/shared/gcli/source/docs/writing-commands.md @@ -0,0 +1,757 @@ + +# Writing Commands + +## Basics + +GCLI has opinions about how commands should be written, and it encourages you +to do The Right Thing. The opinions are based on helping users convert their +intentions to commands and commands to what's actually going to happen. + +- Related commands should be sub-commands of a parent command. One of the goals + of GCLI is to support a large number of commands without things becoming + confusing, this will require some sort of namespacing or there will be + many people wanting to implement the ``add`` command. This style of + writing commands has become common place in Unix as the number of commands + has gone up. + The ```context``` command allows users to focus on a parent command, promoting + its sub-commands above others. + +- Each command should do exactly and only one thing. An example of a Unix + command that breaks this principle is the ``tar`` command + + $ tar -zcf foo.tar.gz . + $ tar -zxf foo.tar.gz . + + These 2 commands do exactly opposite things. Many a file has died as a result + of a x/c typo. In GCLI this would be better expressed: + + $ tar create foo.tar.gz -z . + $ tar extract foo.tar.gz -z . + + There may be commands (like tar) which have enough history behind them + that we shouldn't force everyone to re-learn a new syntax. The can be achieved + by having a single string parameter and parsing the input in the command) + +- Avoid errors. We try to avoid the user having to start again with a command + due to some problem. The majority of problems are simple typos which we can + catch using command metadata, but there are 2 things command authors can do + to prevent breakage. + + - Where possible avoid the need to validate command line parameters in the + exec function. This can be done by good parameter design (see 'do exactly + and only one thing' above) + + - If there is an obvious fix for an unpredictable problem, offer the + solution in the command output. So rather than use request.error (see + Request Object below) output some HTML which contains a link to a fixed + command line. + +Currently these concepts are not enforced at a code level, but they could be in +the future. + + +## How commands work + +This is how to create a basic ``greet`` command: + + gcli.addItems([{ + name: 'greet', + description: 'Show a greeting', + params: [ + { + name: 'name', + type: 'string', + description: 'The name to greet' + } + ], + returnType: 'string', + exec: function(args, context) { + return 'Hello, ' + args.name; + } + }]); + +This command is used as follows: + + : greet Joe + Hello, Joe + +Some terminology that isn't always obvious: a function has 'parameters', and +when you call a function, you pass 'arguments' to it. + + +## Internationalization (i18n) + +There are several ways that GCLI commands can be localized. The best method +depends on what context you are writing your command for. + +### Firefox Embedding + +GCLI supports Mozilla style localization. To add a command that will only ever +be used embedded in Firefox, this is the way to go. Your strings should be +stored in ``toolkit/locales/en-US/chrome/global/devtools/gclicommands.properties``, +And you should access them using ``let l10n = require("gcli/l10n")`` and then +``l10n.lookup(...)`` or ``l10n.lookupFormat()`` + +For examples of existing commands, take a look in +``devtools/client/webconsole/GcliCommands.jsm``, which contains most of the +current GCLI commands. If you will be adding a number of new commands, then +consider starting a new JSM. + +Your command will then look something like this: + + gcli.addItems([{ + name: 'greet', + description: gcli.lookup("greetDesc") + ... + }]); + +### Web Commands + +There are 2 ways to provide translated strings for web use. The first is to +supply the translated strings in the description: + + gcli.addItems([{ + name: 'greet', + description: { + 'root': 'Show a greeting', + 'fr-fr': 'Afficher un message d'accueil', + 'de-de': 'Zeige einen Gruß', + 'gk-gk': 'Εμφάνιση ένα χαιρετισμό', + ... + } + ... + }]); + +Each description should contain at least a 'root' entry which is the +default if no better match is found. This method has the benefit of being +compact and simple, however it has the significant drawback of being wasteful +of memory and bandwidth to transmit and store a significant number of strings, +the majority of which will never be used. + +More efficient is to supply a lookup key and ask GCLI to lookup the key from an +appropriate localized strings file: + + gcli.addItems([{ + name: 'greet', + description: { 'key': 'demoGreetingDesc' } + ... + }]); + +For web usage, the central store of localized strings is +``lib/gcli/nls/strings.js``. Other string files can be added using the +``l10n.registerStringsSource(...)`` function. + +This method can be used both in Firefox and on the Web (see the help command +for an example). However this method has the drawback that it will not work +with DryIce built files until we fix bug 683844. + + +## Default argument values + +The ``greet`` command requires the entry of the ``name`` parameter. This +parameter can be made optional with the addition of a ``defaultValue`` to the +parameter: + + gcli.addItems([{ + name: 'greet', + description: 'Show a message to someone', + params: [ + { + name: 'name', + type: 'string', + description: 'The name to greet', + defaultValue: 'World!' + } + ], + returnType: 'string', + exec: function(args, context) { + return "Hello, " + args.name; + } + }]); + +Now we can also use the ``greet`` command as follows: + + : greet + Hello, World! + + +## Positional vs. named arguments + +Arguments can be entered either positionally or as named arguments. Generally +users will prefer to type the positional version, however the named alternative +can be more self documenting. + +For example, we can also invoke the greet command as follows: + + : greet --name Joe + Hello, Joe + + +## Short argument names + +GCLI allows you to specify a 'short' character for any parameter: + + gcli.addItems([{ + name: 'greet', + params: [ + { + name: 'name', + short: 'n', + type: 'string', + ... + } + ], + ... + }]); + +This is used as follows: + + : greet -n Fred + Hello, Fred + +Currently GCLI does not allow short parameter merging (i.e. ```ls -la```) +however this is planned. + + +## Parameter types + +Initially the available types are: + +- string +- boolean +- number +- selection +- delegate +- date +- array +- file +- node +- nodelist +- resource +- command +- setting + +This list can be extended. See [Writing Types](writing-types.md) on types for +more information. + +The following examples assume the following definition of the ```greet``` +command: + + gcli.addItems([{ + name: 'greet', + params: [ + { name: 'name', type: 'string' }, + { name: 'repeat', type: 'number' } + ], + ... + }]); + +Parameters can be specified either with named arguments: + + : greet --name Joe --repeat 2 + +And sometimes positionally: + + : greet Joe 2 + +Parameters can be specified positionally if they are considered 'important'. +Unimportant parameters must be specified with a named argument. + +Named arguments can be specified anywhere on the command line (after the +command itself) however positional arguments must be in order. So +these examples are the same: + + : greet --name Joe --repeat 2 + : greet --repeat 2 --name Joe + +However (obviously) these are not the same: + + : greet Joe 2 + : greet 2 Joe + +(The second would be an error because 'Joe' is not a number). + +Named arguments are assigned first, then the remaining arguments are assigned +to the remaining parameters. So the following is valid and unambiguous: + + : greet 2 --name Joe + +Positional parameters quickly become unwieldy with long parameter lists so we +recommend only having 2 or 3 important parameters. GCLI provides hints for +important parameters more obviously than unimportant ones. + +Parameters are 'important' if they are not in a parameter group. The easiest way +to achieve this is to use the ```option: true``` property. + +For example, using: + + gcli.addItems([{ + name: 'greet', + params: [ + { name: 'name', type: 'string' }, + { name: 'repeat', type: 'number', option: true, defaultValue: 1 } + ], + ... + }]); + +Would mean that this is an error + + : greet Joe 2 + +You would instead need to do the following: + + : greet Joe --repeat 2 + +For more on parameter groups, see below. + +In addition to being 'important' and 'unimportant' parameters can also be +optional. If is possible to be important and optional, but it is not possible +to be unimportant and non-optional. + +Parameters are optional if they either: +- Have a ```defaultValue``` property +- Are of ```type=boolean``` (boolean arguments automatically default to being false) + +There is currently no way to make parameters mutually exclusive. + + +## Selection types + +Parameters can have a type of ``selection``. For example: + + gcli.addItems([{ + name: 'greet', + params: [ + { name: 'name', ... }, + { + name: 'lang', + description: 'In which language should we greet', + type: { name: 'selection', data: [ 'en', 'fr', 'de', 'es', 'gk' ] }, + defaultValue: 'en' + } + ], + ... + }]); + +GCLI will enforce that the value of ``arg.lang`` was one of the values +specified. Alternatively ``data`` can be a function which returns an array of +strings. + +The ``data`` property is useful when the underlying type is a string but it +doesn't work when the underlying type is something else. For this use the +``lookup`` property as follows: + + type: { + name: 'selection', + lookup: { + 'en': Locale.EN, + 'fr': Locale.FR, + ... + } + }, + +Similarly, ``lookup`` can be a function returning the data of this type. + + +## Number types + +Number types are mostly self explanatory, they have one special property which +is the ability to specify upper and lower bounds for the number: + + gcli.addItems([{ + name: 'volume', + params: [ + { + name: 'vol', + description: 'How loud should we go', + type: { name: 'number', min: 0, max: 11 } + } + ], + ... + }]); + +You can also specify a ``step`` property which specifies by what amount we +should increment and decrement the values. The ``min``, ``max``, and ``step`` +properties are used by the command line when up and down are pressed and in +the input type of a dialog generated from this command. + + +## Delegate types + +Delegate types are needed when the type of some parameter depends on the type +of another parameter. For example: + + : set height 100 + : set name "Joe Walker" + +We can achieve this as follows: + + gcli.addItems([{ + name: 'set', + params: [ + { + name: 'setting', + type: { name: 'selection', values: [ 'height', 'name' ] } + }, + { + name: 'value', + type: { + name: 'delegate', + delegateType: function() { ... } + } + } + ], + ... + }]); + +Several details are left out of this example, like how the delegateType() +function knows what the current setting is. See the ``pref`` command for an +example. + + +## Array types + +Parameters can have a type of ``array``. For example: + + gcli.addItems([{ + name: 'greet', + params: [ + { + name: 'names', + type: { name: 'array', subtype: 'string' }, + description: 'The names to greet', + defaultValue: [ 'World!' ] + } + ], + ... + exec: function(args, context) { + return "Hello, " + args.names.join(', ') + '.'; + } + }]); + +This would be used as follows: + + : greet Fred Jim Shiela + Hello, Fred, Jim, Shiela. + +Or using named arguments: + + : greet --names Fred --names Jim --names Shiela + Hello, Fred, Jim, Shiela. + +There can only be one ungrouped parameter with an array type, and it must be +at the end of the list of parameters (i.e. just before any parameter groups). +This avoids confusion as to which parameter an argument should be assigned. + + +## Sub-commands + +It is common for commands to be groups into those with similar functionality. +Examples include virtually all VCS commands, ``apt-get``, etc. There are many +examples of commands that should be structured as in a sub-command style - +``tar`` being the obvious example, but others include ``crontab``. + +Groups of commands are specified with the top level command not having an +exec function: + + gcli.addItems([ + { + name: 'tar', + description: 'Commands to manipulate archives', + }, + { + name: 'tar create', + description: 'Create a new archive', + exec: function(args, context) { ... }, + ... + }, + { + name: 'tar extract', + description: 'Extract from an archive', + exec: function(args, context) { ... }, + ... + } + ]); + + +## Parameter groups + +Parameters can be grouped into sections. + +There are 3 ways to assign a parameter to a group. + +The simplest uses ```option: true``` to put a parameter into the default +'Options' group: + + gcli.addItems([{ + name: 'greet', + params: [ + { name: 'repeat', type: 'number', option: true } + ], + ... + }]); + +The ```option``` property can also take a string to use an alternative parameter +group: + + gcli.addItems([{ + name: 'greet', + params: [ + { name: 'repeat', type: 'number', option: 'Advanced' } + ], + ... + }]); + +An example of how this can be useful is 'git' which categorizes parameters into +'porcelain' and 'plumbing'. + +Finally, parameters can be grouped together as follows: + + gcli.addItems([{ + name: 'greet', + params: [ + { name: 'name', type: 'string', description: 'The name to greet' }, + { + group: 'Advanced Options', + params: [ + { name: 'repeat', type: 'number', defaultValue: 1 }, + { name: 'debug', type: 'boolean' } + ] + } + ], + ... + }]); + +This could be used as follows: + + : greet Joe --repeat 2 --debug + About to send greeting + Hello, Joe + Hello, Joe + Done! + +Parameter groups must come after non-grouped parameters because non-grouped +parameters can be assigned positionally, so their index is important. We don't +want 'holes' in the order caused by parameter groups. + + +## Command metadata + +Each command should have the following properties: + +- A string ``name``. +- A short ``description`` string. Generally no more than 20 characters without + a terminating period/fullstop. +- A function to ``exec``ute. (Optional for the parent containing sub-commands) + See below for more details. + +And optionally the following extra properties: + +- A declaration of the accepted ``params``. +- A ``hidden`` property to stop the command showing up in requests for help. +- A ``context`` property which defines the scope of the function that we're + calling. Rather than simply call ``exec()``, we do ``exec.call(context)``. +- A ``manual`` property which allows a fuller description of the purpose of the + command. +- A ``returnType`` specifying how we should handle the value returned from the + exec function. + +The ``params`` property is an array of objects, one for each parameter. Each +parameter object should have the following 3 properties: + +- A string ``name``. +- A short string ``description`` as for the command. +- A ``type`` which refers to an existing Type (see Writing Types). + +Optionally each parameter can have these properties: + +- A ``defaultValue`` (which should be in the type specified in ``type``). + The defaultValue will be used when there is no argument supplied for this + parameter on the command line. + If the parameter has a ``defaultValue``, other than ``undefined`` then the + parameter is optional, and if unspecified on the command line, the matching + argument will have this value when the function is called. + If ``defaultValue`` is missing, or if it is set to ``undefined``, then the + system will ensure that a value is provided before anything is executed. + There are 2 special cases: + - If the type is ``selection``, then defaultValue must not be undefined. + The defaultValue must either be ``null`` (meaning that a value must be + supplied by the user) or one of the selection values. + - If the type is ``boolean``, then ``defaultValue:false`` is implied and + can't be changed. Boolean toggles are assumed to be off by default, and + should be named to match. +- A ``manual`` property for parameters is exactly analogous to the ``manual`` + property for commands - descriptive text that is longer than than 20 + characters. + + +## The Command Function (exec) + +The parameters to the exec function are designed to be useful when you have a +large number of parameters, and to give direct access to the environment (if +used). + + gcli.addItems([{ + name: 'echo', + description: 'The message to display.', + params: [ + { + name: 'message', + type: 'string', + description: 'The message to display.' + } + ], + returnType: 'string', + exec: function(args, context) { + return args.message; + } + }]); + +The ``args`` object contains the values specified on the params section and +provided on the command line. In this example it would contain the message for +display as ``args.message``. + +The ``context`` object has the following signature: + + { + environment: ..., // environment object passed to createTerminal() + exec: ..., // function to execute a command + update: ..., // function to alter the text of the input area + createView: ..., // function to help creating rich output + defer: ..., // function to create a deferred promise + } + +The ``environment`` object is opaque to GCLI. It can be used for providing +arbitrary data to your commands about their environment. It is most useful +when more than one command line exists on a page with similar commands in both +which should act in their own ways. +An example use for ``environment`` would be a page with several tabs, each +containing an editor with a command line. Commands executed in those editors +should apply to the relevant editor. +The ``environment`` object is passed to GCLI at startup (probably in the +``createTerminal()`` function). + +The ``document`` object is also passed to GCLI at startup. In some environments +(e.g. embedded in Firefox) there is no global ``document``. This object +provides a way to create DOM nodes. + +``defer()`` allows commands to execute asynchronously. + + +## Returning data + +The command meta-data specifies the type of data returned by the command using +the ``returnValue`` setting. + +``returnValue`` processing is currently functioning, but incomplete, and being +tracked in [Bug 657595](http://bugzil.la/657595). Currently you should specify +a ``returnType`` of ``string`` or ``html``. If using HTML, you can return +either an HTML string or a DOM node. + +In the future, JSON will be strongly encouraged as the return type, with some +formatting functions to convert the JSON to HTML. + +Asynchronous output is achieved using a promise created from the ``context`` +parameter: ``context.defer()``. + +Some examples of this is practice: + + { returnType: "string" } + ... + return "example"; + +GCLI interprets the output as a plain string. It will be escaped before display +and available as input to other commands as a plain string. + + { returnType: "html" } + ... + return "

Hello

"; + +GCLI will interpret this as HTML, and parse it for display. + + { returnType: "dom" } + ... + return util.createElement(context.document, 'div'); + +``util.createElement`` is a utility to ensure use of the XHTML namespace in XUL +and other XML documents. In an HTML document it's functionally equivalent to +``context.document.createElement('div')``. If your command is likely to be used +in Firefox or another XML environment, you should use it. You can import it +with ``var util = require('util/util');``. + +GCLI will use the returned HTML element as returned. See notes on ``context`` +above. + + { returnType: "number" } + ... + return 42; + +GCLI will display the element in a similar way to a string, but it the value +will be available to future commands as a number. + + { returnType: "date" } + ... + return new Date(); + + { returnType: "file" } + ... + return new File(); + +Both these examples return data as a given type, for which a converter will +be required before the value can be displayed. The type system is likely to +change before this is finalized. Please contact the author for more +information. + + { returnType: "string" } + ... + var deferred = context.defer(); + setTimeout(function() { + deferred.resolve("hello"); + }, 500); + return deferred.promise; + +Errors can be signaled by throwing an exception. GCLI will display the message +property (or the toString() value if there is no message property). (However +see *3 principles for writing commands* above for ways to avoid doing this). + + +## Specifying Types + +Types are generally specified by a simple string, e.g. ``'string'``. For most +types this is enough detail. There are a number of exceptions: + +* Array types. We declare a parameter to be an array of things using ``[]``, + for example: ``number[]``. +* Selection types. There are 3 ways to specify the options in a selection: + * Using a lookup map + + type: { + name: 'selection', + lookup: { one:1, two:2, three:3 } + } + + (The boolean type is effectively just a selection that uses + ``lookup:{ 'true': true, 'false': false }``) + + * Using given strings + + type: { + name: 'selection', + data: [ 'left', 'center', 'right' ] + } + + * Using named objects, (objects with a ``name`` property) + + type: { + name: 'selection', + data: [ + { name: 'Google', url: 'http://www.google.com/' }, + { name: 'Microsoft', url: 'http://www.microsoft.com/' }, + { name: 'Yahoo', url: 'http://www.yahoo.com/' } + ] + } + +* Delegate type. It is generally best to inherit from Delegate in order to + provide a customization of this type. See settingValue for an example. + +See below for more information. diff --git a/devtools/shared/gcli/source/docs/writing-tests.md b/devtools/shared/gcli/source/docs/writing-tests.md new file mode 100644 index 000000000..4d42142cd --- /dev/null +++ b/devtools/shared/gcli/source/docs/writing-tests.md @@ -0,0 +1,20 @@ + +# Writing Tests + +There are several sources of GCLI tests and several environments in which they +are run. + +The majority of GCLI tests are stored in +[this repository](https://github.com/joewalker/gcli/) in files named like +```./lib/gclitest/test*.js```. These tests run in Firefox, Chrome, Opera, +and NodeJS/JsDom + +See [Running Tests](running-tests.md) for further details. + +GCLI comes with a generic unit test harness (in ```./lib/test/```) and a +set of helpers for creating GCLI tests (in ```./lib/gclitest/helpers.js```). + +# GCLI tests in Firefox + +The build process converts the GCLI tests to run under Mochitest inside the +Firefox unit tests. It also adds some diff --git a/devtools/shared/gcli/source/docs/writing-types.md b/devtools/shared/gcli/source/docs/writing-types.md new file mode 100644 index 000000000..ed2f75438 --- /dev/null +++ b/devtools/shared/gcli/source/docs/writing-types.md @@ -0,0 +1,106 @@ + +# Writing Types + +Commands are a fundamental building block because they are what the users +directly interacts with, however they are built on ``Type``s. There are a +number of built in types: + +* string. This is a JavaScript string +* number. A JavaScript number +* boolean. A JavaScript boolean +* selection. This is an selection from a number of alternatives +* delegate. This type could change depending on other factors, but is well + defined when one of the conversion routines is called. + +There are a number of additional types defined by Pilot and GCLI as +extensions to the ``selection`` and ``delegate`` types + +* setting. One of the defined settings +* settingValue. A value that can be applied to an associated setting. +* command. One of the defined commands + +Most of our types are 'static' e.g. there is only one type of 'string', however +some types like 'selection' and 'delegate' are customizable. + +All types must inherit from Type and have the following methods: + + /** + * Convert the given value to a string representation. + * Where possible, there should be round-tripping between values and their + * string representations. + */ + stringify: function(value) { return 'string version of value'; }, + + /** + * Convert the given str to an instance of this type. + * Where possible, there should be round-tripping between values and their + * string representations. + * @return Conversion + */ + parse: function(str) { return new Conversion(...); }, + + /** + * The plug-in system, and other things need to know what this type is + * called. The name alone is not enough to fully specify a type. Types like + * 'selection' and 'delegate' need extra data, however this function returns + * only the name, not the extra data. + *

In old bespin, equality was based on the name. This may turn out to be + * important in Ace too. + */ + name: 'example', + +In addition, defining the following function can be helpful, although Type +contains default implementations: + +* nudge(value, by) + +Type, Conversion and Status are all declared by commands.js. + +The values produced by the parse function can be of any type, but if you are +producing your own, you are strongly encouraged to include properties called +``name`` and ``description`` where it makes sense. There are a number of +places in GCLI where the UI will be able to provide better help to users if +your values include these properties. + + +# Writing Fields + +Fields are visual representations of types. For simple types like string it is +enough to use ````, however more complex types we may wish to +provide a custom widget to allow the user to enter values of the given type. + +This is an example of a very simple new password field type: + + function PasswordField(doc) { + this.doc = doc; + } + + PasswordField.prototype = Object.create(Field.prototype); + + PasswordField.prototype.createElement = function(assignment) { + this.assignment = assignment; + this.input = dom.createElement(this.doc, 'input'); + this.input.type = 'password'; + this.input.value = assignment.arg ? assignment.arg.text : ''; + + this.onKeyup = function() { + this.assignment.setValue(this.input.value); + }.bind(this); + this.input.addEventListener('keyup', this.onKeyup, false); + + this.onChange = function() { + this.input.value = this.assignment.arg.text; + }; + this.assignment.onAssignmentChange.add(this.onChange, this); + + return this.input; + }; + + PasswordField.prototype.destroy = function() { + this.input.removeEventListener('keyup', this.onKeyup, false); + this.assignment.onAssignmentChange.remove(this.onChange, this); + }; + + PasswordField.claim = function(type) { + return type.name === 'password' ? Field.claim.MATCH : Field.claim.NO_MATCH; + }; diff --git a/devtools/shared/gcli/source/lib/gcli/cli.js b/devtools/shared/gcli/source/lib/gcli/cli.js new file mode 100644 index 000000000..4b7c115e2 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/cli.js @@ -0,0 +1,2209 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('./util/util'); +var host = require('./util/host'); +var l10n = require('./util/l10n'); + +var view = require('./ui/view'); +var Parameter = require('./commands/commands').Parameter; +var CommandOutputManager = require('./commands/commands').CommandOutputManager; + +var Status = require('./types/types').Status; +var Conversion = require('./types/types').Conversion; +var commandModule = require('./types/command'); +var selectionModule = require('./types/selection'); + +var Argument = require('./types/types').Argument; +var ArrayArgument = require('./types/types').ArrayArgument; +var NamedArgument = require('./types/types').NamedArgument; +var TrueNamedArgument = require('./types/types').TrueNamedArgument; +var MergedArgument = require('./types/types').MergedArgument; +var ScriptArgument = require('./types/types').ScriptArgument; + +var RESOLVED = Promise.resolve(undefined); + +// Helper to produce a `deferred` object +// using DOM Promise +function defer() { + let resolve, reject; + let p = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { + promise: p, + resolve: resolve, + reject: reject + }; +} + +/** + * This is a list of the known command line components to enable certain + * privileged commands to alter parts of a running command line. It is an array + * of objects shaped like: + * { conversionContext:..., executionContext:..., mapping:... } + * So lookup is O(n) where 'n' is the number of command lines. + */ +var instances = []; + +/** + * An indexOf that looks-up both types of context + */ +function instanceIndex(context) { + for (var i = 0; i < instances.length; i++) { + var instance = instances[i]; + if (instance.conversionContext === context || + instance.executionContext === context) { + return i; + } + } + return -1; +} + +/** + * findInstance gets access to a Terminal object given a conversionContext or + * an executionContext (it doesn't have to be a terminal object, just whatever + * was passed into addMapping() + */ +exports.getMapping = function(context) { + var index = instanceIndex(context); + if (index === -1) { + console.log('Missing mapping for context: ', context); + console.log('Known contexts: ', instances); + throw new Error('Missing mapping for context'); + } + return instances[index].mapping; +}; + +/** + * Add a requisition context->terminal mapping + */ +var addMapping = function(requisition) { + if (instanceIndex(requisition.conversionContext) !== -1) { + throw new Error('Remote existing mapping before adding a new one'); + } + + instances.push({ + conversionContext: requisition.conversionContext, + executionContext: requisition.executionContext, + mapping: { requisition: requisition } + }); +}; + +/** + * Remove a requisition context->terminal mapping + */ +var removeMapping = function(requisition) { + var index = instanceIndex(requisition.conversionContext); + instances.splice(index, 1); +}; + +/** + * Assignment is a link between a parameter and the data for that parameter. + * The data for the parameter is available as in the preferred type and as + * an Argument for the CLI. + *

We also record validity information where applicable. + *

For values, null and undefined have distinct definitions. null means + * that a value has been provided, undefined means that it has not. + * Thus, null is a valid default value, and common because it identifies an + * parameter that is optional. undefined means there is no value from + * the command line. + * @constructor + */ +function Assignment(param) { + // The parameter that we are assigning to + this.param = param; + this.conversion = undefined; +} + +/** + * Easy accessor for conversion.arg. + * This is a read-only property because writes to arg should be done through + * the 'conversion' property. + */ +Object.defineProperty(Assignment.prototype, 'arg', { + get: function() { + return this.conversion == null ? undefined : this.conversion.arg; + }, + enumerable: true +}); + +/** + * Easy accessor for conversion.value. + * This is a read-only property because writes to value should be done through + * the 'conversion' property. + */ +Object.defineProperty(Assignment.prototype, 'value', { + get: function() { + return this.conversion == null ? undefined : this.conversion.value; + }, + enumerable: true +}); + +/** + * Easy (and safe) accessor for conversion.message + */ +Object.defineProperty(Assignment.prototype, 'message', { + get: function() { + if (this.conversion != null && this.conversion.message) { + return this.conversion.message; + } + // ERROR conversions have messages, VALID conversions don't need one, so + // we just need to consider INCOMPLETE conversions. + if (this.getStatus() === Status.INCOMPLETE) { + return l10n.lookupFormat('cliIncompleteParam', [ this.param.name ]); + } + return ''; + }, + enumerable: true +}); + +/** + * Easy (and safe) accessor for conversion.getPredictions() + * @return An array of objects with name and value elements. For example: + * [ { name:'bestmatch', value:foo1 }, { name:'next', value:foo2 }, ... ] + */ +Assignment.prototype.getPredictions = function(context) { + return this.conversion == null ? [] : this.conversion.getPredictions(context); +}; + +/** + * Accessor for a prediction by index. + * This is useful above getPredictions()[index] because it normalizes + * index to be within the bounds of the predictions, which means that the UI + * can maintain an index of which prediction to choose without caring how many + * predictions there are. + * @param rank The index of the prediction to choose + */ +Assignment.prototype.getPredictionRanked = function(context, rank) { + if (rank == null) { + rank = 0; + } + + if (this.isInName()) { + return Promise.resolve(undefined); + } + + return this.getPredictions(context).then(function(predictions) { + if (predictions.length === 0) { + return undefined; + } + + rank = rank % predictions.length; + if (rank < 0) { + rank = predictions.length + rank; + } + return predictions[rank]; + }.bind(this)); +}; + +/** + * Some places want to take special action if we are in the name part of a + * named argument (i.e. the '--foo' bit). + * Currently this does not take actual cursor position into account, it just + * assumes that the cursor is at the end. In the future we will probably want + * to take this into account. + */ +Assignment.prototype.isInName = function() { + return this.conversion.arg.type === 'NamedArgument' && + this.conversion.arg.prefix.slice(-1) !== ' '; +}; + +/** + * Work out what the status of the current conversion is which involves looking + * not only at the conversion, but also checking if data has been provided + * where it should. + * @param arg For assignments with multiple args (e.g. array assignments) we + * can narrow the search for status to a single argument. + */ +Assignment.prototype.getStatus = function(arg) { + if (this.param.isDataRequired && !this.conversion.isDataProvided()) { + return Status.INCOMPLETE; + } + + // Selection/Boolean types with a defined range of values will say that + // '' is INCOMPLETE, but the parameter may be optional, so we don't ask + // if the user doesn't need to enter something and hasn't done so. + if (!this.param.isDataRequired && this.arg.type === 'BlankArgument') { + return Status.VALID; + } + + return this.conversion.getStatus(arg); +}; + +/** + * Helper when we're rebuilding command lines. + */ +Assignment.prototype.toString = function() { + return this.conversion.toString(); +}; + +/** + * For test/debug use only. The output from this function is subject to wanton + * random change without notice, and should not be relied upon to even exist + * at some later date. + */ +Object.defineProperty(Assignment.prototype, '_summaryJson', { + get: function() { + return { + param: this.param.name + '/' + this.param.type.name, + defaultValue: this.param.defaultValue, + arg: this.conversion.arg._summaryJson, + value: this.value, + message: this.message, + status: this.getStatus().toString() + }; + }, + enumerable: true +}); + +exports.Assignment = Assignment; + + +/** + * How to dynamically execute JavaScript code + */ +var customEval = eval; + +/** + * Setup a function to be called in place of 'eval', generally for security + * reasons + */ +exports.setEvalFunction = function(newCustomEval) { + customEval = newCustomEval; +}; + +/** + * Remove the binding done by setEvalFunction(). + * We purposely set customEval to undefined rather than to 'eval' because there + * is an implication of setEvalFunction that we're in a security sensitive + * situation. What if we can trick GCLI into calling unsetEvalFunction() at the + * wrong time? + * So to properly undo the effects of setEvalFunction(), you need to call + * setEvalFunction(eval) rather than unsetEvalFunction(), however the latter is + * preferred in most cases. + */ +exports.unsetEvalFunction = function() { + customEval = undefined; +}; + +/** + * 'eval' command + */ +var evalCmd = { + item: 'command', + name: '{', + params: [ + { + name: 'javascript', + type: 'javascript', + description: '' + } + ], + hidden: true, + description: { key: 'cliEvalJavascript' }, + exec: function(args, context) { + var reply = customEval(args.javascript); + return context.typedData(typeof reply, reply); + }, + isCommandRegexp: /^\s*\{\s*/ +}; + +exports.items = [ evalCmd ]; + +/** + * This is a special assignment to reflect the command itself. + */ +function CommandAssignment(requisition) { + var commandParamMetadata = { + name: '__command', + type: { name: 'command', allowNonExec: false } + }; + // This is a hack so that rather than reply with a generic description of the + // command assignment, we reply with the description of the assigned command, + // (using a generic term if there is no assigned command) + var self = this; + Object.defineProperty(commandParamMetadata, 'description', { + get: function() { + var value = self.value; + return value && value.description ? + value.description : + 'The command to execute'; + }, + enumerable: true + }); + this.param = new Parameter(requisition.system.types, commandParamMetadata); +} + +CommandAssignment.prototype = Object.create(Assignment.prototype); + +CommandAssignment.prototype.getStatus = function(arg) { + return Status.combine( + Assignment.prototype.getStatus.call(this, arg), + this.conversion.value && this.conversion.value.exec ? + Status.VALID : Status.INCOMPLETE + ); +}; + +exports.CommandAssignment = CommandAssignment; + + +/** + * Special assignment used when ignoring parameters that don't have a home + */ +function UnassignedAssignment(requisition, arg) { + var isIncompleteName = (arg.text.charAt(0) === '-'); + this.param = new Parameter(requisition.system.types, { + name: '__unassigned', + description: l10n.lookup('cliOptions'), + type: { + name: 'param', + requisition: requisition, + isIncompleteName: isIncompleteName + } + }); + + // It would be nice to do 'conversion = parm.type.parse(arg, ...)' except + // that type.parse returns a promise (even though it's synchronous in this + // case) + if (isIncompleteName) { + var lookup = commandModule.getDisplayedParamLookup(requisition); + var predictions = selectionModule.findPredictions(arg, lookup); + this.conversion = selectionModule.convertPredictions(arg, predictions); + } + else { + var message = l10n.lookup('cliUnusedArg'); + this.conversion = new Conversion(undefined, arg, Status.ERROR, message); + } + + this.conversion.assignment = this; +} + +UnassignedAssignment.prototype = Object.create(Assignment.prototype); + +UnassignedAssignment.prototype.getStatus = function(arg) { + return this.conversion.getStatus(); +}; + +var logErrors = true; + +/** + * Allow tests that expect failures to avoid clogging up the console + */ +Object.defineProperty(exports, 'logErrors', { + get: function() { + return logErrors; + }, + set: function(val) { + logErrors = val; + }, + enumerable: true +}); + +/** + * A Requisition collects the information needed to execute a command. + * + * (For a definition of the term, see http://en.wikipedia.org/wiki/Requisition) + * This term is used because carries the notion of a work-flow, or process to + * getting the information to execute a command correct. + * There is little point in a requisition for parameter-less commands because + * there is no information to collect. A Requisition is a collection of + * assignments of values to parameters, each handled by an instance of + * Assignment. + * + * @param system Allows access to the various plug-in points in GCLI. At a + * minimum it must contain commands and types objects. + * @param options A set of options to customize how GCLI is used. Includes: + * - environment An optional opaque object passed to commands in the + * Execution Context. + * - document A DOM Document passed to commands using the Execution Context in + * order to allow creation of DOM nodes. If missing Requisition will use the + * global 'document', or leave undefined. + * - commandOutputManager A custom commandOutputManager to which output should + * be sent + * @constructor + */ +function Requisition(system, options) { + options = options || {}; + + this.environment = options.environment || {}; + this.document = options.document; + if (this.document == null) { + try { + this.document = document; + } + catch (ex) { + // Ignore + } + } + + this.commandOutputManager = options.commandOutputManager || new CommandOutputManager(); + this.system = system; + + this.shell = { + cwd: '/', // Where we store the current working directory + env: {} // Where we store the current environment + }; + + // The command that we are about to execute. + // @see setCommandConversion() + this.commandAssignment = new CommandAssignment(this); + + // The object that stores of Assignment objects that we are filling out. + // The Assignment objects are stored under their param.name for named + // lookup. Note: We make use of the property of Javascript objects that + // they are not just hashmaps, but linked-list hashmaps which iterate in + // insertion order. + // _assignments excludes the commandAssignment. + this._assignments = {}; + + // The count of assignments. Excludes the commandAssignment + this.assignmentCount = 0; + + // Used to store cli arguments in the order entered on the cli + this._args = []; + + // Used to store cli arguments that were not assigned to parameters + this._unassigned = []; + + // Changes can be asynchronous, when one update starts before another + // finishes we abandon the former change + this._nextUpdateId = 0; + + // We can set a prefix to typed commands to make it easier to focus on + // Allowing us to type "add -a; commit" in place of "git add -a; git commit" + this.prefix = ''; + + addMapping(this); + this._setBlankAssignment(this.commandAssignment); + + // If a command calls context.update then the UI needs some way to be + // informed of the change + this.onExternalUpdate = util.createEvent('Requisition.onExternalUpdate'); +} + +/** + * Avoid memory leaks + */ +Requisition.prototype.destroy = function() { + this.document = undefined; + this.environment = undefined; + removeMapping(this); +}; + +/** + * If we're about to make an asynchronous change when other async changes could + * overtake this one, then we want to be able to bail out if overtaken. The + * value passed back from beginChange should be passed to endChangeCheckOrder + * on completion of calculation, before the results are applied in order to + * check that the calculation has not been overtaken + */ +Requisition.prototype._beginChange = function() { + var updateId = this._nextUpdateId; + this._nextUpdateId++; + return updateId; +}; + +/** + * Check to see if another change has started since updateId started. + * This allows us to bail out of an update. + * It's hard to make updates atomic because until you've responded to a parse + * of the command argument, you don't know how to parse the arguments to that + * command. + */ +Requisition.prototype._isChangeCurrent = function(updateId) { + return updateId + 1 === this._nextUpdateId; +}; + +/** + * See notes on beginChange + */ +Requisition.prototype._endChangeCheckOrder = function(updateId) { + if (updateId + 1 !== this._nextUpdateId) { + // An update that started after we did has already finished, so our + // changes are out of date. Abandon further work. + return false; + } + + return true; +}; + +var legacy = false; + +/** + * Functions and data related to the execution of a command + */ +Object.defineProperty(Requisition.prototype, 'executionContext', { + get: function() { + if (this._executionContext == null) { + this._executionContext = { + defer: defer, + typedData: function(type, data) { + return { + isTypedData: true, + data: data, + type: type + }; + }, + getArgsObject: this.getArgsObject.bind(this) + }; + + // Alias requisition so we're clear about what's what + var requisition = this; + Object.defineProperty(this._executionContext, 'prefix', { + get: function() { return requisition.prefix; }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'typed', { + get: function() { return requisition.toString(); }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'environment', { + get: function() { return requisition.environment; }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'shell', { + get: function() { return requisition.shell; }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'system', { + get: function() { return requisition.system; }, + enumerable: true + }); + + this._executionContext.updateExec = this._contextUpdateExec.bind(this); + + if (legacy) { + this._executionContext.createView = view.createView; + this._executionContext.exec = this.exec.bind(this); + this._executionContext.update = this._contextUpdate.bind(this); + + Object.defineProperty(this._executionContext, 'document', { + get: function() { return requisition.document; }, + enumerable: true + }); + } + } + + return this._executionContext; + }, + enumerable: true +}); + +/** + * Functions and data related to the conversion of the output of a command + */ +Object.defineProperty(Requisition.prototype, 'conversionContext', { + get: function() { + if (this._conversionContext == null) { + this._conversionContext = { + defer: defer, + + createView: view.createView, + exec: this.exec.bind(this), + update: this._contextUpdate.bind(this), + updateExec: this._contextUpdateExec.bind(this) + }; + + // Alias requisition so we're clear about what's what + var requisition = this; + + Object.defineProperty(this._conversionContext, 'document', { + get: function() { return requisition.document; }, + enumerable: true + }); + Object.defineProperty(this._conversionContext, 'environment', { + get: function() { return requisition.environment; }, + enumerable: true + }); + Object.defineProperty(this._conversionContext, 'system', { + get: function() { return requisition.system; }, + enumerable: true + }); + } + + return this._conversionContext; + }, + enumerable: true +}); + +/** + * Assignments have an order, so we need to store them in an array. + * But we also need named access ... + * @return The found assignment, or undefined, if no match was found + */ +Requisition.prototype.getAssignment = function(nameOrNumber) { + var name = (typeof nameOrNumber === 'string') ? + nameOrNumber : + Object.keys(this._assignments)[nameOrNumber]; + return this._assignments[name] || undefined; +}; + +/** + * Where parameter name == assignment names - they are the same + */ +Requisition.prototype.getParameterNames = function() { + return Object.keys(this._assignments); +}; + +/** + * The overall status is the most severe status. + * There is no such thing as an INCOMPLETE overall status because the + * definition of INCOMPLETE takes into account the cursor position to say 'this + * isn't quite ERROR because the user can fix it by typing', however overall, + * this is still an error status. + */ +Object.defineProperty(Requisition.prototype, 'status', { + get: function() { + var status = Status.VALID; + if (this._unassigned.length !== 0) { + var isAllIncomplete = true; + this._unassigned.forEach(function(assignment) { + if (!assignment.param.type.isIncompleteName) { + isAllIncomplete = false; + } + }); + status = isAllIncomplete ? Status.INCOMPLETE : Status.ERROR; + } + + this.getAssignments(true).forEach(function(assignment) { + var assignStatus = assignment.getStatus(); + if (assignStatus > status) { + status = assignStatus; + } + }, this); + if (status === Status.INCOMPLETE) { + status = Status.ERROR; + } + return status; + }, + enumerable : true +}); + +/** + * If ``requisition.status != VALID`` message then return a string which + * best describes what is wrong. Generally error messages are delivered by + * looking at the error associated with the argument at the cursor, but there + * are times when you just want to say 'tell me the worst'. + * If ``requisition.status != VALID`` then return ``null``. + */ +Requisition.prototype.getStatusMessage = function() { + if (this.commandAssignment.getStatus() !== Status.VALID) { + return l10n.lookupFormat('cliUnknownCommand2', + [ this.commandAssignment.arg.text ]); + } + + var assignments = this.getAssignments(); + for (var i = 0; i < assignments.length; i++) { + if (assignments[i].getStatus() !== Status.VALID) { + return assignments[i].message; + } + } + + if (this._unassigned.length !== 0) { + return l10n.lookup('cliUnusedArg'); + } + + return null; +}; + +/** + * Extract the names and values of all the assignments, and return as + * an object. + */ +Requisition.prototype.getArgsObject = function() { + var args = {}; + this.getAssignments().forEach(function(assignment) { + args[assignment.param.name] = assignment.conversion.isDataProvided() ? + assignment.value : + assignment.param.defaultValue; + }, this); + return args; +}; + +/** + * Access the arguments as an array. + * @param includeCommand By default only the parameter arguments are + * returned unless (includeCommand === true), in which case the list is + * prepended with commandAssignment.arg + */ +Requisition.prototype.getAssignments = function(includeCommand) { + var assignments = []; + if (includeCommand === true) { + assignments.push(this.commandAssignment); + } + Object.keys(this._assignments).forEach(function(name) { + assignments.push(this.getAssignment(name)); + }, this); + return assignments; +}; + +/** + * There are a few places where we need to know what the 'next thing' is. What + * is the user going to be filling out next (assuming they don't enter a named + * argument). The next argument is the first in line that is both blank, and + * that can be filled in positionally. + * @return The next assignment to be used, or null if all the positional + * parameters have values. + */ +Requisition.prototype._getFirstBlankPositionalAssignment = function() { + var reply = null; + Object.keys(this._assignments).some(function(name) { + var assignment = this.getAssignment(name); + if (assignment.arg.type === 'BlankArgument' && + assignment.param.isPositionalAllowed) { + reply = assignment; + return true; // i.e. break + } + return false; + }, this); + return reply; +}; + +/** + * The update process is asynchronous, so there is (unavoidably) a window + * where we've worked out the command but don't yet understand all the params. + * If we try to do things to a requisition in this window we may get + * inconsistent results. Asynchronous promises have made the window bigger. + * The only time we've seen this in practice is during focus events due to + * clicking on a shortcut. The focus want to check the cursor position while + * the shortcut is updating the command line. + * This function allows us to detect and back out of this problem. + * We should be able to remove this function when all the state in a + * requisition can be encapsulated and updated atomically. + */ +Requisition.prototype.isUpToDate = function() { + if (!this._args) { + return false; + } + for (var i = 0; i < this._args.length; i++) { + if (this._args[i].assignment == null) { + return false; + } + } + return true; +}; + +/** + * Look through the arguments attached to our assignments for the assignment + * at the given position. + * @param {number} cursor The cursor position to query + */ +Requisition.prototype.getAssignmentAt = function(cursor) { + // We short circuit this one because we may have no args, or no args with + // any size and the alg below only finds arguments with size. + if (cursor === 0) { + return this.commandAssignment; + } + + var assignForPos = []; + var i, j; + for (i = 0; i < this._args.length; i++) { + var arg = this._args[i]; + var assignment = arg.assignment; + + // prefix and text are clearly part of the argument + for (j = 0; j < arg.prefix.length; j++) { + assignForPos.push(assignment); + } + for (j = 0; j < arg.text.length; j++) { + assignForPos.push(assignment); + } + + // suffix is part of the argument only if this is a named parameter, + // otherwise it looks forwards + if (arg.assignment.arg.type === 'NamedArgument') { + // leave the argument as it is + } + else if (this._args.length > i + 1) { + // first to the next argument + assignment = this._args[i + 1].assignment; + } + else { + // then to the first blank positional parameter, leaving 'as is' if none + var nextAssignment = this._getFirstBlankPositionalAssignment(); + if (nextAssignment != null) { + assignment = nextAssignment; + } + } + + for (j = 0; j < arg.suffix.length; j++) { + assignForPos.push(assignment); + } + } + + // Possible shortcut, we don't really need to go through all the args + // to work out the solution to this + + return assignForPos[cursor - 1]; +}; + +/** + * Extract a canonical version of the input + * @return a promise of a string which is the canonical version of what was + * typed + */ +Requisition.prototype.toCanonicalString = function() { + var cmd = this.commandAssignment.value ? + this.commandAssignment.value.name : + this.commandAssignment.arg.text; + + // Canonically, if we've opened with a { then we should have a } to close + var lineSuffix = ''; + if (cmd === '{') { + var scriptSuffix = this.getAssignment(0).arg.suffix; + lineSuffix = (scriptSuffix.indexOf('}') === -1) ? ' }' : ''; + } + + var ctx = this.executionContext; + + // First stringify all the arguments + var argPromise = util.promiseEach(this.getAssignments(), function(assignment) { + // Bug 664377: This will cause problems if there is a non-default value + // after a default value. Also we need to decide when to use + // named parameters in place of positional params. Both can wait. + if (assignment.value === assignment.param.defaultValue) { + return ''; + } + + var val = assignment.param.type.stringify(assignment.value, ctx); + return Promise.resolve(val).then(function(str) { + return ' ' + str; + }.bind(this)); + }.bind(this)); + + return argPromise.then(function(strings) { + return cmd + strings.join('') + lineSuffix; + }.bind(this)); +}; + +/** + * Reconstitute the input from the args + */ +Requisition.prototype.toString = function() { + if (!this._args) { + throw new Error('toString requires a command line. See source.'); + } + + return this._args.map(function(arg) { + return arg.toString(); + }).join(''); +}; + +/** + * For test/debug use only. The output from this function is subject to wanton + * random change without notice, and should not be relied upon to even exist + * at some later date. + */ +Object.defineProperty(Requisition.prototype, '_summaryJson', { + get: function() { + var summary = { + $args: this._args.map(function(arg) { + return arg._summaryJson; + }), + _command: this.commandAssignment._summaryJson, + _unassigned: this._unassigned.forEach(function(assignment) { + return assignment._summaryJson; + }) + }; + + Object.keys(this._assignments).forEach(function(name) { + summary[name] = this.getAssignment(name)._summaryJson; + }.bind(this)); + + return summary; + }, + enumerable: true +}); + +/** + * When any assignment changes, we might need to update the _args array to + * match and inform people of changes to the typed input text. + */ +Requisition.prototype._setAssignmentInternal = function(assignment, conversion) { + var oldConversion = assignment.conversion; + + assignment.conversion = conversion; + assignment.conversion.assignment = assignment; + + // Do nothing if the conversion is unchanged + if (assignment.conversion.equals(oldConversion)) { + if (assignment === this.commandAssignment) { + this._setBlankArguments(); + } + return; + } + + // When the command changes, we need to keep a bunch of stuff in sync + if (assignment === this.commandAssignment) { + this._assignments = {}; + + var command = this.commandAssignment.value; + if (command) { + for (var i = 0; i < command.params.length; i++) { + var param = command.params[i]; + var newAssignment = new Assignment(param); + this._setBlankAssignment(newAssignment); + this._assignments[param.name] = newAssignment; + } + } + this.assignmentCount = Object.keys(this._assignments).length; + } +}; + +/** + * Internal function to alter the given assignment using the given arg. + * @param assignment The assignment to alter + * @param arg The new value for the assignment. An instance of Argument, or an + * instance of Conversion, or null to set the blank value. + * @param options There are a number of ways to customize how the assignment + * is made, including: + * - internal: (default:false) External updates are required to do more work, + * including adjusting the args in this requisition to stay in sync. + * On the other hand non internal changes use beginChange to back out of + * changes when overtaken asynchronously. + * Setting internal:true effectively means this is being called as part of + * the update process. + * - matchPadding: (default:false) Alter the whitespace on the prefix and + * suffix of the new argument to match that of the old argument. This only + * makes sense with internal=false + * @return A promise that resolves to undefined when the assignment is complete + */ +Requisition.prototype.setAssignment = function(assignment, arg, options) { + options = options || {}; + if (!options.internal) { + var originalArgs = assignment.arg.getArgs(); + + // Update the args array + var replacementArgs = arg.getArgs(); + var maxLen = Math.max(originalArgs.length, replacementArgs.length); + for (var i = 0; i < maxLen; i++) { + // If there are no more original args, or if the original arg was blank + // (i.e. not typed by the user), we'll just need to add at the end + if (i >= originalArgs.length || originalArgs[i].type === 'BlankArgument') { + this._args.push(replacementArgs[i]); + continue; + } + + var index = this._args.indexOf(originalArgs[i]); + if (index === -1) { + console.error('Couldn\'t find ', originalArgs[i], ' in ', this._args); + throw new Error('Couldn\'t find ' + originalArgs[i]); + } + + // If there are no more replacement args, we just remove the original args + // Otherwise swap original args and replacements + if (i >= replacementArgs.length) { + this._args.splice(index, 1); + } + else { + if (options.matchPadding) { + if (replacementArgs[i].prefix.length === 0 && + this._args[index].prefix.length !== 0) { + replacementArgs[i].prefix = this._args[index].prefix; + } + if (replacementArgs[i].suffix.length === 0 && + this._args[index].suffix.length !== 0) { + replacementArgs[i].suffix = this._args[index].suffix; + } + } + this._args[index] = replacementArgs[i]; + } + } + } + + var updateId = options.internal ? null : this._beginChange(); + + var setAssignmentInternal = function(conversion) { + if (options.internal || this._isChangeCurrent(updateId)) { + this._setAssignmentInternal(assignment, conversion); + } + + if (!options.internal) { + this._endChangeCheckOrder(updateId); + } + + return Promise.resolve(undefined); + }.bind(this); + + if (arg == null) { + var blank = assignment.param.type.getBlank(this.executionContext); + return setAssignmentInternal(blank); + } + + if (typeof arg.getStatus === 'function') { + // It's not really an arg, it's a conversion already + return setAssignmentInternal(arg); + } + + var parsed = assignment.param.type.parse(arg, this.executionContext); + return parsed.then(setAssignmentInternal); +}; + +/** + * Reset an assignment to its default value. + * For internal use only. + * Happens synchronously. + */ +Requisition.prototype._setBlankAssignment = function(assignment) { + var blank = assignment.param.type.getBlank(this.executionContext); + this._setAssignmentInternal(assignment, blank); +}; + +/** + * Reset all the assignments to their default values. + * For internal use only. + * Happens synchronously. + */ +Requisition.prototype._setBlankArguments = function() { + this.getAssignments().forEach(this._setBlankAssignment.bind(this)); +}; + +/** + * Input trace gives us an array of Argument tracing objects, one for each + * character in the typed input, from which we can derive information about how + * to display this typed input. It's a bit like toString on steroids. + *

+ * The returned object has the following members:

    + *
  • character: The character to which this arg trace refers. + *
  • arg: The Argument to which this character is assigned. + *
  • part: One of ['prefix'|'text'|suffix'] - how was this char understood + *
+ *

+ * The Argument objects are as output from tokenize() rather than as applied + * to Assignments by _assign() (i.e. they are not instances of NamedArgument, + * ArrayArgument, etc). + *

+ * To get at the arguments applied to the assignments simply call + * arg.assignment.arg. If arg.assignment.arg !== arg then + * the arg applied to the assignment will contain the original arg. + * See _assign() for details. + */ +Requisition.prototype.createInputArgTrace = function() { + if (!this._args) { + throw new Error('createInputMap requires a command line. See source.'); + } + + var args = []; + var i; + this._args.forEach(function(arg) { + for (i = 0; i < arg.prefix.length; i++) { + args.push({ arg: arg, character: arg.prefix[i], part: 'prefix' }); + } + for (i = 0; i < arg.text.length; i++) { + args.push({ arg: arg, character: arg.text[i], part: 'text' }); + } + for (i = 0; i < arg.suffix.length; i++) { + args.push({ arg: arg, character: arg.suffix[i], part: 'suffix' }); + } + }); + + return args; +}; + +/** + * If the last character is whitespace then things that we suggest to add to + * the end don't need a space prefix. + * While this is quite a niche function, it has 2 benefits: + * - it's more correct because we can distinguish between final whitespace that + * is part of an unclosed string, and parameter separating whitespace. + * - also it's faster than toString() the whole thing and checking the end char + * @return true iff the last character is interpreted as parameter separating + * whitespace + */ +Requisition.prototype.typedEndsWithSeparator = function() { + if (!this._args) { + throw new Error('typedEndsWithSeparator requires a command line. See source.'); + } + + if (this._args.length === 0) { + return false; + } + + // This is not as easy as doing (this.toString().slice(-1) === ' ') + // See the doc comments above; We're checking for separators, not spaces + var lastArg = this._args.slice(-1)[0]; + if (lastArg.suffix.slice(-1) === ' ') { + return true; + } + + return lastArg.text === '' && lastArg.suffix === '' + && lastArg.prefix.slice(-1) === ' '; +}; + +/** + * Return an array of Status scores so we can create a marked up + * version of the command line input. + * @param cursor We only take a status of INCOMPLETE to be INCOMPLETE when the + * cursor is actually in the argument. Otherwise it's an error. + * @return Array of objects each containing status property and a + * string property containing the characters to which the status + * applies. Concatenating the strings in order gives the original input. + */ +Requisition.prototype.getInputStatusMarkup = function(cursor) { + var argTraces = this.createInputArgTrace(); + // Generally the 'argument at the cursor' is the argument before the cursor + // unless it is before the first char, in which case we take the first. + cursor = cursor === 0 ? 0 : cursor - 1; + var cTrace = argTraces[cursor]; + + var markup = []; + for (var i = 0; i < argTraces.length; i++) { + var argTrace = argTraces[i]; + var arg = argTrace.arg; + var status = Status.VALID; + // When things get very async we can get here while something else is + // doing an update, in which case arg.assignment == null, so we check first + if (argTrace.part === 'text' && arg.assignment != null) { + status = arg.assignment.getStatus(arg); + // Promote INCOMPLETE to ERROR ... + if (status === Status.INCOMPLETE) { + // If the cursor is in the prefix or suffix of an argument then we + // don't consider it in the argument for the purposes of preventing + // the escalation to ERROR. However if this is a NamedArgument, then we + // allow the suffix (as space between 2 parts of the argument) to be in. + // We use arg.assignment.arg not arg because we're looking at the arg + // that got put into the assignment not as returned by tokenize() + var isNamed = (cTrace.arg.assignment.arg.type === 'NamedArgument'); + var isInside = cTrace.part === 'text' || + (isNamed && cTrace.part === 'suffix'); + if (arg.assignment !== cTrace.arg.assignment || !isInside) { + // And if we're not in the command + if (!(arg.assignment instanceof CommandAssignment)) { + status = Status.ERROR; + } + } + } + } + + markup.push({ status: status, string: argTrace.character }); + } + + // De-dupe: merge entries where 2 adjacent have same status + i = 0; + while (i < markup.length - 1) { + if (markup[i].status === markup[i + 1].status) { + markup[i].string += markup[i + 1].string; + markup.splice(i + 1, 1); + } + else { + i++; + } + } + + return markup; +}; + +/** + * Describe the state of the current input in a way that allows display of + * predictions and completion hints + * @param start The location of the cursor + * @param rank The index of the chosen prediction + * @return A promise of an object containing the following properties: + * - statusMarkup: An array of Status scores so we can create a marked up + * version of the command line input. See getInputStatusMarkup() for details + * - unclosedJs: Is the entered command a JS command with no closing '}'? + * - directTabText: A promise of the text that we *add* to the command line + * when TAB is pressed, to be displayed directly after the cursor. See also + * arrowTabText. + * - emptyParameters: A promise of the text that describes the arguments that + * the user is yet to type. + * - arrowTabText: A promise of the text that *replaces* the current argument + * when TAB is pressed, generally displayed after a "|->" symbol. See also + * directTabText. + */ +Requisition.prototype.getStateData = function(start, rank) { + var typed = this.toString(); + var current = this.getAssignmentAt(start); + var context = this.executionContext; + var predictionPromise = (typed.trim().length !== 0) ? + current.getPredictionRanked(context, rank) : + Promise.resolve(null); + + return predictionPromise.then(function(prediction) { + // directTabText is for when the current input is a prefix of the completion + // arrowTabText is for when we need to use an -> to show what will be used + var directTabText = ''; + var arrowTabText = ''; + var emptyParameters = []; + + if (typed.trim().length !== 0) { + var cArg = current.arg; + + if (prediction) { + var tabText = prediction.name; + var existing = cArg.text; + + // Normally the cursor being just before whitespace means that you are + // 'in' the previous argument, which means that the prediction is based + // on that argument, however NamedArguments break this by having 2 parts + // so we need to prepend the tabText with a space for NamedArguments, + // but only when there isn't already a space at the end of the prefix + // (i.e. ' --name' not ' --name ') + if (current.isInName()) { + tabText = ' ' + tabText; + } + + if (existing !== tabText) { + // Decide to use directTabText or arrowTabText + // Strip any leading whitespace from the user inputted value because + // the tabText will never have leading whitespace. + var inputValue = existing.replace(/^\s*/, ''); + var isStrictCompletion = tabText.indexOf(inputValue) === 0; + if (isStrictCompletion && start === typed.length) { + // Display the suffix of the prediction as the completion + var numLeadingSpaces = existing.match(/^(\s*)/)[0].length; + + directTabText = tabText.slice(existing.length - numLeadingSpaces); + } + else { + // Display the '-> prediction' at the end of the completer element + // \u21E5 is the JS escape right arrow + arrowTabText = '\u21E5 ' + tabText; + } + } + } + else { + // There's no prediction, but if this is a named argument that needs a + // value (that is without any) then we need to show that one is needed + // For example 'git commit --message ', clearly needs some more text + if (cArg.type === 'NamedArgument' && cArg.valueArg == null) { + emptyParameters.push('<' + current.param.type.name + '>\u00a0'); + } + } + } + + // Add a space between the typed text (+ directTabText) and the hints, + // making sure we don't add 2 sets of padding + if (directTabText !== '') { + directTabText += '\u00a0'; // a.k.a   + } + else if (!this.typedEndsWithSeparator()) { + emptyParameters.unshift('\u00a0'); + } + + // Calculate the list of parameters to be filled in + // We generate an array of emptyParameter markers for each positional + // parameter to the current command. + // Generally each emptyParameter marker begins with a space to separate it + // from whatever came before, unless what comes before ends in a space. + + this.getAssignments().forEach(function(assignment) { + // Named arguments are handled with a group [options] marker + if (!assignment.param.isPositionalAllowed) { + return; + } + + // No hints if we've got content for this parameter + if (assignment.arg.toString().trim() !== '') { + return; + } + + // No hints if we have a prediction + if (directTabText !== '' && current === assignment) { + return; + } + + var text = (assignment.param.isDataRequired) ? + '<' + assignment.param.name + '>\u00a0' : + '[' + assignment.param.name + ']\u00a0'; + + emptyParameters.push(text); + }.bind(this)); + + var command = this.commandAssignment.value; + var addOptionsMarker = false; + + // We add an '[options]' marker when there are named parameters that are + // not filled in and not hidden, and we don't have any directTabText + if (command && command.hasNamedParameters) { + command.params.forEach(function(param) { + var arg = this.getAssignment(param.name).arg; + if (!param.isPositionalAllowed && !param.hidden + && arg.type === 'BlankArgument') { + addOptionsMarker = true; + } + }, this); + } + + if (addOptionsMarker) { + // Add an nbsp if we don't have one at the end of the input or if + // this isn't the first param we've mentioned + emptyParameters.push('[options]\u00a0'); + } + + // Is the entered command a JS command with no closing '}'? + var unclosedJs = command && command.name === '{' && + this.getAssignment(0).arg.suffix.indexOf('}') === -1; + + return { + statusMarkup: this.getInputStatusMarkup(start), + unclosedJs: unclosedJs, + directTabText: directTabText, + arrowTabText: arrowTabText, + emptyParameters: emptyParameters + }; + }.bind(this)); +}; + +/** + * Pressing TAB sometimes requires that we add a space to denote that we're on + * to the 'next thing'. + * @param assignment The assignment to which to append the space + */ +Requisition.prototype._addSpace = function(assignment) { + var arg = assignment.arg.beget({ suffixSpace: true }); + if (arg !== assignment.arg) { + return this.setAssignment(assignment, arg); + } + else { + return Promise.resolve(undefined); + } +}; + +/** + * Complete the argument at cursor. + * Basically the same as: + * assignment = getAssignmentAt(cursor); + * assignment.value = assignment.conversion.predictions[0]; + * Except it's done safely, and with particular care to where we place the + * space, which is complex, and annoying if we get it wrong. + * + * WARNING: complete() can happen asynchronously. + * + * @param cursor The cursor configuration. Should have start and end properties + * which should be set to start and end of the selection. + * @param rank The index of the prediction that we should choose. + * This number is not bounded by the size of the prediction array, we take the + * modulus to get it within bounds + * @return A promise which completes (with undefined) when any outstanding + * completion tasks are done. + */ +Requisition.prototype.complete = function(cursor, rank) { + var assignment = this.getAssignmentAt(cursor.start); + + var context = this.executionContext; + var predictionPromise = assignment.getPredictionRanked(context, rank); + return predictionPromise.then(function(prediction) { + var outstanding = []; + + // Note: Since complete is asynchronous we should perhaps have a system to + // bail out of making changes if the command line has changed since TAB + // was pressed. It's not yet clear if this will be a problem. + + if (prediction == null) { + // No predictions generally means we shouldn't change anything on TAB, + // but TAB has the connotation of 'next thing' and when we're at the end + // of a thing that implies that we should add a space. i.e. + // 'help' -> 'help ' + // But we should only do this if the thing that we're 'completing' is + // valid and doesn't already end in a space. + if (assignment.arg.suffix.slice(-1) !== ' ' && + assignment.getStatus() === Status.VALID) { + outstanding.push(this._addSpace(assignment)); + } + + // Also add a space if we are in the name part of an assignment, however + // this time we don't want the 'push the space to the next assignment' + // logic, so we don't use addSpace + if (assignment.isInName()) { + var newArg = assignment.arg.beget({ prefixPostSpace: true }); + outstanding.push(this.setAssignment(assignment, newArg)); + } + } + else { + // Mutate this argument to hold the completion + var arg = assignment.arg.beget({ + text: prediction.name, + dontQuote: (assignment === this.commandAssignment) + }); + var assignPromise = this.setAssignment(assignment, arg); + + if (!prediction.incomplete) { + assignPromise = assignPromise.then(function() { + // The prediction is complete, add a space to let the user move-on + return this._addSpace(assignment).then(function() { + // Bug 779443 - Remove or explain the re-parse + if (assignment instanceof UnassignedAssignment) { + return this.update(this.toString()); + } + }.bind(this)); + }.bind(this)); + } + + outstanding.push(assignPromise); + } + + return Promise.all(outstanding).then(function() { + return true; + }.bind(this)); + }.bind(this)); +}; + +/** + * Replace the current value with the lower value if such a concept exists. + */ +Requisition.prototype.nudge = function(assignment, by) { + var ctx = this.executionContext; + var val = assignment.param.type.nudge(assignment.value, by, ctx); + return Promise.resolve(val).then(function(replacement) { + if (replacement != null) { + var val = assignment.param.type.stringify(replacement, ctx); + return Promise.resolve(val).then(function(str) { + var arg = assignment.arg.beget({ text: str }); + return this.setAssignment(assignment, arg); + }.bind(this)); + } + }.bind(this)); +}; + +/** + * Helper to find the 'data-command' attribute, used by |update()| + */ +function getDataCommandAttribute(element) { + var command = element.getAttribute('data-command'); + if (!command) { + command = element.querySelector('*[data-command]') + .getAttribute('data-command'); + } + return command; +} + +/** + * Designed to be called from context.update(). Acts just like update() except + * that it also calls onExternalUpdate() to inform the UI of an unexpected + * change to the current command. + */ +Requisition.prototype._contextUpdate = function(typed) { + return this.update(typed).then(function(reply) { + this.onExternalUpdate({ typed: typed }); + return reply; + }.bind(this)); +}; + +/** + * Called by the UI when ever the user interacts with a command line input + * @param typed The contents of the input field OR an HTML element (or an event + * that targets an HTML element) which has a data-command attribute or a child + * with the same that contains the command to update with + */ +Requisition.prototype.update = function(typed) { + // Should be "if (typed instanceof HTMLElement)" except Gecko + if (typeof typed.querySelector === 'function') { + typed = getDataCommandAttribute(typed); + } + // Should be "if (typed instanceof Event)" except Gecko + if (typeof typed.currentTarget === 'object') { + typed = getDataCommandAttribute(typed.currentTarget); + } + + var updateId = this._beginChange(); + + this._args = exports.tokenize(typed); + var args = this._args.slice(0); // i.e. clone + + this._split(args); + + return this._assign(args).then(function() { + return this._endChangeCheckOrder(updateId); + }.bind(this)); +}; + +/** + * Similar to update('') except that it's guaranteed to execute synchronously + */ +Requisition.prototype.clear = function() { + var arg = new Argument('', '', ''); + this._args = [ arg ]; + + var conversion = commandModule.parse(this.executionContext, arg, false); + this.setAssignment(this.commandAssignment, conversion, { internal: true }); +}; + +/** + * tokenize() is a state machine. These are the states. + */ +var In = { + /** + * The last character was ' '. + * Typing a ' ' character will not change the mode + * Typing one of '"{ will change mode to SINGLE_Q, DOUBLE_Q or SCRIPT. + * Anything else goes into SIMPLE mode. + */ + WHITESPACE: 1, + + /** + * The last character was part of a parameter. + * Typing ' ' returns to WHITESPACE mode. Any other character + * (including '"{} which are otherwise special) does not change the mode. + */ + SIMPLE: 2, + + /** + * We're inside single quotes: ' + * Typing ' returns to WHITESPACE mode. Other characters do not change mode. + */ + SINGLE_Q: 3, + + /** + * We're inside double quotes: " + * Typing " returns to WHITESPACE mode. Other characters do not change mode. + */ + DOUBLE_Q: 4, + + /** + * We're inside { and } + * Typing } returns to WHITESPACE mode. Other characters do not change mode. + * SCRIPT mode is slightly different from other modes in that spaces between + * the {/} delimiters and the actual input are not considered significant. + * e.g: " x " is a 3 character string, delimited by double quotes, however + * { x } is a 1 character JavaScript surrounded by whitespace and {} + * delimiters. + * In the short term we assume that the JS routines can make sense of the + * extra whitespace, however at some stage we may need to move the space into + * the Argument prefix/suffix. + * Also we don't attempt to handle nested {}. See bug 678961 + */ + SCRIPT: 5 +}; + +/** + * Split up the input taking into account ', " and {. + * We don't consider \t or other classical whitespace characters to split + * arguments apart. For one thing these characters are hard to type, but also + * if the user has gone to the trouble of pasting a TAB character into the + * input field (or whatever it takes), they probably mean it. + */ +exports.tokenize = function(typed) { + // For blank input, place a dummy empty argument into the list + if (typed == null || typed.length === 0) { + return [ new Argument('', '', '') ]; + } + + if (isSimple(typed)) { + return [ new Argument(typed, '', '') ]; + } + + var mode = In.WHITESPACE; + + // First we swap out escaped characters that are special to the tokenizer. + // So a backslash followed by any of ['"{} ] is turned into a unicode private + // char so we can swap back later + typed = typed + .replace(/\\\\/g, '\uF000') + .replace(/\\ /g, '\uF001') + .replace(/\\'/g, '\uF002') + .replace(/\\"/g, '\uF003') + .replace(/\\{/g, '\uF004') + .replace(/\\}/g, '\uF005'); + + function unescape2(escaped) { + return escaped + .replace(/\uF000/g, '\\\\') + .replace(/\uF001/g, '\\ ') + .replace(/\uF002/g, '\\\'') + .replace(/\uF003/g, '\\\"') + .replace(/\uF004/g, '\\{') + .replace(/\uF005/g, '\\}'); + } + + var i = 0; // The index of the current character + var start = 0; // Where did this section start? + var prefix = ''; // Stuff that comes before the current argument + var args = []; // The array that we're creating + var blockDepth = 0; // For JS with nested {} + + // This is just a state machine. We're going through the string char by char + // The 'mode' is one of the 'In' states. As we go, we're adding Arguments + // to the 'args' array. + + while (true) { + var c = typed[i]; + var str; + switch (mode) { + case In.WHITESPACE: + if (c === '\'') { + prefix = typed.substring(start, i + 1); + mode = In.SINGLE_Q; + start = i + 1; + } + else if (c === '"') { + prefix = typed.substring(start, i + 1); + mode = In.DOUBLE_Q; + start = i + 1; + } + else if (c === '{') { + prefix = typed.substring(start, i + 1); + mode = In.SCRIPT; + blockDepth++; + start = i + 1; + } + else if (/ /.test(c)) { + // Still whitespace, do nothing + } + else { + prefix = typed.substring(start, i); + mode = In.SIMPLE; + start = i; + } + break; + + case In.SIMPLE: + // There is an edge case of xx'xx which we are assuming to + // be a single parameter (and same with ") + if (c === ' ') { + str = unescape2(typed.substring(start, i)); + args.push(new Argument(str, prefix, '')); + mode = In.WHITESPACE; + start = i; + prefix = ''; + } + break; + + case In.SINGLE_Q: + if (c === '\'') { + str = unescape2(typed.substring(start, i)); + args.push(new Argument(str, prefix, c)); + mode = In.WHITESPACE; + start = i + 1; + prefix = ''; + } + break; + + case In.DOUBLE_Q: + if (c === '"') { + str = unescape2(typed.substring(start, i)); + args.push(new Argument(str, prefix, c)); + mode = In.WHITESPACE; + start = i + 1; + prefix = ''; + } + break; + + case In.SCRIPT: + if (c === '{') { + blockDepth++; + } + else if (c === '}') { + blockDepth--; + if (blockDepth === 0) { + str = unescape2(typed.substring(start, i)); + args.push(new ScriptArgument(str, prefix, c)); + mode = In.WHITESPACE; + start = i + 1; + prefix = ''; + } + } + break; + } + + i++; + + if (i >= typed.length) { + // There is nothing else to read - tidy up + if (mode === In.WHITESPACE) { + if (i !== start) { + // There's whitespace at the end of the typed string. Add it to the + // last argument's suffix, creating an empty argument if needed. + var extra = typed.substring(start, i); + var lastArg = args[args.length - 1]; + if (!lastArg) { + args.push(new Argument('', extra, '')); + } + else { + lastArg.suffix += extra; + } + } + } + else if (mode === In.SCRIPT) { + str = unescape2(typed.substring(start, i + 1)); + args.push(new ScriptArgument(str, prefix, '')); + } + else { + str = unescape2(typed.substring(start, i + 1)); + args.push(new Argument(str, prefix, '')); + } + break; + } + } + + return args; +}; + +/** + * If the input has no spaces, quotes, braces or escapes, + * we can take the fast track. + */ +function isSimple(typed) { + for (var i = 0; i < typed.length; i++) { + var c = typed.charAt(i); + if (c === ' ' || c === '"' || c === '\'' || + c === '{' || c === '}' || c === '\\') { + return false; + } + } + return true; +} + +/** + * Looks in the commands for a command extension that matches what has been + * typed at the command line. + */ +Requisition.prototype._split = function(args) { + // Handle the special case of the user typing { javascript(); } + // We use the hidden 'eval' command directly rather than shift()ing one of + // the parameters, and parse()ing it. + var conversion; + if (args[0].type === 'ScriptArgument') { + // Special case: if the user enters { console.log('foo'); } then we need to + // use the hidden 'eval' command + var command = this.system.commands.get(evalCmd.name); + conversion = new Conversion(command, new ScriptArgument()); + this._setAssignmentInternal(this.commandAssignment, conversion); + return; + } + + var argsUsed = 1; + + while (argsUsed <= args.length) { + var arg = (argsUsed === 1) ? + args[0] : + new MergedArgument(args, 0, argsUsed); + + if (this.prefix != null && this.prefix !== '') { + var prefixArg = new Argument(this.prefix, '', ' '); + var prefixedArg = new MergedArgument([ prefixArg, arg ]); + + conversion = commandModule.parse(this.executionContext, prefixedArg, false); + if (conversion.value == null) { + conversion = commandModule.parse(this.executionContext, arg, false); + } + } + else { + conversion = commandModule.parse(this.executionContext, arg, false); + } + + // We only want to carry on if this command is a parent command, + // which means that there is a commandAssignment, but not one with + // an exec function. + if (!conversion.value || conversion.value.exec) { + break; + } + + // Previously we needed a way to hide commands depending context. + // We have not resurrected that feature yet, but if we do we should + // insert code here to ignore certain commands depending on the + // context/environment + + argsUsed++; + } + + // This could probably be re-written to consume args as we go + for (var i = 0; i < argsUsed; i++) { + args.shift(); + } + + this._setAssignmentInternal(this.commandAssignment, conversion); +}; + +/** + * Add all the passed args to the list of unassigned assignments. + */ +Requisition.prototype._addUnassignedArgs = function(args) { + args.forEach(function(arg) { + this._unassigned.push(new UnassignedAssignment(this, arg)); + }.bind(this)); + + return RESOLVED; +}; + +/** + * Work out which arguments are applicable to which parameters. + */ +Requisition.prototype._assign = function(args) { + // See comment in _split. Avoid multiple updates + var noArgUp = { internal: true }; + + this._unassigned = []; + + if (!this.commandAssignment.value) { + return this._addUnassignedArgs(args); + } + + if (args.length === 0) { + this._setBlankArguments(); + return RESOLVED; + } + + // Create an error if the command does not take parameters, but we have + // been given them ... + if (this.assignmentCount === 0) { + return this._addUnassignedArgs(args); + } + + // Special case: if there is only 1 parameter, and that's of type + // text, then we put all the params into the first param + if (this.assignmentCount === 1) { + var assignment = this.getAssignment(0); + if (assignment.param.type.name === 'string') { + var arg = (args.length === 1) ? args[0] : new MergedArgument(args); + return this.setAssignment(assignment, arg, noArgUp); + } + } + + // Positional arguments can still be specified by name, but if they are + // then we need to ignore them when working them out positionally + var unassignedParams = this.getParameterNames(); + + // We collect the arguments used in arrays here before assigning + var arrayArgs = {}; + + // Extract all the named parameters + var assignments = this.getAssignments(false); + var namedDone = util.promiseEach(assignments, function(assignment) { + // Loop over the arguments + // Using while rather than loop because we remove args as we go + var i = 0; + while (i < args.length) { + if (!assignment.param.isKnownAs(args[i].text)) { + // Skip this parameter and handle as a positional parameter + i++; + continue; + } + + var arg = args.splice(i, 1)[0]; + /* jshint loopfunc:true */ + unassignedParams = unassignedParams.filter(function(test) { + return test !== assignment.param.name; + }); + + // boolean parameters don't have values, default to false + if (assignment.param.type.name === 'boolean') { + arg = new TrueNamedArgument(arg); + } + else { + var valueArg = null; + if (i + 1 <= args.length) { + valueArg = args.splice(i, 1)[0]; + } + arg = new NamedArgument(arg, valueArg); + } + + if (assignment.param.type.name === 'array') { + var arrayArg = arrayArgs[assignment.param.name]; + if (!arrayArg) { + arrayArg = new ArrayArgument(); + arrayArgs[assignment.param.name] = arrayArg; + } + arrayArg.addArgument(arg); + return RESOLVED; + } + else { + if (assignment.arg.type === 'BlankArgument') { + return this.setAssignment(assignment, arg, noArgUp); + } + else { + return this._addUnassignedArgs(arg.getArgs()); + } + } + } + }, this); + + // What's left are positional parameters: assign in order + var positionalDone = namedDone.then(function() { + return util.promiseEach(unassignedParams, function(name) { + var assignment = this.getAssignment(name); + + // If not set positionally, and we can't set it non-positionally, + // we have to default it to prevent previous values surviving + if (!assignment.param.isPositionalAllowed) { + this._setBlankAssignment(assignment); + return RESOLVED; + } + + // If this is a positional array argument, then it swallows the + // rest of the arguments. + if (assignment.param.type.name === 'array') { + var arrayArg = arrayArgs[assignment.param.name]; + if (!arrayArg) { + arrayArg = new ArrayArgument(); + arrayArgs[assignment.param.name] = arrayArg; + } + arrayArg.addArguments(args); + args = []; + // The actual assignment to the array parameter is done below + return RESOLVED; + } + + // Set assignment to defaults if there are no more arguments + if (args.length === 0) { + this._setBlankAssignment(assignment); + return RESOLVED; + } + + var arg = args.splice(0, 1)[0]; + // --foo and -f are named parameters, -4 is a number. So '-' is either + // the start of a named parameter or a number depending on the context + var isIncompleteName = assignment.param.type.name === 'number' ? + /-[-a-zA-Z_]/.test(arg.text) : + arg.text.charAt(0) === '-'; + + if (isIncompleteName) { + this._unassigned.push(new UnassignedAssignment(this, arg)); + return RESOLVED; + } + else { + return this.setAssignment(assignment, arg, noArgUp); + } + }, this); + }.bind(this)); + + // Now we need to assign the array argument (if any) + var arrayDone = positionalDone.then(function() { + return util.promiseEach(Object.keys(arrayArgs), function(name) { + var assignment = this.getAssignment(name); + return this.setAssignment(assignment, arrayArgs[name], noArgUp); + }, this); + }.bind(this)); + + // What's left is can't be assigned, but we need to officially unassign them + return arrayDone.then(function() { + return this._addUnassignedArgs(args); + }.bind(this)); +}; + +/** + * Entry point for keyboard accelerators or anything else that wants to execute + * a command. + * @param options Object describing how the execution should be handled. + * (optional). Contains some of the following properties: + * - hidden (boolean, default=false) Should the output be hidden from the + * commandOutputManager for this requisition + * - command/args A fast shortcut to executing a known command with a known + * set of parsed arguments. + */ +Requisition.prototype.exec = function(options) { + var command = null; + var args = null; + var hidden = false; + + if (options) { + if (options.hidden) { + hidden = true; + } + + if (options.command != null) { + // Fast track by looking up the command directly since passed args + // means there is no command line to parse. + command = this.system.commands.get(options.command); + if (!command) { + console.error('Command not found: ' + options.command); + } + args = options.args; + } + } + + if (!command) { + command = this.commandAssignment.value; + args = this.getArgsObject(); + } + + // Display JavaScript input without the initial { or closing } + var typed = this.toString(); + if (evalCmd.isCommandRegexp.test(typed)) { + typed = typed.replace(evalCmd.isCommandRegexp, ''); + // Bug 717763: What if the JavaScript naturally ends with a }? + typed = typed.replace(/\s*}\s*$/, ''); + } + + var output = new Output({ + command: command, + args: args, + typed: typed, + canonical: this.toCanonicalString(), + hidden: hidden + }); + + this.commandOutputManager.onOutput({ output: output }); + + var onDone = function(data) { + output.complete(data, false); + return output; + }; + + var onError = function(data, ex) { + if (logErrors) { + if (ex != null) { + util.errorHandler(ex); + } + else { + console.error(data); + } + } + + if (data != null && typeof data === 'string') { + data = data.replace(/^Protocol error: /, ''); // Temp fix for bug 1035296 + } + + data = (data != null && data.isTypedData) ? data : { + isTypedData: true, + data: data, + type: 'error' + }; + output.complete(data, true); + return output; + }; + + if (this.status !== Status.VALID) { + var ex = new Error(this.getStatusMessage()); + // We only reject a call to exec if GCLI breaks. Errors with commands are + // exposed in the 'error' status of the Output object + return Promise.resolve(onError(ex)).then(function(output) { + this.clear(); + return output; + }.bind(this)); + } + else { + try { + return host.exec(function() { + return command.exec(args, this.executionContext); + }.bind(this)).then(onDone, onError); + } + catch (ex) { + var data = (typeof ex.message === 'string' && ex.stack != null) ? + ex.message : ex; + return Promise.resolve(onError(data, ex)); + } + finally { + this.clear(); + } + } +}; + +/** + * Designed to be called from context.updateExec(). Acts just like updateExec() + * except that it also calls onExternalUpdate() to inform the UI of an + * unexpected change to the current command. + */ +Requisition.prototype._contextUpdateExec = function(typed, options) { + var reqOpts = { + document: this.document, + environment: this.environment + }; + var child = new Requisition(this.system, reqOpts); + return child.updateExec(typed, options).then(function(reply) { + child.destroy(); + return reply; + }.bind(child)); +}; + +/** + * A shortcut for calling update, resolving the promise and then exec. + * @param input The string to execute + * @param options Passed to exec + * @return A promise of an output object + */ +Requisition.prototype.updateExec = function(input, options) { + return this.update(input).then(function() { + return this.exec(options); + }.bind(this)); +}; + +exports.Requisition = Requisition; + +/** + * A simple object to hold information about the output of a command + */ +function Output(options) { + options = options || {}; + this.command = options.command || ''; + this.args = options.args || {}; + this.typed = options.typed || ''; + this.canonical = options.canonical || ''; + this.hidden = options.hidden === true ? true : false; + + this.type = undefined; + this.data = undefined; + this.completed = false; + this.error = false; + this.start = new Date(); + + this.promise = new Promise(function(resolve, reject) { + this._resolve = resolve; + }.bind(this)); +} + +/** + * Called when there is data to display, and the command has finished executing + * See changed() for details on parameters. + */ +Output.prototype.complete = function(data, error) { + this.end = new Date(); + this.completed = true; + this.error = error; + + if (data != null && data.isTypedData) { + this.data = data.data; + this.type = data.type; + } + else { + this.data = data; + this.type = this.command.returnType; + if (this.type == null) { + this.type = (this.data == null) ? 'undefined' : typeof this.data; + } + } + + if (this.type === 'object') { + throw new Error('No type from output of ' + this.typed); + } + + this._resolve(); +}; + +/** + * Call converters.convert using the data in this Output object + */ +Output.prototype.convert = function(type, conversionContext) { + var converters = conversionContext.system.converters; + return converters.convert(this.data, this.type, type, conversionContext); +}; + +Output.prototype.toJson = function() { + // Exceptions don't stringify, so we try a bit harder + var data = this.data; + if (this.error && JSON.stringify(this.data) === '{}') { + data = { + columnNumber: data.columnNumber, + fileName: data.fileName, + lineNumber: data.lineNumber, + message: data.message, + stack: data.stack + }; + } + + return { + typed: this.typed, + type: this.type, + data: data, + isError: this.error + }; +}; + +exports.Output = Output; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/clear.js b/devtools/shared/gcli/source/lib/gcli/commands/clear.js new file mode 100644 index 000000000..8f9327021 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/clear.js @@ -0,0 +1,59 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var l10n = require('../util/l10n'); + +exports.items = [ + { + // A command to clear the output area + item: 'command', + runAt: 'client', + name: 'clear', + description: l10n.lookup('clearDesc'), + returnType: 'clearoutput', + exec: function(args, context) { } + }, + { + item: 'converter', + from: 'clearoutput', + to: 'view', + exec: function(ignore, conversionContext) { + return { + html: '', + data: { + onload: function(ev) { + // element starts off being the span above, and we walk up the + // tree looking for the terminal + var element = ev.target; + while (element != null && element.terminal == null) { + element = element.parentElement; + } + + if (element == null) { + // This is only an event handler on a completed command + // So we're relying on this showing up in the console + throw new Error('Failed to find clear'); + } + + element.terminal.clear(); + } + } + }; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/commands.js b/devtools/shared/gcli/source/lib/gcli/commands/commands.js new file mode 100644 index 000000000..67793b2dc --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/commands.js @@ -0,0 +1,570 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); +var l10n = require('../util/l10n'); + +/** + * Implement the localization algorithm for any documentation objects (i.e. + * description and manual) in a command. + * @param data The data assigned to a description or manual property + * @param onUndefined If data == null, should we return the data untouched or + * lookup a 'we don't know' key in it's place. + */ +function lookup(data, onUndefined) { + if (data == null) { + if (onUndefined) { + return l10n.lookup(onUndefined); + } + + return data; + } + + if (typeof data === 'string') { + return data; + } + + if (typeof data === 'object') { + if (data.key) { + return l10n.lookup(data.key); + } + + var locales = l10n.getPreferredLocales(); + var translated; + locales.some(function(locale) { + translated = data[locale]; + return translated != null; + }); + if (translated != null) { + return translated; + } + + console.error('Can\'t find locale in descriptions: ' + + 'locales=' + JSON.stringify(locales) + ', ' + + 'description=' + JSON.stringify(data)); + return '(No description)'; + } + + return l10n.lookup(onUndefined); +} + + +/** + * The command object is mostly just setup around a commandSpec (as passed to + * Commands.add()). + */ +function Command(types, commandSpec) { + Object.keys(commandSpec).forEach(function(key) { + this[key] = commandSpec[key]; + }, this); + + if (!this.name) { + throw new Error('All registered commands must have a name'); + } + + if (this.params == null) { + this.params = []; + } + if (!Array.isArray(this.params)) { + throw new Error('command.params must be an array in ' + this.name); + } + + this.hasNamedParameters = false; + this.description = 'description' in this ? this.description : undefined; + this.description = lookup(this.description, 'canonDescNone'); + this.manual = 'manual' in this ? this.manual : undefined; + this.manual = lookup(this.manual); + + // At this point this.params has nested param groups. We want to flatten it + // out and replace the param object literals with Parameter objects + var paramSpecs = this.params; + this.params = []; + this.paramGroups = {}; + this._shortParams = {}; + + var addParam = function(param) { + var groupName = param.groupName || l10n.lookup('canonDefaultGroupName'); + this.params.push(param); + if (!this.paramGroups.hasOwnProperty(groupName)) { + this.paramGroups[groupName] = []; + } + this.paramGroups[groupName].push(param); + }.bind(this); + + // Track if the user is trying to mix default params and param groups. + // All the non-grouped parameters must come before all the param groups + // because non-grouped parameters can be assigned positionally, so their + // index is important. We don't want 'holes' in the order caused by + // parameter groups. + var usingGroups = false; + + // In theory this could easily be made recursive, so param groups could + // contain nested param groups. Current thinking is that the added + // complexity for the UI probably isn't worth it, so this implementation + // prevents nesting. + paramSpecs.forEach(function(spec) { + if (!spec.group) { + var param = new Parameter(types, spec, this, null); + addParam(param); + + if (!param.isPositionalAllowed) { + this.hasNamedParameters = true; + } + + if (usingGroups && param.groupName == null) { + throw new Error('Parameters can\'t come after param groups.' + + ' Ignoring ' + this.name + '/' + spec.name); + } + + if (param.groupName != null) { + usingGroups = true; + } + } + else { + spec.params.forEach(function(ispec) { + var param = new Parameter(types, ispec, this, spec.group); + addParam(param); + + if (!param.isPositionalAllowed) { + this.hasNamedParameters = true; + } + }, this); + + usingGroups = true; + } + }, this); + + this.params.forEach(function(param) { + if (param.short != null) { + if (this._shortParams[param.short] != null) { + throw new Error('Multiple params using short name ' + param.short); + } + this._shortParams[param.short] = param; + } + }, this); +} + +/** + * JSON serializer that avoids non-serializable data + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. + */ +Command.prototype.toJson = function(customProps) { + var json = { + item: 'command', + name: this.name, + params: this.params.map(function(param) { return param.toJson(); }), + returnType: this.returnType, + isParent: (this.exec == null) + }; + + if (this.description !== l10n.lookup('canonDescNone')) { + json.description = this.description; + } + if (this.manual != null) { + json.manual = this.manual; + } + if (this.hidden != null) { + json.hidden = this.hidden; + } + + if (Array.isArray(customProps)) { + customProps.forEach(function(prop) { + if (this[prop] != null) { + json[prop] = this[prop]; + } + }.bind(this)); + } + + return json; +}; + +/** + * Easy way to lookup parameters by full name + */ +Command.prototype.getParameterByName = function(name) { + var reply; + this.params.forEach(function(param) { + if (param.name === name) { + reply = param; + } + }); + return reply; +}; + +/** + * Easy way to lookup parameters by short name + */ +Command.prototype.getParameterByShortName = function(short) { + return this._shortParams[short]; +}; + +exports.Command = Command; + + +/** + * A wrapper for a paramSpec so we can sort out shortened versions names for + * option switches + */ +function Parameter(types, paramSpec, command, groupName) { + this.command = command || { name: 'unnamed' }; + this.paramSpec = paramSpec; + this.name = this.paramSpec.name; + this.type = this.paramSpec.type; + this.short = this.paramSpec.short; + + if (this.short != null && !/[0-9A-Za-z]/.test(this.short)) { + throw new Error('\'short\' value must be a single alphanumeric digit.'); + } + + this.groupName = groupName; + if (this.groupName != null) { + if (this.paramSpec.option != null) { + throw new Error('Can\'t have a "option" property in a nested parameter'); + } + } + else { + if (this.paramSpec.option != null) { + this.groupName = (this.paramSpec.option === true) ? + l10n.lookup('canonDefaultGroupName') : + '' + this.paramSpec.option; + } + } + + if (!this.name) { + throw new Error('In ' + this.command.name + + ': all params must have a name'); + } + + var typeSpec = this.type; + this.type = types.createType(typeSpec); + if (this.type == null) { + console.error('Known types: ' + types.getTypeNames().join(', ')); + throw new Error('In ' + this.command.name + '/' + this.name + + ': can\'t find type for: ' + JSON.stringify(typeSpec)); + } + + // boolean parameters have an implicit defaultValue:false, which should + // not be changed. See the docs. + if (this.type.name === 'boolean' && + this.paramSpec.defaultValue !== undefined) { + throw new Error('In ' + this.command.name + '/' + this.name + + ': boolean parameters can not have a defaultValue.' + + ' Ignoring'); + } + + // All parameters that can only be set via a named parameter must have a + // non-undefined default value + if (!this.isPositionalAllowed && this.paramSpec.defaultValue === undefined && + this.type.getBlank == null && this.type.name !== 'boolean') { + throw new Error('In ' + this.command.name + '/' + this.name + + ': Missing defaultValue for optional parameter.'); + } + + if (this.paramSpec.defaultValue !== undefined) { + this.defaultValue = this.paramSpec.defaultValue; + } + else { + Object.defineProperty(this, 'defaultValue', { + get: function() { + return this.type.getBlank().value; + }, + enumerable: true + }); + } + + // Resolve the documentation + this.manual = lookup(this.paramSpec.manual); + this.description = lookup(this.paramSpec.description, 'canonDescNone'); + + // Is the user required to enter data for this parameter? (i.e. has + // defaultValue been set to something other than undefined) + // TODO: When the defaultValue comes from type.getBlank().value (see above) + // then perhaps we should set using something like + // isDataRequired = (type.getBlank().status !== VALID) + this.isDataRequired = (this.defaultValue === undefined); + + // Are we allowed to assign data to this parameter using positional + // parameters? + this.isPositionalAllowed = this.groupName == null; +} + +/** + * Does the given name uniquely identify this param (among the other params + * in this command) + * @param name The name to check + */ +Parameter.prototype.isKnownAs = function(name) { + return (name === '--' + this.name) || (name === '-' + this.short); +}; + +/** + * Reflect the paramSpec 'hidden' property (dynamically so it can change) + */ +Object.defineProperty(Parameter.prototype, 'hidden', { + get: function() { + return this.paramSpec.hidden; + }, + enumerable: true +}); + +/** + * JSON serializer that avoids non-serializable data + */ +Parameter.prototype.toJson = function() { + var json = { + name: this.name, + type: this.type.getSpec(this.command.name, this.name), + short: this.short + }; + + // Values do not need to be serializable, so we don't try. For the client + // side (which doesn't do any executing) we don't actually care what the + // default value is, just that it exists + if (this.paramSpec.defaultValue !== undefined) { + json.defaultValue = {}; + } + if (this.paramSpec.description != null) { + json.description = this.paramSpec.description; + } + if (this.paramSpec.manual != null) { + json.manual = this.paramSpec.manual; + } + if (this.paramSpec.hidden != null) { + json.hidden = this.paramSpec.hidden; + } + + // groupName can be set outside a paramSpec, (e.g. in grouped parameters) + // but it works like 'option' does so we use 'option' for groupNames + if (this.groupName != null || this.paramSpec.option != null) { + json.option = this.groupName || this.paramSpec.option; + } + + return json; +}; + +exports.Parameter = Parameter; + + +/** + * A store for a list of commands + * @param types Each command uses a set of Types to parse its parameters so the + * Commands container needs access to the list of available types. + * @param location String that, if set will force all commands to have a + * matching runAt property to be accepted + */ +function Commands(types, location) { + this.types = types; + this.location = location; + + // A lookup hash of our registered commands + this._commands = {}; + // A sorted list of command names, we regularly want them in order, so pre-sort + this._commandNames = []; + // A lookup of the original commandSpecs by command name + this._commandSpecs = {}; + + // Enable people to be notified of changes to the list of commands + this.onCommandsChange = util.createEvent('commands.onCommandsChange'); +} + +/** + * Add a command to the list of known commands. + * @param commandSpec The command and its metadata. + * @return The new command, or null if a location property has been set and the + * commandSpec doesn't have a matching runAt property. + */ +Commands.prototype.add = function(commandSpec) { + if (this.location != null && commandSpec.runAt != null && + commandSpec.runAt !== this.location) { + return; + } + + if (this._commands[commandSpec.name] != null) { + // Roughly commands.remove() without the event call, which we do later + delete this._commands[commandSpec.name]; + this._commandNames = this._commandNames.filter(function(test) { + return test !== commandSpec.name; + }); + } + + var command = new Command(this.types, commandSpec); + this._commands[commandSpec.name] = command; + this._commandNames.push(commandSpec.name); + this._commandNames.sort(); + + this._commandSpecs[commandSpec.name] = commandSpec; + + this.onCommandsChange(); + return command; +}; + +/** + * Remove an individual command. The opposite of Commands.add(). + * Removing a non-existent command is a no-op. + * @param commandOrName Either a command name or the command itself. + * @return true if a command was removed, false otherwise. + */ +Commands.prototype.remove = function(commandOrName) { + var name = typeof commandOrName === 'string' ? + commandOrName : + commandOrName.name; + + if (!this._commands[name]) { + return false; + } + + // See start of commands.add if changing this code + delete this._commands[name]; + delete this._commandSpecs[name]; + this._commandNames = this._commandNames.filter(function(test) { + return test !== name; + }); + + this.onCommandsChange(); + return true; +}; + +/** + * Retrieve a command by name + * @param name The name of the command to retrieve + */ +Commands.prototype.get = function(name) { + // '|| undefined' is to silence 'reference to undefined property' warnings + return this._commands[name] || undefined; +}; + +/** + * Get an array of all the registered commands. + */ +Commands.prototype.getAll = function() { + return Object.keys(this._commands).map(function(name) { + return this._commands[name]; + }, this); +}; + +/** + * Get access to the stored commandMetaDatas (i.e. before they were made into + * instances of Command/Parameters) so we can remote them. + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. + */ +Commands.prototype.getCommandSpecs = function(customProps) { + var commandSpecs = []; + + Object.keys(this._commands).forEach(function(name) { + var command = this._commands[name]; + if (!command.noRemote) { + commandSpecs.push(command.toJson(customProps)); + } + }.bind(this)); + + return commandSpecs; +}; + +/** + * Add a set of commands that are executed somewhere else, optionally with a + * command prefix to distinguish these commands from a local set of commands. + * @param commandSpecs Presumably as obtained from getCommandSpecs + * @param remoter Function to call on exec of a new remote command. This is + * defined just like an exec function (i.e. that takes args/context as params + * and returns a promise) with one extra feature, that the context includes a + * 'commandName' property that contains the original command name. + * @param prefix The name prefix that we assign to all command names + * @param to URL-like string that describes where the commands are executed. + * This is to complete the parent command description. + */ +Commands.prototype.addProxyCommands = function(commandSpecs, remoter, prefix, to) { + if (prefix != null) { + if (this._commands[prefix] != null) { + throw new Error(l10n.lookupFormat('canonProxyExists', [ prefix ])); + } + + // We need to add the parent command so all the commands from the other + // system have a parent + this.add({ + name: prefix, + isProxy: true, + description: l10n.lookupFormat('canonProxyDesc', [ to ]), + manual: l10n.lookupFormat('canonProxyManual', [ to ]) + }); + } + + commandSpecs.forEach(function(commandSpec) { + var originalName = commandSpec.name; + if (!commandSpec.isParent) { + commandSpec.exec = function(args, context) { + context.commandName = originalName; + return remoter(args, context); + }.bind(this); + } + + if (prefix != null) { + commandSpec.name = prefix + ' ' + commandSpec.name; + } + commandSpec.isProxy = true; + this.add(commandSpec); + }.bind(this)); +}; + +/** + * Remove a set of commands added with addProxyCommands. + * @param prefix The name prefix that we assign to all command names + */ +Commands.prototype.removeProxyCommands = function(prefix) { + var toRemove = []; + Object.keys(this._commandSpecs).forEach(function(name) { + if (name.indexOf(prefix) === 0) { + toRemove.push(name); + } + }.bind(this)); + + var removed = []; + toRemove.forEach(function(name) { + var command = this.get(name); + if (command.isProxy) { + this.remove(name); + removed.push(name); + } + else { + console.error('Skipping removal of \'' + name + + '\' because it is not a proxy command.'); + } + }.bind(this)); + + return removed; +}; + +exports.Commands = Commands; + +/** + * CommandOutputManager stores the output objects generated by executed + * commands. + * + * CommandOutputManager is exposed to the the outside world and could (but + * shouldn't) be used before gcli.startup() has been called. + * This could should be defensive to that where possible, and we should + * certainly document if the use of it or similar will fail if used too soon. + */ +function CommandOutputManager() { + this.onOutput = util.createEvent('CommandOutputManager.onOutput'); +} + +exports.CommandOutputManager = CommandOutputManager; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/context.js b/devtools/shared/gcli/source/lib/gcli/commands/context.js new file mode 100644 index 000000000..ad1f87ee8 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/context.js @@ -0,0 +1,62 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var l10n = require('../util/l10n'); +var cli = require('../cli'); + +/** + * 'context' command + */ +var context = { + item: 'command', + name: 'context', + description: l10n.lookup('contextDesc'), + manual: l10n.lookup('contextManual'), + params: [ + { + name: 'prefix', + type: 'command', + description: l10n.lookup('contextPrefixDesc'), + defaultValue: null + } + ], + returnType: 'string', + // The context command is client only because it's essentially sugar for + // typing commands. When there is a command prefix in action, it is the job + // of the remoter to add the prefix to the typed strings that are sent for + // remote execution + noRemote: true, + exec: function echo(args, context) { + var requisition = cli.getMapping(context).requisition; + + if (args.prefix == null) { + requisition.prefix = null; + return l10n.lookup('contextEmptyReply'); + } + + if (args.prefix.exec != null) { + throw new Error(l10n.lookupFormat('contextNotParentError', + [ args.prefix.name ])); + } + + requisition.prefix = args.prefix.name; + return l10n.lookupFormat('contextReply', [ args.prefix.name ]); + } +}; + +exports.items = [ context ]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/help.js b/devtools/shared/gcli/source/lib/gcli/commands/help.js new file mode 100644 index 000000000..317f80240 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/help.js @@ -0,0 +1,387 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var l10n = require('../util/l10n'); +var cli = require('../cli'); + +/** + * Add an 'paramGroups' accessor to a command metadata object to sort the + * params into groups according to the option of the param. + */ +function addParamGroups(command) { + Object.defineProperty(command, 'paramGroups', { + get: function() { + var paramGroups = {}; + this.params.forEach(function(param) { + var groupName = param.option || l10n.lookup('canonDefaultGroupName'); + if (paramGroups[groupName] == null) { + paramGroups[groupName] = []; + } + paramGroups[groupName].push(param); + }); + return paramGroups; + }, + enumerable: true + }); +} + +/** + * Get a data block for the help_man.html/help_man.txt templates + */ +function getHelpManData(commandData, context) { + // Filter out hidden parameters + commandData.command.params = commandData.command.params.filter( + param => !param.hidden + ); + + addParamGroups(commandData.command); + commandData.subcommands.forEach(addParamGroups); + + return { + l10n: l10n.propertyLookup, + onclick: context.update, + ondblclick: context.updateExec, + describe: function(item) { + return item.manual || item.description; + }, + getTypeDescription: function(param) { + var input = ''; + if (param.defaultValue === undefined) { + input = l10n.lookup('helpManRequired'); + } + else if (param.defaultValue === null) { + input = l10n.lookup('helpManOptional'); + } + else { + // We need defaultText to work the text version of defaultValue + input = l10n.lookupFormat('helpManOptional'); + /* + var val = param.type.stringify(param.defaultValue); + input = Promise.resolve(val).then(function(defaultValue) { + return l10n.lookupFormat('helpManDefault', [ defaultValue ]); + }.bind(this)); + */ + } + + return Promise.resolve(input).then(function(defaultDescr) { + return '(' + (param.type.name || param.type) + ', ' + defaultDescr + ')'; + }.bind(this)); + }, + getSynopsis: function(param) { + var name = param.name + (param.short ? '|-' + param.short : ''); + if (param.option == null) { + return param.defaultValue !== undefined ? + '[' + name + ']' : + '<' + name + '>'; + } + else { + return param.type === 'boolean' || param.type.name === 'boolean' ? + '[--' + name + ']' : + '[--' + name + ' ...]'; + } + }, + command: commandData.command, + subcommands: commandData.subcommands + }; +} + +/** + * Get a data block for the help_list.html/help_list.txt templates + */ +function getHelpListData(commandsData, context) { + commandsData.commands.forEach(addParamGroups); + + var heading; + if (commandsData.commands.length === 0) { + heading = l10n.lookupFormat('helpListNone', [ commandsData.prefix ]); + } + else if (commandsData.prefix == null) { + heading = l10n.lookup('helpListAll'); + } + else { + heading = l10n.lookupFormat('helpListPrefix', [ commandsData.prefix ]); + } + + return { + l10n: l10n.propertyLookup, + includeIntro: commandsData.prefix == null, + heading: heading, + onclick: context.update, + ondblclick: context.updateExec, + matchingCommands: commandsData.commands + }; +} + +/** + * Create a block of data suitable to be passed to the help_list.html template + */ +function getMatchingCommands(context, prefix) { + var commands = cli.getMapping(context).requisition.system.commands; + var reply = commands.getAll().filter(function(command) { + if (command.hidden) { + return false; + } + + if (prefix && command.name.indexOf(prefix) !== 0) { + // Filtered out because they don't match the search + return false; + } + if (!prefix && command.name.indexOf(' ') != -1) { + // We don't show sub commands with plain 'help' + return false; + } + return true; + }); + + reply.sort(function(c1, c2) { + return c1.name.localeCompare(c2.name); + }); + + reply = reply.map(function(command) { + return command.toJson(); + }); + + return reply; +} + +/** + * Find all the sub commands of the given command + */ +function getSubCommands(context, command) { + var commands = cli.getMapping(context).requisition.system.commands; + var subcommands = commands.getAll().filter(function(subcommand) { + return subcommand.name.indexOf(command.name) === 0 && + subcommand.name !== command.name && + !subcommand.hidden; + }); + + subcommands.sort(function(c1, c2) { + return c1.name.localeCompare(c2.name); + }); + + subcommands = subcommands.map(function(subcommand) { + return subcommand.toJson(); + }); + + return subcommands; +} + +var helpCss = '' + + '.gcli-help-name {\n' + + ' text-align: end;\n' + + '}\n' + + '\n' + + '.gcli-help-arrow {\n' + + ' color: #AAA;\n' + + '}\n' + + '\n' + + '.gcli-help-description {\n' + + ' margin: 0 20px;\n' + + ' padding: 0;\n' + + '}\n' + + '\n' + + '.gcli-help-parameter {\n' + + ' margin: 0 30px;\n' + + ' padding: 0;\n' + + '}\n' + + '\n' + + '.gcli-help-header {\n' + + ' margin: 10px 0 6px;\n' + + '}\n'; + +exports.items = [ + { + // 'help' command + item: 'command', + name: 'help', + runAt: 'client', + description: l10n.lookup('helpDesc'), + manual: l10n.lookup('helpManual'), + params: [ + { + name: 'search', + type: 'string', + description: l10n.lookup('helpSearchDesc'), + manual: l10n.lookup('helpSearchManual3'), + defaultValue: null + } + ], + + exec: function(args, context) { + var commands = cli.getMapping(context).requisition.system.commands; + var command = commands.get(args.search); + if (command) { + return context.typedData('commandData', { + command: command.toJson(), + subcommands: getSubCommands(context, command) + }); + } + + return context.typedData('commandsData', { + prefix: args.search, + commands: getMatchingCommands(context, args.search) + }); + } + }, + { + // Convert a command into an HTML man page + item: 'converter', + from: 'commandData', + to: 'view', + exec: function(commandData, context) { + return { + html: + '

\n' + + '

\n' + + ' ${l10n.helpManSynopsis}:\n' + + ' \n' + + ' ${command.name}\n' + + ' ${getSynopsis(param)} \n' + + ' \n' + + '

\n' + + '\n' + + '

${describe(command)}

\n' + + '\n' + + '
\n' + + '
\n' + + '

${groupName}:

\n' + + '
    \n' + + '
  • ${l10n.helpManNone}
  • \n' + + '
  • \n' + + ' ${getSynopsis(param)} ${getTypeDescription(param)}\n' + + '
    \n' + + ' ${describe(param)}\n' + + '
  • \n' + + '
\n' + + '
\n' + + '
\n' + + '\n' + + '
\n' + + '

${l10n.subCommands}:

\n' + + '
    \n' + + '
  • ${l10n.subcommandsNone}
  • \n' + + '
  • \n' + + ' ${subcommand.name}: ${subcommand.description}\n' + + ' \n' + + ' help ${subcommand.name}\n' + + ' \n' + + '
  • \n' + + '
\n' + + '
\n' + + '\n' + + '
\n', + options: { allowEval: true, stack: 'commandData->view' }, + data: getHelpManData(commandData, context), + css: helpCss, + cssId: 'gcli-help' + }; + } + }, + { + // Convert a command into a string based man page + item: 'converter', + from: 'commandData', + to: 'stringView', + exec: function(commandData, context) { + return { + html: + '
## ${command.name}\n' + + '\n' + + '# ${l10n.helpManSynopsis}: ${command.name} ${getSynopsis(param)} \n' + + '\n' + + '# ${l10n.helpManDescription}:\n' + + '\n' + + '${command.manual || command.description}\n' + + '\n' + + '\n' + + '# ${groupName}:\n' + + '\n' + + '${l10n.helpManNone}* ${param.name}: ${getTypeDescription(param)}\n' + + ' ${param.manual || param.description}\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '# ${l10n.subCommands}:\n' + + '\n' + + '${l10n.subcommandsNone}\n' + + '* ${subcommand.name}: ${subcommand.description}\n' + + '\n' + + '
\n', + options: { allowEval: true, stack: 'commandData->stringView' }, + data: getHelpManData(commandData, context) + }; + } + }, + { + // Convert a list of commands into a formatted list + item: 'converter', + from: 'commandsData', + to: 'view', + exec: function(commandsData, context) { + return { + html: + '
\n' + + '
\n' + + '

${l10n.helpIntro}

\n' + + '
\n' + + '\n' + + '

${heading}

\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '
${command.name}-\n' + + ' ${command.description}\n' + + ' help ${command.name}\n' + + '
\n' + + '
\n', + options: { allowEval: true, stack: 'commandsData->view' }, + data: getHelpListData(commandsData, context), + css: helpCss, + cssId: 'gcli-help' + }; + } + }, + { + // Convert a list of commands into a formatted list + item: 'converter', + from: 'commandsData', + to: 'stringView', + exec: function(commandsData, context) { + return { + html: + '
## ${l10n.helpIntro}\n' +
+          '\n' +
+          '# ${heading}\n' +
+          '\n' +
+          '${command.name} → ${command.description}\n' +
+          '
', + options: { allowEval: true, stack: 'commandsData->stringView' }, + data: getHelpListData(commandsData, context) + }; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/mocks.js b/devtools/shared/gcli/source/lib/gcli/commands/mocks.js new file mode 100644 index 000000000..12b2ade86 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/mocks.js @@ -0,0 +1,68 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var cli = require('../cli'); +var mockCommands = require('../test/mockCommands'); +var mockFileCommands = require('../test/mockFileCommands'); +var mockSettings = require('../test/mockSettings'); + +var isNode = (typeof(process) !== 'undefined' && + process.title.indexOf('node') != -1); + +exports.items = [ + { + item: 'command', + name: 'mocks', + description: 'Add/remove mock commands', + params: [ + { + name: 'included', + type: { + name: 'selection', + data: [ 'on', 'off' ] + }, + description: 'Turn mock commands on or off', + } + ], + returnType: 'string', + + exec: function(args, context) { + var requisition = cli.getMapping(context).requisition; + this[args.included](requisition); + return 'Mock commands are now ' + args.included; + }, + + on: function(requisition) { + mockCommands.setup(requisition); + mockSettings.setup(requisition.system); + + if (isNode) { + mockFileCommands.setup(requisition); + } + }, + + off: function(requisition) { + mockCommands.shutdown(requisition); + mockSettings.shutdown(requisition.system); + + if (isNode) { + mockFileCommands.shutdown(requisition); + } + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/moz.build b/devtools/shared/gcli/source/lib/gcli/commands/moz.build new file mode 100644 index 000000000..8cf5f0e96 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'clear.js', + 'commands.js', + 'context.js', + 'help.js', + 'mocks.js', + 'pref.js', + 'preflist.js', + 'test.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/commands/pref.js b/devtools/shared/gcli/source/lib/gcli/commands/pref.js new file mode 100644 index 000000000..387b1f8e4 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/pref.js @@ -0,0 +1,93 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var l10n = require('../util/l10n'); + +exports.items = [ + { + // 'pref' command + item: 'command', + name: 'pref', + description: l10n.lookup('prefDesc'), + manual: l10n.lookup('prefManual') + }, + { + // 'pref show' command + item: 'command', + name: 'pref show', + runAt: 'client', + description: l10n.lookup('prefShowDesc'), + manual: l10n.lookup('prefShowManual'), + params: [ + { + name: 'setting', + type: 'setting', + description: l10n.lookup('prefShowSettingDesc'), + manual: l10n.lookup('prefShowSettingManual') + } + ], + exec: function(args, context) { + return l10n.lookupFormat('prefShowSettingValue', + [ args.setting.name, args.setting.value ]); + } + }, + { + // 'pref set' command + item: 'command', + name: 'pref set', + runAt: 'client', + description: l10n.lookup('prefSetDesc'), + manual: l10n.lookup('prefSetManual'), + params: [ + { + name: 'setting', + type: 'setting', + description: l10n.lookup('prefSetSettingDesc'), + manual: l10n.lookup('prefSetSettingManual') + }, + { + name: 'value', + type: 'settingValue', + description: l10n.lookup('prefSetValueDesc'), + manual: l10n.lookup('prefSetValueManual') + } + ], + exec: function(args, context) { + args.setting.value = args.value; + } + }, + { + // 'pref reset' command + item: 'command', + name: 'pref reset', + runAt: 'client', + description: l10n.lookup('prefResetDesc'), + manual: l10n.lookup('prefResetManual'), + params: [ + { + name: 'setting', + type: 'setting', + description: l10n.lookup('prefResetSettingDesc'), + manual: l10n.lookup('prefResetSettingManual') + } + ], + exec: function(args, context) { + args.setting.setDefault(); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/preflist.js b/devtools/shared/gcli/source/lib/gcli/commands/preflist.js new file mode 100644 index 000000000..b6ca04a0b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/preflist.js @@ -0,0 +1,214 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var l10n = require('../util/l10n'); + +/** + * Format a list of settings for display + */ +var prefsViewConverter = { + item: 'converter', + from: 'prefsData', + to: 'view', + exec: function(prefsData, conversionContext) { + var prefList = new PrefList(prefsData, conversionContext); + return { + html: + '
\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '
${l10n.prefOutputName}${l10n.prefOutputValue}
\n' + + '
\n' + + ' \n' + + '
\n' + + '
\n' + + '
\n', + data: prefList, + options: { + blankNullUndefined: true, + allowEval: true, + stack: 'prefsData->view' + }, + css: + '.gcli-pref-list-scroller {\n' + + ' max-height: 200px;\n' + + ' overflow-y: auto;\n' + + ' overflow-x: hidden;\n' + + ' display: inline-block;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-table {\n' + + ' width: 500px;\n' + + ' table-layout: fixed;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-table tr > th {\n' + + ' text-align: left;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-table tr > td {\n' + + ' text-overflow: elipsis;\n' + + ' word-wrap: break-word;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-name {\n' + + ' width: 70%;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-command {\n' + + ' display: none;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-row:hover .gcli-pref-list-command {\n' + + ' /* \'pref list\' is a bit broken and unimportant. Band-aid follows */\n' + + ' /* display: inline-block; */\n' + + '}\n', + cssId: 'gcli-pref-list' + }; + } +}; + +/** + * Format a list of settings for display + */ +var prefsStringConverter = { + item: 'converter', + from: 'prefsData', + to: 'string', + exec: function(prefsData, conversionContext) { + var reply = ''; + prefsData.settings.forEach(function(setting) { + reply += setting.name + ' -> ' + setting.value + '\n'; + }); + return reply; + } +}; + +/** + * 'pref list' command + */ +var prefList = { + item: 'command', + name: 'pref list', + description: l10n.lookup('prefListDesc'), + manual: l10n.lookup('prefListManual'), + params: [ + { + name: 'search', + type: 'string', + defaultValue: null, + description: l10n.lookup('prefListSearchDesc'), + manual: l10n.lookup('prefListSearchManual') + } + ], + returnType: 'prefsData', + exec: function(args, context) { + return new Promise(function(resolve, reject) { + // This can be slow, get out of the way of the main thread + setTimeout(function() { + var prefsData = { + settings: context.system.settings.getAll(args.search), + search: args.search + }; + resolve(prefsData); + }.bind(this), 10); + }); + } +}; + +/** + * A manager for our version of about:config + */ +function PrefList(prefsData, conversionContext) { + this.search = prefsData.search; + this.settings = prefsData.settings; + this.conversionContext = conversionContext; + + this.onLoad = this.onLoad.bind(this); +} + +/** + * A load event handler registered by the template engine so we can load the + * inner document + */ +PrefList.prototype.onLoad = function(element) { + var table = element.querySelector('.gcli-pref-list-table'); + this.updateTable(table); + return ''; +}; + +/** + * Forward localization lookups + */ +PrefList.prototype.l10n = l10n.propertyLookup; + +/** + * Called from the template onkeyup for the filter element + */ +PrefList.prototype.updateTable = function(table) { + var view = this.conversionContext.createView({ + html: + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '
${setting.name}\n' + + ' ${setting.value}\n' + + ' [Edit]\n' + + '
\n', + options: { blankNullUndefined: true, stack: 'prefsData#inner' }, + data: this + }); + + view.appendTo(table, true); +}; + +PrefList.prototype.onFilterChange = function(ev) { + if (ev.target.value !== this.search) { + this.search = ev.target.value; + + var root = ev.target.parentNode.parentNode; + var table = root.querySelector('.gcli-pref-list-table'); + this.updateTable(table); + } +}; + +PrefList.prototype.onSetClick = function(ev) { + var typed = ev.currentTarget.getAttribute('data-command'); + this.conversionContext.update(typed); +}; + +exports.items = [ prefsViewConverter, prefsStringConverter, prefList ]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/test.js b/devtools/shared/gcli/source/lib/gcli/commands/test.js new file mode 100644 index 000000000..90f56c361 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/test.js @@ -0,0 +1,215 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var examiner = require('../testharness/examiner'); +var stati = require('../testharness/status').stati; +var helpers = require('../test/helpers'); +var suite = require('../test/suite'); +var cli = require('../cli'); +var Requisition = require('../cli').Requisition; +var createRequisitionAutomator = require('../test/automators/requisition').createRequisitionAutomator; + +var isNode = (typeof(process) !== 'undefined' && + process.title.indexOf('node') != -1); + +suite.init(isNode); + +exports.optionsContainer = []; + +exports.items = [ + { + item: 'type', + name: 'suite', + parent: 'selection', + cacheable: true, + lookup: function() { + return Object.keys(examiner.suites).map(function(name) { + return { name: name, value: examiner.suites[name] }; + }); + } + }, + { + item: 'command', + name: 'test', + description: 'Run GCLI unit tests', + params: [ + { + name: 'suite', + type: 'suite', + description: 'Test suite to run.', + defaultValue: examiner + }, + { + name: 'usehost', + type: 'boolean', + description: 'Run the unit tests in the host window', + option: true + } + ], + returnType: 'examiner-output', + noRemote: true, + exec: function(args, context) { + if (args.usehost && exports.optionsContainer.length === 0) { + throw new Error('Can\'t use --usehost without injected options'); + } + + var options; + if (args.usehost) { + options = exports.optionsContainer[0]; + } + else { + var env = { + document: document, + window: window + }; + options = { + isNode: isNode, + isFirefox: false, + isPhantomjs: false, + requisition: new Requisition(context.system, { environment: env }) + }; + options.automator = createRequisitionAutomator(options.requisition); + } + + var requisition = options.requisition; + requisition.system.commands.get('mocks').on(requisition); + helpers.resetResponseTimes(); + examiner.reset(); + + return args.suite.run(options).then(function() { + requisition.system.commands.get('mocks').off(requisition); + var output = context.typedData('examiner-output', examiner.toRemote()); + + if (output.data.summary.status === stati.pass) { + return output; + } + else { + cli.logErrors = false; + throw output; + } + }); + } + }, + { + item: 'converter', + from: 'examiner-output', + to: 'string', + exec: function(output, conversionContext) { + return '\n' + examiner.detailedResultLog('NodeJS/NoDom') + + '\n' + helpers.timingSummary; + } + }, + { + item: 'converter', + from: 'examiner-output', + to: 'view', + exec: function(output, conversionContext) { + return { + html: + '
\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '
SuiteTestResultsChecksNotes
${suite.name}${test.title}${test.status.name}${test.checks}\n' + + '
\n' + + ' ${failure.message}\n' + + '
    \n' + + '
  • P1: ${failure.p1}
  • \n' + + '
  • P2: ${failure.p2}
  • \n' + + '
\n' + + '
\n' + + '
Total${summary.status.name}${summary.checks}
\n' + + '
', + css: + '.gcliTestSkipped {\n' + + ' background-color: #EEE;\n' + + ' color: #000;\n' + + '}\n' + + '\n' + + '.gcliTestExecuting {\n' + + ' background-color: #888;\n' + + ' color: #FFF;\n' + + '}\n' + + '\n' + + '.gcliTestWaiting {\n' + + ' background-color: #FFA;\n' + + ' color: #000;\n' + + '}\n' + + '\n' + + '.gcliTestPass {\n' + + ' background-color: #8F8;\n' + + ' color: #000;\n' + + '}\n' + + '\n' + + '.gcliTestFail {\n' + + ' background-color: #F00;\n' + + ' color: #FFF;\n' + + '}\n' + + '\n' + + 'td.gcliTestSuite {\n' + + ' font-family: monospace;\n' + + ' font-size: 90%;\n' + + ' text-align: right;\n' + + '}\n' + + '\n' + + '.gcliTestResults th.gcliTestSuite,\n' + + '.gcliTestResults .gcliTestChecks {\n' + + ' text-align: right;\n' + + '}\n' + + '\n' + + '.gcliTestResults th {\n' + + ' text-align: left;\n' + + '}\n' + + '\n' + + '.gcliTestMessages ul {\n' + + ' margin: 0 0 10px;\n' + + ' padding-left: 20px;\n' + + ' list-style-type: square;\n' + + '}\n', + cssId: 'gcli-test', + data: output, + options: { allowEval: true, stack: 'test.html' } + }; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/connectors/connectors.js b/devtools/shared/gcli/source/lib/gcli/connectors/connectors.js new file mode 100644 index 000000000..f1a6fe339 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/connectors/connectors.js @@ -0,0 +1,157 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +/** + * This is how to implement a connector + * var baseConnector = { + * item: 'connector', + * name: 'foo', + * + * connect: function(url) { + * return Promise.resolve(new FooConnection(url)); + * } + * }; + */ + +/** + * A prototype base for Connectors + */ +function Connection() { +} + +/** + * Add an event listener + */ +Connection.prototype.on = function(event, action) { + if (!this._listeners) { + this._listeners = {}; + } + if (!this._listeners[event]) { + this._listeners[event] = []; + } + this._listeners[event].push(action); +}; + +/** + * Remove an event listener + */ +Connection.prototype.off = function(event, action) { + if (!this._listeners) { + return; + } + var actions = this._listeners[event]; + if (actions) { + this._listeners[event] = actions.filter(function(li) { + return li !== action; + }.bind(this)); + } +}; + +/** + * Emit an event. For internal use only + */ +Connection.prototype._emit = function(event, data) { + if (this._listeners == null || this._listeners[event] == null) { + return; + } + + var listeners = this._listeners[event]; + listeners.forEach(function(listener) { + // Fail fast if we mutate the list of listeners while emitting + if (listeners !== this._listeners[event]) { + throw new Error('Listener list changed while emitting'); + } + + try { + listener.call(null, data); + } + catch (ex) { + console.log('Error calling listeners to ' + event); + console.error(ex); + } + }.bind(this)); +}; + +/** + * Send a message to the other side of the connection + */ +Connection.prototype.call = function(feature, data) { + throw new Error('Not implemented'); +}; + +/** + * Disconnecting a Connection destroys the resources it holds. There is no + * common route back to being connected once this has been called + */ +Connection.prototype.disconnect = function() { + return Promise.resolve(); +}; + +exports.Connection = Connection; + +/** + * A manager for the registered Connectors + */ +function Connectors() { + // This is where we cache the connectors that we know about + this._registered = {}; +} + +/** + * Add a new connector to the cache + */ +Connectors.prototype.add = function(connector) { + this._registered[connector.name] = connector; +}; + +/** + * Remove an existing connector from the cache + */ +Connectors.prototype.remove = function(connector) { + var name = typeof connector === 'string' ? connector : connector.name; + delete this._registered[name]; +}; + +/** + * Get access to the list of known connectors + */ +Connectors.prototype.getAll = function() { + return Object.keys(this._registered).map(function(name) { + return this._registered[name]; + }.bind(this)); +}; + +var defaultConnectorName; + +/** + * Get access to a connector by name. If name is undefined then first try to + * use the same connector that we used last time, and if there was no last + * time, then just use the first registered connector as a default. + */ +Connectors.prototype.get = function(name) { + if (name == null) { + name = (defaultConnectorName == null) ? + Object.keys(this._registered)[0] : + defaultConnectorName; + } + + defaultConnectorName = name; + return this._registered[name]; +}; + +exports.Connectors = Connectors; diff --git a/devtools/shared/gcli/source/lib/gcli/connectors/moz.build b/devtools/shared/gcli/source/lib/gcli/connectors/moz.build new file mode 100644 index 000000000..33fda8fbc --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/connectors/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'connectors.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/converters/basic.js b/devtools/shared/gcli/source/lib/gcli/converters/basic.js new file mode 100644 index 000000000..3cb448e91 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/basic.js @@ -0,0 +1,94 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); + +/** + * Several converters are just data.toString inside a 'p' element + */ +function nodeFromDataToString(data, conversionContext) { + var node = util.createElement(conversionContext.document, 'p'); + node.textContent = data.toString(); + return node; +} + +exports.items = [ + { + item: 'converter', + from: 'string', + to: 'dom', + exec: nodeFromDataToString + }, + { + item: 'converter', + from: 'number', + to: 'dom', + exec: nodeFromDataToString + }, + { + item: 'converter', + from: 'boolean', + to: 'dom', + exec: nodeFromDataToString + }, + { + item: 'converter', + from: 'undefined', + to: 'dom', + exec: function(data, conversionContext) { + return util.createElement(conversionContext.document, 'span'); + } + }, + { + item: 'converter', + from: 'json', + to: 'view', + exec: function(json, context) { + var html = JSON.stringify(json, null, ' ').replace(/\n/g, '
'); + return { + html: '
' + html + '
' + }; + } + }, + { + item: 'converter', + from: 'number', + to: 'string', + exec: function(data) { return '' + data; } + }, + { + item: 'converter', + from: 'boolean', + to: 'string', + exec: function(data) { return '' + data; } + }, + { + item: 'converter', + from: 'undefined', + to: 'string', + exec: function(data) { return ''; } + }, + { + item: 'converter', + from: 'json', + to: 'string', + exec: function(json, conversionContext) { + return JSON.stringify(json, null, ' '); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/converters/converters.js b/devtools/shared/gcli/source/lib/gcli/converters/converters.js new file mode 100644 index 000000000..c054871d6 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/converters.js @@ -0,0 +1,280 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); +var host = require('../util/host'); + +// It's probably easiest to read this bottom to top + +/** + * Best guess at creating a DOM element from random data + */ +var fallbackDomConverter = { + from: '*', + to: 'dom', + exec: function(data, conversionContext) { + return conversionContext.document.createTextNode(data || ''); + } +}; + +/** + * Best guess at creating a string from random data + */ +var fallbackStringConverter = { + from: '*', + to: 'string', + exec: function(data, conversionContext) { + return data == null ? '' : data.toString(); + } +}; + +/** + * Convert a view object to a DOM element + */ +var viewDomConverter = { + item: 'converter', + from: 'view', + to: 'dom', + exec: function(view, conversionContext) { + if (!view.isView) { + view = conversionContext.createView(view); + } + return view.toDom(conversionContext.document); + } +}; + +/** + * Convert a view object to a string + */ +var viewStringConverter = { + item: 'converter', + from: 'view', + to: 'string', + exec: function(view, conversionContext) { + if (!view.isView) { + view = conversionContext.createView(view); + } + return view.toDom(conversionContext.document).textContent; + } +}; + +/** + * Convert a view object to a string + */ +var stringViewStringConverter = { + item: 'converter', + from: 'stringView', + to: 'string', + exec: function(view, conversionContext) { + if (!view.isView) { + view = conversionContext.createView(view); + } + return view.toDom(conversionContext.document).textContent; + } +}; + +/** + * Convert an exception to a DOM element + */ +var errorDomConverter = { + item: 'converter', + from: 'error', + to: 'dom', + exec: function(ex, conversionContext) { + var node = util.createElement(conversionContext.document, 'p'); + node.className = 'gcli-error'; + node.textContent = errorStringConverter.exec(ex, conversionContext); + return node; + } +}; + +/** + * Convert an exception to a string + */ +var errorStringConverter = { + item: 'converter', + from: 'error', + to: 'string', + exec: function(ex, conversionContext) { + if (typeof ex === 'string') { + return ex; + } + if (ex instanceof Error) { + return '' + ex; + } + if (typeof ex.message === 'string') { + return ex.message; + } + return '' + ex; + } +}; + +/** + * Create a new converter by using 2 converters, one after the other + */ +function getChainConverter(first, second) { + if (first.to !== second.from) { + throw new Error('Chain convert impossible: ' + first.to + '!=' + second.from); + } + return { + from: first.from, + to: second.to, + exec: function(data, conversionContext) { + var intermediate = first.exec(data, conversionContext); + return second.exec(intermediate, conversionContext); + } + }; +} + +/** + * A manager for the registered Converters + */ +function Converters() { + // This is where we cache the converters that we know about + this._registered = { + from: {} + }; +} + +/** + * Add a new converter to the cache + */ +Converters.prototype.add = function(converter) { + var fromMatch = this._registered.from[converter.from]; + if (fromMatch == null) { + fromMatch = {}; + this._registered.from[converter.from] = fromMatch; + } + + fromMatch[converter.to] = converter; +}; + +/** + * Remove an existing converter from the cache + */ +Converters.prototype.remove = function(converter) { + var fromMatch = this._registered.from[converter.from]; + if (fromMatch == null) { + return; + } + + if (fromMatch[converter.to] === converter) { + fromMatch[converter.to] = null; + } +}; + +/** + * Work out the best converter that we've got, for a given conversion. + */ +Converters.prototype.get = function(from, to) { + var fromMatch = this._registered.from[from]; + if (fromMatch == null) { + return this._getFallbackConverter(from, to); + } + + var converter = fromMatch[to]; + if (converter == null) { + // Someone is going to love writing a graph search algorithm to work out + // the smallest number of conversions, or perhaps the least 'lossy' + // conversion but for now the only 2 step conversions which we are going to + // special case are foo->view->dom and foo->stringView->string. + if (to === 'dom') { + converter = fromMatch.view; + if (converter != null) { + return getChainConverter(converter, viewDomConverter); + } + } + + if (to === 'string') { + converter = fromMatch.stringView; + if (converter != null) { + return getChainConverter(converter, stringViewStringConverter); + } + converter = fromMatch.view; + if (converter != null) { + return getChainConverter(converter, viewStringConverter); + } + } + + return this._getFallbackConverter(from, to); + } + return converter; +}; + +/** + * Get all the registered converters. Most for debugging + */ +Converters.prototype.getAll = function() { + return Object.keys(this._registered.from).map(function(name) { + return this._registered.from[name]; + }.bind(this)); +}; + +/** + * Helper for get to pick the best fallback converter + */ +Converters.prototype._getFallbackConverter = function(from, to) { + console.error('No converter from ' + from + ' to ' + to + '. Using fallback'); + + if (to === 'dom') { + return fallbackDomConverter; + } + + if (to === 'string') { + return fallbackStringConverter; + } + + throw new Error('No conversion possible from ' + from + ' to ' + to + '.'); +}; + +/** + * Convert some data from one type to another + * @param data The object to convert + * @param from The type of the data right now + * @param to The type that we would like the data in + * @param conversionContext An execution context (i.e. simplified requisition) + * which is often required for access to a document, or createView function + */ +Converters.prototype.convert = function(data, from, to, conversionContext) { + try { + if (from === to) { + return Promise.resolve(data); + } + + var converter = this.get(from, to); + return host.exec(function() { + return converter.exec(data, conversionContext); + }.bind(this)); + } + catch (ex) { + var converter = this.get('error', to); + return host.exec(function() { + return converter.exec(ex, conversionContext); + }.bind(this)); + } +}; + +exports.Converters = Converters; + +/** + * Items for export + */ +exports.items = [ + viewDomConverter, viewStringConverter, stringViewStringConverter, + errorDomConverter, errorStringConverter +]; diff --git a/devtools/shared/gcli/source/lib/gcli/converters/html.js b/devtools/shared/gcli/source/lib/gcli/converters/html.js new file mode 100644 index 000000000..2dea0eb82 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/html.js @@ -0,0 +1,47 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); + +/** + * 'html' means a string containing HTML markup. We use innerHTML to inject + * this into a DOM which has security implications, so this module will not + * be used in all implementations. + */ +exports.items = [ + { + item: 'converter', + from: 'html', + to: 'dom', + exec: function(html, conversionContext) { + var div = util.createElement(conversionContext.document, 'div'); + div.innerHTML = html; + return div; + } + }, + { + item: 'converter', + from: 'html', + to: 'string', + exec: function(html, conversionContext) { + var div = util.createElement(conversionContext.document, 'div'); + div.innerHTML = html; + return div.textContent; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/converters/moz.build b/devtools/shared/gcli/source/lib/gcli/converters/moz.build new file mode 100644 index 000000000..d3a649197 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'basic.js', + 'converters.js', + 'html.js', + 'terminal.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/converters/terminal.js b/devtools/shared/gcli/source/lib/gcli/converters/terminal.js new file mode 100644 index 000000000..a2406c689 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/terminal.js @@ -0,0 +1,56 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); + +/** + * A 'terminal' object is a string or an array of strings, which are typically + * the output from a shell command + */ +exports.items = [ + { + item: 'converter', + from: 'terminal', + to: 'dom', + createTextArea: function(text, conversionContext) { + var node = util.createElement(conversionContext.document, 'textarea'); + node.classList.add('gcli-row-subterminal'); + node.readOnly = true; + node.textContent = text; + return node; + }, + exec: function(data, conversionContext) { + if (Array.isArray(data)) { + var node = util.createElement(conversionContext.document, 'div'); + data.forEach(function(member) { + node.appendChild(this.createTextArea(member, conversionContext)); + }); + return node; + } + return this.createTextArea(data); + } + }, + { + item: 'converter', + from: 'terminal', + to: 'string', + exec: function(data, conversionContext) { + return Array.isArray(data) ? data.join('') : '' + data; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/fields/delegate.js b/devtools/shared/gcli/source/lib/gcli/fields/delegate.js new file mode 100644 index 000000000..a2fa508f0 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/delegate.js @@ -0,0 +1,96 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); +var Field = require('./fields').Field; + +/** + * A field that works with delegate types by delaying resolution until that + * last possible time + */ +function DelegateField(type, options) { + Field.call(this, type, options); + this.options = options; + + this.element = util.createElement(this.document, 'div'); + this.update(); + + this.onFieldChange = util.createEvent('DelegateField.onFieldChange'); +} + +DelegateField.prototype = Object.create(Field.prototype); + +DelegateField.prototype.update = function() { + var subtype = this.type.getType(this.options.requisition.executionContext); + if (typeof subtype.parse !== 'function') { + subtype = this.options.requisition.system.types.createType(subtype); + } + + // It's not clear that we can compare subtypes in this way. + // Perhaps we need a type.equals(...) function + if (subtype === this.subtype) { + return; + } + + if (this.field) { + this.field.destroy(); + } + + this.subtype = subtype; + var fields = this.options.requisition.system.fields; + this.field = fields.get(subtype, this.options); + + util.clearElement(this.element); + this.element.appendChild(this.field.element); +}; + +DelegateField.claim = function(type, context) { + return type.isDelegate ? Field.MATCH : Field.NO_MATCH; +}; + +DelegateField.prototype.destroy = function() { + this.element = undefined; + this.options = undefined; + if (this.field) { + this.field.destroy(); + } + this.subtype = undefined; + Field.prototype.destroy.call(this); +}; + +DelegateField.prototype.setConversion = function(conversion) { + this.field.setConversion(conversion); +}; + +DelegateField.prototype.getConversion = function() { + return this.field.getConversion(); +}; + +Object.defineProperty(DelegateField.prototype, 'isImportant', { + get: function() { + return this.field.isImportant; + }, + enumerable: true +}); + +/** + * Exported items + */ +exports.items = [ + DelegateField +]; diff --git a/devtools/shared/gcli/source/lib/gcli/fields/fields.js b/devtools/shared/gcli/source/lib/gcli/fields/fields.js new file mode 100644 index 000000000..c97184731 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/fields.js @@ -0,0 +1,245 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); + +/** + * A Field is a way to get input for a single parameter. + * This class is designed to be inherited from. It's important that all + * subclasses have a similar constructor signature because they are created + * via Fields.get(...) + * @param type The type to use in conversions + * @param options A set of properties to help fields configure themselves: + * - document: The document we use in calling createElement + * - requisition: The requisition that we're attached to + */ +function Field(type, options) { + this.type = type; + this.document = options.document; + this.requisition = options.requisition; +} + +/** + * Enable registration of fields using addItems + */ +Field.prototype.item = 'field'; + +/** + * Subclasses should assign their element with the DOM node that gets added + * to the 'form'. It doesn't have to be an input node, just something that + * contains it. + */ +Field.prototype.element = undefined; + +/** + * Called from the outside to indicate that the command line has changed and + * the field should update itself + */ +Field.prototype.update = function() { +}; + +/** + * Indicates that this field should drop any resources that it has created + */ +Field.prototype.destroy = function() { + this.messageElement = undefined; + this.document = undefined; + this.requisition = undefined; +}; + +// Note: We could/should probably change Fields from working with Conversions +// to working with Arguments (Tokens), which makes for less calls to parse() + +/** + * Update this field display with the value from this conversion. + * Subclasses should provide an implementation of this function. + */ +Field.prototype.setConversion = function(conversion) { + throw new Error('Field should not be used directly'); +}; + +/** + * Extract a conversion from the values in this field. + * Subclasses should provide an implementation of this function. + */ +Field.prototype.getConversion = function() { + throw new Error('Field should not be used directly'); +}; + +/** + * Set the element where messages and validation errors will be displayed + * @see setMessage() + */ +Field.prototype.setMessageElement = function(element) { + this.messageElement = element; +}; + +/** + * Display a validation message in the UI + */ +Field.prototype.setMessage = function(message) { + if (this.messageElement) { + util.setTextContent(this.messageElement, message || ''); + } +}; + +/** + * Some fields contain information that is more important to the user, for + * example error messages and completion menus. + */ +Field.prototype.isImportant = false; + +/** + * 'static/abstract' method to allow implementations of Field to lay a claim + * to a type. This allows claims of various strength to be weighted up. + * See the Field.*MATCH values. + */ +Field.claim = function(type, context) { + throw new Error('Field should not be used directly'); +}; + +/** + * How good a match is a field for a given type + */ +Field.MATCH = 3; // Good match +Field.DEFAULT = 2; // A default match +Field.BASIC = 1; // OK in an emergency. i.e. assume Strings +Field.NO_MATCH = 0; // This field can't help with the given type + +exports.Field = Field; + + +/** + * A manager for the registered Fields + */ +function Fields() { + // Internal array of known fields + this._fieldCtors = []; +} + +/** + * Add a field definition by field constructor + * @param fieldCtor Constructor function of new Field + */ +Fields.prototype.add = function(fieldCtor) { + if (typeof fieldCtor !== 'function') { + console.error('fields.add erroring on ', fieldCtor); + throw new Error('fields.add requires a Field constructor'); + } + this._fieldCtors.push(fieldCtor); +}; + +/** + * Remove a Field definition + * @param field A previously registered field, specified either with a field + * name or from the field name + */ +Fields.prototype.remove = function(field) { + if (typeof field !== 'string') { + this._fieldCtors = this._fieldCtors.filter(function(test) { + return test !== field; + }); + } + else if (field instanceof Field) { + this.remove(field.name); + } + else { + console.error('fields.remove erroring on ', field); + throw new Error('fields.remove requires an instance of Field'); + } +}; + +/** + * Find the best possible matching field from the specification of the type + * of field required. + * @param type An instance of Type that we will represent + * @param options A set of properties that we should attempt to match, and use + * in the construction of the new field object: + * - document: The document to use in creating new elements + * - requisition: The requisition we're monitoring, + * @return A newly constructed field that best matches the input options + */ +Fields.prototype.get = function(type, options) { + var FieldConstructor; + var highestClaim = -1; + this._fieldCtors.forEach(function(fieldCtor) { + var context = (options.requisition == null) ? + null : options.requisition.executionContext; + var claim = fieldCtor.claim(type, context); + if (claim > highestClaim) { + highestClaim = claim; + FieldConstructor = fieldCtor; + } + }); + + if (!FieldConstructor) { + console.error('Unknown field type ', type, ' in ', this._fieldCtors); + throw new Error('Can\'t find field for ' + type); + } + + if (highestClaim < Field.DEFAULT) { + return new BlankField(type, options); + } + + return new FieldConstructor(type, options); +}; + +/** + * Get all the registered fields. Most for debugging + */ +Fields.prototype.getAll = function() { + return this._fieldCtors.slice(); +}; + +exports.Fields = Fields; + +/** + * For use with delegate types that do not yet have anything to resolve to. + * BlankFields are not for general use. + */ +function BlankField(type, options) { + Field.call(this, type, options); + + this.element = util.createElement(this.document, 'div'); + + this.onFieldChange = util.createEvent('BlankField.onFieldChange'); +} + +BlankField.prototype = Object.create(Field.prototype); + +BlankField.claim = function(type, context) { + return type.name === 'blank' ? Field.MATCH : Field.NO_MATCH; +}; + +BlankField.prototype.destroy = function() { + this.element = undefined; + Field.prototype.destroy.call(this); +}; + +BlankField.prototype.setConversion = function(conversion) { + this.setMessage(conversion.message); +}; + +BlankField.prototype.getConversion = function() { + return this.type.parseString('', this.requisition.executionContext); +}; + +/** + * Items for export + */ +exports.items = [ BlankField ]; diff --git a/devtools/shared/gcli/source/lib/gcli/fields/moz.build b/devtools/shared/gcli/source/lib/gcli/fields/moz.build new file mode 100644 index 000000000..74fa1cc95 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'delegate.js', + 'fields.js', + 'selection.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/fields/selection.js b/devtools/shared/gcli/source/lib/gcli/fields/selection.js new file mode 100644 index 000000000..4f5885777 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/selection.js @@ -0,0 +1,124 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); +var Menu = require('../ui/menu').Menu; + +var Argument = require('../types/types').Argument; +var Conversion = require('../types/types').Conversion; +var Field = require('./fields').Field; + +/** + * A field that allows selection of one of a number of options + */ +function SelectionField(type, options) { + Field.call(this, type, options); + + this.arg = new Argument(); + + this.menu = new Menu({ + document: this.document, + maxPredictions: Conversion.maxPredictions + }); + this.element = this.menu.element; + + this.onFieldChange = util.createEvent('SelectionField.onFieldChange'); + + // i.e. Register this.onItemClick as the default action for a menu click + this.menu.onItemClick.add(this.itemClicked, this); +} + +SelectionField.prototype = Object.create(Field.prototype); + +SelectionField.claim = function(type, context) { + if (context == null) { + return Field.NO_MATCH; + } + return type.getType(context).hasPredictions ? Field.DEFAULT : Field.NO_MATCH; +}; + +SelectionField.prototype.destroy = function() { + this.menu.onItemClick.remove(this.itemClicked, this); + this.menu.destroy(); + this.menu = undefined; + this.element = undefined; + Field.prototype.destroy.call(this); +}; + +SelectionField.prototype.setConversion = function(conversion) { + this.arg = conversion.arg; + this.setMessage(conversion.message); + + var context = this.requisition.executionContext; + conversion.getPredictions(context).then(function(predictions) { + var items = predictions.map(function(prediction) { + // If the prediction value is an 'item' (that is an object with a name and + // description) then use that, otherwise use the prediction itself, because + // at least that has a name. + return prediction.value && prediction.value.description ? + prediction.value : + prediction; + }, this); + if (this.menu != null) { + this.menu.show(items, conversion.arg.text); + } + }.bind(this)).catch(util.errorHandler); +}; + +SelectionField.prototype.itemClicked = function(ev) { + var arg = new Argument(ev.name, '', ' '); + var context = this.requisition.executionContext; + + this.type.parse(arg, context).then(function(conversion) { + this.onFieldChange({ conversion: conversion }); + this.setMessage(conversion.message); + }.bind(this)).catch(util.errorHandler); +}; + +SelectionField.prototype.getConversion = function() { + // This tweaks the prefix/suffix of the argument to fit + this.arg = this.arg.beget({ text: this.input.value }); + return this.type.parse(this.arg, this.requisition.executionContext); +}; + +/** + * Allow the terminal to use RETURN to chose the current menu item when + * it can't execute the command line + * @return true if an item was 'clicked', false otherwise + */ +SelectionField.prototype.selectChoice = function() { + var selected = this.menu.selected; + if (selected == null) { + return false; + } + + this.itemClicked({ name: selected }); + return true; +}; + +Object.defineProperty(SelectionField.prototype, 'isImportant', { + get: function() { + return this.type.name !== 'command'; + }, + enumerable: true +}); + +/** + * Allow registration and de-registration. + */ +exports.items = [ SelectionField ]; diff --git a/devtools/shared/gcli/source/lib/gcli/index.js b/devtools/shared/gcli/source/lib/gcli/index.js new file mode 100644 index 000000000..0b889b63d --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/index.js @@ -0,0 +1,29 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; + + +var prefSvc = Cc['@mozilla.org/preferences-service;1'] + .getService(Ci.nsIPrefService); +var prefBranch = prefSvc.getBranch(null).QueryInterface(Ci.nsIPrefBranch2); + +exports.hiddenByChromePref = function() { + return !prefBranch.prefHasUserValue('devtools.chrome.enabled'); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/l10n.js b/devtools/shared/gcli/source/lib/gcli/l10n.js new file mode 100644 index 000000000..4d3f36595 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/l10n.js @@ -0,0 +1,74 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +"use strict"; + +var Cc = require("chrome").Cc; +var Ci = require("chrome").Ci; + +var prefSvc = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService); +var prefBranch = prefSvc.getBranch(null).QueryInterface(Ci.nsIPrefBranch); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/shared/locales/gclicommands.properties"); + +/** + * Lookup a string in the GCLI string bundle + */ +exports.lookup = function (name) { + try { + return L10N.getStr(name); + } catch (ex) { + throw new Error("Failure in lookup('" + name + "')"); + } +}; + +/** + * An alternative to lookup(). + * l10n.lookup("BLAH") === l10n.propertyLookup.BLAH + * This is particularly nice for templates because you can pass + * l10n:l10n.propertyLookup in the template data and use it + * like ${l10n.BLAH} + */ +exports.propertyLookup = new Proxy({}, { + get: function (rcvr, name) { + return exports.lookup(name); + } +}); + +/** + * Lookup a string in the GCLI string bundle + */ +exports.lookupFormat = function (name, swaps) { + try { + return L10N.getFormatStr(name, ...swaps); + } catch (ex) { + throw new Error("Failure in lookupFormat('" + name + "')"); + } +}; + +/** + * Allow GCLI users to be hidden by the "devtools.chrome.enabled" pref. + * Use it in commands like this: + *
+ *   name: "somecommand",
+ *   hidden: l10n.hiddenByChromePref(),
+ *   exec: function (args, context) { ... }
+ * 
+ */ +exports.hiddenByChromePref = function () { + return !prefBranch.getBoolPref("devtools.chrome.enabled"); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/command.html b/devtools/shared/gcli/source/lib/gcli/languages/command.html new file mode 100644 index 000000000..45b367332 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/command.html @@ -0,0 +1,14 @@ + +
+
+ :${output.typed} +
+
+
+
+
diff --git a/devtools/shared/gcli/source/lib/gcli/languages/command.js b/devtools/shared/gcli/source/lib/gcli/languages/command.js new file mode 100644 index 000000000..58357ce2b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/command.js @@ -0,0 +1,563 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); +var domtemplate = require('../util/domtemplate'); +var host = require('../util/host'); + +var Status = require('../types/types').Status; +var cli = require('../cli'); +var Requisition = require('../cli').Requisition; +var CommandAssignment = require('../cli').CommandAssignment; +var intro = require('../ui/intro'); + +var RESOLVED = Promise.resolve(true); + +/** + * Various ways in which we need to manipulate the caret/selection position. + * A value of null means we're not expecting a change + */ +var Caret = exports.Caret = { + /** + * We are expecting changes, but we don't need to move the cursor + */ + NO_CHANGE: 0, + + /** + * We want the entire input area to be selected + */ + SELECT_ALL: 1, + + /** + * The whole input has changed - push the cursor to the end + */ + TO_END: 2, + + /** + * A part of the input has changed - push the cursor to the end of the + * changed section + */ + TO_ARG_END: 3 +}; + +/** + * Shared promise for loading command.html + */ +var commandHtmlPromise; + +var commandLanguage = exports.commandLanguage = { + // Language implementation for GCLI commands + item: 'language', + name: 'commands', + prompt: ':', + proportionalFonts: true, + + constructor: function(terminal) { + this.terminal = terminal; + this.document = terminal.document; + this.focusManager = terminal.focusManager; + + var options = this.terminal.options; + this.requisition = options.requisition; + if (this.requisition == null) { + if (options.environment == null) { + options.environment = {}; + options.environment.document = options.document || this.document; + options.environment.window = options.environment.document.defaultView; + } + + this.requisition = new Requisition(terminal.system, options); + } + + // We also keep track of the last known arg text for the current assignment + this.lastText = undefined; + + // Used to effect caret changes. See _processCaretChange() + this._caretChange = null; + + // We keep track of which assignment the cursor is in + this.assignment = this.requisition.getAssignmentAt(0); + + if (commandHtmlPromise == null) { + commandHtmlPromise = host.staticRequire(module, './command.html'); + } + + return commandHtmlPromise.then(function(commandHtml) { + this.commandDom = host.toDom(this.document, commandHtml); + + this.requisition.commandOutputManager.onOutput.add(this.outputted, this); + var mapping = cli.getMapping(this.requisition.executionContext); + mapping.terminal = this.terminal; + + this.requisition.onExternalUpdate.add(this.textChanged, this); + + return this; + }.bind(this)); + }, + + destroy: function() { + var mapping = cli.getMapping(this.requisition.executionContext); + delete mapping.terminal; + + this.requisition.commandOutputManager.onOutput.remove(this.outputted, this); + this.requisition.onExternalUpdate.remove(this.textChanged, this); + + this.terminal = undefined; + this.requisition = undefined; + this.commandDom = undefined; + }, + + textChanged: function() { + if (this.terminal == null) { + return; // This can happen post-destroy() + } + + if (this.terminal._caretChange == null) { + // We weren't expecting a change so this was requested by the hint system + // we should move the cursor to the end of the 'changed section', and the + // best we can do for that right now is the end of the current argument. + this.terminal._caretChange = Caret.TO_ARG_END; + } + + var newStr = this.requisition.toString(); + var input = this.terminal.getInputState(); + + input.typed = newStr; + this._processCaretChange(input); + + // We don't update terminal._previousValue. Should we? + // Shouldn't this really be a function of terminal? + if (this.terminal.inputElement.value !== newStr) { + this.terminal.inputElement.value = newStr; + } + this.terminal.onInputChange({ inputState: input }); + + // We get here for minor things like whitespace change in arg prefix, + // so we ignore anything but an actual value change. + if (this.assignment.arg.text === this.lastText) { + return; + } + + this.lastText = this.assignment.arg.text; + + this.terminal.field.update(); + this.terminal.field.setConversion(this.assignment.conversion); + util.setTextContent(this.terminal.descriptionEle, this.description); + }, + + // Called internally whenever we think that the current assignment might + // have changed, typically on mouse-clicks or key presses. + caretMoved: function(start) { + if (!this.requisition.isUpToDate()) { + return; + } + var newAssignment = this.requisition.getAssignmentAt(start); + if (newAssignment == null) { + return; + } + + if (this.assignment !== newAssignment) { + if (this.assignment.param.type.onLeave) { + this.assignment.param.type.onLeave(this.assignment); + } + + // This can be kicked off either by requisition doing an assign or by + // terminal noticing a cursor movement out of a command, so we should + // check that this really is a new assignment + var isNew = (this.assignment !== newAssignment); + + this.assignment = newAssignment; + this.terminal.updateCompletion().catch(util.errorHandler); + + if (isNew) { + this.updateHints(); + } + + if (this.assignment.param.type.onEnter) { + this.assignment.param.type.onEnter(this.assignment); + } + } + else { + if (this.assignment && this.assignment.param.type.onChange) { + this.assignment.param.type.onChange(this.assignment); + } + } + + // Warning: compare the logic here with the logic in fieldChanged, which + // is slightly different. They should probably be the same + var error = (this.assignment.status === Status.ERROR); + this.focusManager.setError(error); + }, + + // Called whenever the assignment that we're providing help with changes + updateHints: function() { + this.lastText = this.assignment.arg.text; + + var field = this.terminal.field; + if (field) { + field.onFieldChange.remove(this.terminal.fieldChanged, this.terminal); + field.destroy(); + } + + var fields = this.terminal.system.fields; + field = this.terminal.field = fields.get(this.assignment.param.type, { + document: this.terminal.document, + requisition: this.requisition + }); + + this.focusManager.setImportantFieldFlag(field.isImportant); + + field.onFieldChange.add(this.terminal.fieldChanged, this.terminal); + field.setConversion(this.assignment.conversion); + + // Filled in by the template process + this.terminal.errorEle = undefined; + this.terminal.descriptionEle = undefined; + + var contents = this.terminal.tooltipTemplate.cloneNode(true); + domtemplate.template(contents, this.terminal, { + blankNullUndefined: true, + stack: 'terminal.html#tooltip' + }); + + util.clearElement(this.terminal.tooltipElement); + this.terminal.tooltipElement.appendChild(contents); + this.terminal.tooltipElement.style.display = 'block'; + + field.setMessageElement(this.terminal.errorEle); + }, + + /** + * See also handleDownArrow for some symmetry + */ + handleUpArrow: function() { + // If the user is on a valid value, then we increment the value, but if + // they've typed something that's not right we page through predictions + if (this.assignment.getStatus() === Status.VALID) { + return this.requisition.nudge(this.assignment, 1).then(function() { + this.textChanged(); + this.focusManager.onInputChange(); + return true; + }.bind(this)); + } + + return Promise.resolve(false); + }, + + /** + * See also handleUpArrow for some symmetry + */ + handleDownArrow: function() { + if (this.assignment.getStatus() === Status.VALID) { + return this.requisition.nudge(this.assignment, -1).then(function() { + this.textChanged(); + this.focusManager.onInputChange(); + return true; + }.bind(this)); + } + + return Promise.resolve(false); + }, + + /** + * RETURN checks status and might exec + */ + handleReturn: function(input) { + // Deny RETURN unless the command might work + if (this.requisition.status !== Status.VALID) { + return Promise.resolve(false); + } + + this.terminal.history.add(input); + this.terminal.unsetChoice().catch(util.errorHandler); + + this.terminal._previousValue = this.terminal.inputElement.value; + this.terminal.inputElement.value = ''; + + return this.requisition.exec().then(function() { + this.textChanged(); + return true; + }.bind(this)); + }, + + /** + * Warning: We get TAB events for more than just the user pressing TAB in our + * input element. + */ + handleTab: function() { + // It's possible for TAB to not change the input, in which case the + // textChanged event will not fire, and the caret move will not be + // processed. So we check that this is done first + this.terminal._caretChange = Caret.TO_ARG_END; + var inputState = this.terminal.getInputState(); + this._processCaretChange(inputState); + + this.terminal._previousValue = this.terminal.inputElement.value; + + // The changes made by complete may happen asynchronously, so after the + // the call to complete() we should avoid making changes before the end + // of the event loop + var index = this.terminal.getChoiceIndex(); + return this.requisition.complete(inputState.cursor, index).then(function(updated) { + // Abort UI changes if this UI update has been overtaken + if (!updated) { + return RESOLVED; + } + this.textChanged(); + return this.terminal.unsetChoice(); + }.bind(this)); + }, + + /** + * The input text has changed in some way. + */ + handleInput: function(value) { + this.terminal._caretChange = Caret.NO_CHANGE; + + return this.requisition.update(value).then(function(updated) { + // Abort UI changes if this UI update has been overtaken + if (!updated) { + return RESOLVED; + } + this.textChanged(); + return this.terminal.unsetChoice(); + }.bind(this)); + }, + + /** + * Counterpart to |setInput| for moving the cursor. + * @param cursor A JS object shaped like { start: x, end: y } + */ + setCursor: function(cursor) { + this._caretChange = Caret.NO_CHANGE; + this._processCaretChange({ + typed: this.terminal.inputElement.value, + cursor: cursor + }); + }, + + /** + * If this._caretChange === Caret.TO_ARG_END, we alter the input object to move + * the selection start to the end of the current argument. + * @param input An object shaped like { typed:'', cursor: { start:0, end:0 }} + */ + _processCaretChange: function(input) { + var start, end; + switch (this._caretChange) { + case Caret.SELECT_ALL: + start = 0; + end = input.typed.length; + break; + + case Caret.TO_END: + start = input.typed.length; + end = input.typed.length; + break; + + case Caret.TO_ARG_END: + // There could be a fancy way to do this involving assignment/arg math + // but it doesn't seem easy, so we cheat a move the cursor to just before + // the next space, or the end of the input + start = input.cursor.start; + do { + start++; + } + while (start < input.typed.length && input.typed[start - 1] !== ' '); + + end = start; + break; + + default: + start = input.cursor.start; + end = input.cursor.end; + break; + } + + start = (start > input.typed.length) ? input.typed.length : start; + end = (end > input.typed.length) ? input.typed.length : end; + + var newInput = { + typed: input.typed, + cursor: { start: start, end: end } + }; + + if (this.terminal.inputElement.selectionStart !== start) { + this.terminal.inputElement.selectionStart = start; + } + if (this.terminal.inputElement.selectionEnd !== end) { + this.terminal.inputElement.selectionEnd = end; + } + + this.caretMoved(start); + + this._caretChange = null; + return newInput; + }, + + /** + * Calculate the properties required by the template process for completer.html + */ + getCompleterTemplateData: function() { + var input = this.terminal.getInputState(); + var start = input.cursor.start; + var index = this.terminal.getChoiceIndex(); + + return this.requisition.getStateData(start, index).then(function(data) { + // Calculate the statusMarkup required to show wavy lines underneath the + // input text (like that of an inline spell-checker) which used by the + // template process for completer.html + // i.e. s/space/ /g in the string (for HTML display) and status to an + // appropriate class name (i.e. lower cased, prefixed with gcli-in-) + data.statusMarkup.forEach(function(member) { + member.string = member.string.replace(/ /g, '\u00a0'); // i.e.   + member.className = 'gcli-in-' + member.status.toString().toLowerCase(); + }, this); + + return data; + }); + }, + + /** + * Called by the onFieldChange event (via the terminal) on the current Field + */ + fieldChanged: function(ev) { + this.requisition.setAssignment(this.assignment, ev.conversion.arg, + { matchPadding: true }).then(function() { + this.textChanged(); + }.bind(this)); + + var isError = ev.conversion.message != null && ev.conversion.message !== ''; + this.focusManager.setError(isError); + }, + + /** + * Monitor for new command executions + */ + outputted: function(ev) { + if (ev.output.hidden) { + return; + } + + var template = this.commandDom.cloneNode(true); + var templateOptions = { stack: 'terminal.html#outputView' }; + + var context = this.requisition.conversionContext; + var data = { + onclick: context.update, + ondblclick: context.updateExec, + language: this, + output: ev.output, + promptClass: (ev.output.error ? 'gcli-row-error' : '') + + (ev.output.completed ? ' gcli-row-complete' : ''), + // Elements attached to this by template(). + rowinEle: null, + rowoutEle: null, + throbEle: null, + promptEle: null + }; + + domtemplate.template(template, data, templateOptions); + + ev.output.promise.then(function() { + var document = data.rowoutEle.ownerDocument; + + if (ev.output.completed) { + data.promptEle.classList.add('gcli-row-complete'); + } + if (ev.output.error) { + data.promptEle.classList.add('gcli-row-error'); + } + + util.clearElement(data.rowoutEle); + + return ev.output.convert('dom', context).then(function(node) { + this.terminal.scrollToBottom(); + data.throbEle.style.display = ev.output.completed ? 'none' : 'block'; + + if (node == null) { + data.promptEle.classList.add('gcli-row-error'); + // TODO: show some error to the user + } + + this._linksToNewTab(node); + data.rowoutEle.appendChild(node); + + var event = document.createEvent('Event'); + event.initEvent('load', true, true); + event.addedElement = node; + node.dispatchEvent(event); + }.bind(this)); + }.bind(this)).catch(console.error); + + this.terminal.addElement(data.rowinEle); + this.terminal.addElement(data.rowoutEle); + this.terminal.scrollToBottom(); + + this.focusManager.outputted(); + }, + + /** + * Find elements with href attributes and add a target=_blank so opened links + * will open in a new window + */ + _linksToNewTab: function(element) { + var links = element.querySelectorAll('*[href]'); + for (var i = 0; i < links.length; i++) { + links[i].setAttribute('target', '_blank'); + } + return element; + }, + + /** + * Show a short introduction to this language + */ + showIntro: function() { + intro.maybeShowIntro(this.requisition.commandOutputManager, + this.requisition.conversionContext); + }, +}; + +/** + * The description (displayed at the top of the hint area) should be blank if + * we're entering the CommandAssignment (because it's obvious) otherwise it's + * the parameter description. + */ +Object.defineProperty(commandLanguage, 'description', { + get: function() { + if (this.assignment == null || ( + this.assignment instanceof CommandAssignment && + this.assignment.value == null)) { + return ''; + } + + return this.assignment.param.manual || this.assignment.param.description; + }, + enumerable: true +}); + +/** + * Present an error message to the hint popup + */ +Object.defineProperty(commandLanguage, 'message', { + get: function() { + return this.assignment.conversion.message; + }, + enumerable: true +}); + +exports.items = [ commandLanguage ]; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/javascript.js b/devtools/shared/gcli/source/lib/gcli/languages/javascript.js new file mode 100644 index 000000000..229cdd4ff --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/javascript.js @@ -0,0 +1,86 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var host = require('../util/host'); +var prism = require('../util/prism').Prism; + +function isMultiline(text) { + return typeof text === 'string' && text.indexOf('\n') > -1; +} + +exports.items = [ + { + // Language implementation for Javascript + item: 'language', + name: 'javascript', + prompt: '>', + + constructor: function(terminal) { + this.document = terminal.document; + this.focusManager = terminal.focusManager; + + this.updateHints(); + }, + + destroy: function() { + this.document = undefined; + }, + + exec: function(input) { + return this.evaluate(input).then(function(response) { + var output = (response.exception != null) ? + response.exception.class : + response.output; + + var isSameString = typeof output === 'string' && + input.substr(1, input.length - 2) === output; + var isSameOther = typeof output !== 'string' && + input === '' + output; + + // Place strings in quotes + if (typeof output === 'string' && response.exception == null) { + if (output.indexOf('\'') === -1) { + output = '\'' + output + '\''; + } + else { + output = output.replace(/\\/, '\\').replace(/"/, '"').replace(/'/, '\''); + output = '"' + output + '"'; + } + } + + var line; + if (isSameString || isSameOther || output === undefined) { + line = input; + } + else if (isMultiline(output)) { + line = input + '\n/*\n' + output + '\n*/'; + } + else { + line = input + ' // ' + output; + } + + var grammar = prism.languages[this.name]; + return prism.highlight(line, grammar, this.name); + }.bind(this)); + }, + + evaluate: function(input) { + return host.script.evaluate(input); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/languages.js b/devtools/shared/gcli/source/lib/gcli/languages/languages.js new file mode 100644 index 000000000..3444c9a8f --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/languages.js @@ -0,0 +1,179 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); + +var RESOLVED = Promise.resolve(true); + +/** + * This is the base implementation for all languages + */ +var baseLanguage = { + item: 'language', + name: undefined, + + constructor: function(terminal) { + }, + + destroy: function() { + }, + + updateHints: function() { + util.clearElement(this.terminal.tooltipElement); + }, + + description: '', + message: '', + caretMoved: function() {}, + + handleUpArrow: function() { + return Promise.resolve(false); + }, + + handleDownArrow: function() { + return Promise.resolve(false); + }, + + handleTab: function() { + return this.terminal.unsetChoice().then(function() { + return RESOLVED; + }, util.errorHandler); + }, + + handleInput: function(input) { + if (input === ':') { + return this.terminal.setInput('').then(function() { + return this.terminal.pushLanguage('commands'); + }.bind(this)); + } + + return this.terminal.unsetChoice().then(function() { + return RESOLVED; + }, util.errorHandler); + }, + + handleReturn: function(input) { + var rowoutEle = this.document.createElement('pre'); + rowoutEle.classList.add('gcli-row-out'); + rowoutEle.classList.add('gcli-row-script'); + rowoutEle.setAttribute('aria-live', 'assertive'); + + return this.exec(input).then(function(line) { + rowoutEle.innerHTML = line; + + this.terminal.addElement(rowoutEle); + this.terminal.scrollToBottom(); + + this.focusManager.outputted(); + + this.terminal.unsetChoice().catch(util.errorHandler); + this.terminal.inputElement.value = ''; + }.bind(this)); + }, + + setCursor: function(cursor) { + this.terminal.inputElement.selectionStart = cursor.start; + this.terminal.inputElement.selectionEnd = cursor.end; + }, + + getCompleterTemplateData: function() { + return Promise.resolve({ + statusMarkup: [ + { + string: this.terminal.inputElement.value.replace(/ /g, '\u00a0'), // i.e.   + className: 'gcli-in-valid' + } + ], + unclosedJs: false, + directTabText: '', + arrowTabText: '', + emptyParameters: '' + }); + }, + + showIntro: function() { + }, + + exec: function(input) { + throw new Error('Missing implementation of handleReturn() or exec() ' + this.name); + } +}; + +/** + * A manager for the registered Languages + */ +function Languages() { + // This is where we cache the languages that we know about + this._registered = {}; +} + +/** + * Add a new language to the cache + */ +Languages.prototype.add = function(language) { + this._registered[language.name] = language; +}; + +/** + * Remove an existing language from the cache + */ +Languages.prototype.remove = function(language) { + var name = typeof language === 'string' ? language : language.name; + delete this._registered[name]; +}; + +/** + * Get access to the list of known languages + */ +Languages.prototype.getAll = function() { + return Object.keys(this._registered).map(function(name) { + return this._registered[name]; + }.bind(this)); +}; + +/** + * Find a previously registered language + */ +Languages.prototype.createLanguage = function(name, terminal) { + if (name == null) { + name = Object.keys(this._registered)[0]; + } + + var language = (typeof name === 'string') ? this._registered[name] : name; + if (!language) { + console.error('Known languages: ' + Object.keys(this._registered).join(', ')); + throw new Error('Unknown language: \'' + name + '\''); + } + + // clone 'type' + var newInstance = {}; + util.copyProperties(baseLanguage, newInstance); + util.copyProperties(language, newInstance); + + if (typeof newInstance.constructor === 'function') { + var reply = newInstance.constructor(terminal); + return Promise.resolve(reply).then(function() { + return newInstance; + }); + } + else { + return Promise.resolve(newInstance); + } +}; + +exports.Languages = Languages; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/moz.build b/devtools/shared/gcli/source/lib/gcli/languages/moz.build new file mode 100644 index 000000000..e1828a51f --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'command.html', + 'command.js', + 'javascript.js', + 'languages.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/moz.build b/devtools/shared/gcli/source/lib/gcli/moz.build new file mode 100644 index 000000000..7b1e6dd2a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + 'cli.js', + 'index.js', + 'l10n.js', + 'settings.js', + 'system.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/completer.js b/devtools/shared/gcli/source/lib/gcli/mozui/completer.js new file mode 100644 index 000000000..fd9a74732 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/completer.js @@ -0,0 +1,151 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); +var host = require('../util/host'); +var domtemplate = require('../util/domtemplate'); + +var completerHtml = + '\n' + + ' \n' + + ' \n' + + ' \n' + + '