diff options
Diffstat (limited to 'devtools/shared/gcli')
104 files changed, 21260 insertions, 0 deletions
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: "<p>${message}</p>", + 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: + "<table>" + + " <caption>${header}</caption>" + + " <tbody>" + + " <tr foreach='addon in ${addons}'" + + " class=\"gcli-addon-${addon.status}\">" + + " <td>${addon.name} ${addon.version}</td>" + + " <td>${addon.pendingOperations}</td>" + + " <td>" + + " <span class='gcli-out-shortcut'" + + " data-command='addon ${addon.toggleActionName} ${addon.label}'" + + " onclick='${onclick}' ondblclick='${ondblclick}'" + + " >${addon.toggleActionMessage}</span>" + + " </td>" + + " </tr>" + + " </tbody>" + + "</table>", + 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: "<span>" + l10n.lookup("appCacheValidatedSuccessfully") + "</span>" + }); + } + + return context.createView({ + html: + "<div>" + + " <h4>Manifest URI: ${manifestURI}</h4>" + + " <ol>" + + " <li foreach='error in ${errors}'>${error.msg}</li>" + + " </ol>" + + "</div>", + 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: "" + + "<ul class='gcli-appcache-list'>" + + " <li foreach='entry in ${entries}'>" + + " <table class='gcli-appcache-detail'>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListKey") + "</td>" + + " <td>${entry.key}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListFetchCount") + "</td>" + + " <td>${entry.fetchCount}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListLastFetched") + "</td>" + + " <td>${entry.lastFetched}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListLastModified") + "</td>" + + " <td>${entry.lastModified}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListExpirationTime") + "</td>" + + " <td>${entry.expirationTime}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListDataSize") + "</td>" + + " <td>${entry.dataSize}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("appCacheListDeviceID") + "</td>" + + " <td>${entry.deviceID} <span class='gcli-out-shortcut' " + + "onclick='${onclick}' ondblclick='${ondblclick}' " + + "data-command='appcache viewentry ${entry.key}'" + + ">" + l10n.lookup("appCacheListViewEntry") + "</span>" + + " </td>" + + " </tr>" + + " </table>" + + " </li>" + + "</ul>", + 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 = "<anonymous>"; + 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: "<span>" + msg + "</span>" }); + } + + 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: + "<ul class='gcli-cookielist-list'>" + + " <li foreach='cookie in ${cookies}'>" + + " <div>${cookie.name}=${cookie.value}</div>" + + " <table class='gcli-cookielist-detail'>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutHost") + "</td>" + + " <td>${cookie.host}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutPath") + "</td>" + + " <td>${cookie.path}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutExpires") + "</td>" + + " <td>${cookie.expires}</td>" + + " </tr>" + + " <tr>" + + " <td>" + l10n.lookup("cookieListOutAttributes") + "</td>" + + " <td>${cookie.attrs}</td>" + + " </tr>" + + " <tr><td colspan='2'>" + + " <span class='gcli-out-shortcut' onclick='${onclick}'" + + " data-command='cookie set ${cookie.name} '" + + " >" + l10n.lookup("cookieListOutEdit") + "</span>" + + " <span class='gcli-out-shortcut'" + + " onclick='${onclick}' ondblclick='${ondblclick}'" + + " data-command='cookie remove ${cookie.name}'" + + " >" + l10n.lookup("cookieListOutRemove") + "</span>" + + " </td></tr>" + + " </table>" + + " </li>" + + "</ul>", + 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<Target, Links> where Links is an object that looks like + * { refs: number, promise: Promise<System>, 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<System> 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: + "<table class='gcli-csp-detail' cellspacing='10' valign='top'>" + + " <tr>" + + " <td> <img src='chrome://browser/content/gcli_sec_bad.svg' width='20px' /> </td> " + + " <td>" + NO_CSP_ON_PAGE_MSG + " <b>" + url + "</b></td>" + + " </tr>" + + "</table>"}); + } + + return context.createView({ + html: + "<table class='gcli-csp-detail' cellspacing='10' valign='top'>" + + // iterate all policies + " <tr foreach='csp in ${cspinfo}' >" + + " <td> ${csp.header} <b>" + url + "</b><br/><br/>" + + " <table class='gcli-csp-dir-detail' valign='top'>" + + // >> iterate all directives + " <tr foreach='dir in ${csp.directives}' >" + + " <td valign='top'> ${dir.dirValue} </td>" + + " <td valign='top'>" + + " <table class='gcli-csp-src-detail' valign='top'>" + + // >> >> iterate all srs + " <tr foreach='src in ${dir.dirSrc}' >" + + " <td valign='center' width='20px'> <img src= \"${src.icon}\" width='20px' /> </td> " + + " <td valign='center' width='200px'> ${src.src} </td>" + + " <td valign='center'> ${src.desc} </td>" + + " </tr>" + + " </table>" + + " </td>" + + " </tr>" + + " </table>" + + " </td>" + + " </tr>" + + "</table>", + 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: + "<div class='gcli-referrer-policy'>" + + " <strong> ${rpi.header} </strong> <br />" + + " ${rpi.policyName} <br />" + + " <table class='gcli-referrer-policy-detail' cellspacing='10' >" + + " <tr>" + + " <th> " + NEXT_URI_HEADER + " </th>" + + " <th> " + CALCULATED_REFERRER_HEADER + " </th>" + + " </tr>" + + // iterate all policies + " <tr foreach='nextURI in ${rpi.urls}' >" + + " <td> ${nextURI.description} (e.g., ${nextURI.uri}) </td>" + + " <td> ${nextURI.referrer} </td>" + + " </tr>" + + " </table>" + + "</div>", + 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 <random@developer.example.org> + + 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. + + <script src="path/to/gcli-uncompressed.js" type="text/javascript"></script> + +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``. + + <input id="gcli-input" type="text"/> + <div id="gcli-display"></div> + +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 "<p>Hello</p>"; + +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 <tt>value</tt> 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 <tt>str</tt> 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. + * <p>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 ``<input type=...>``, 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. + * <p>We also record validity information where applicable. + * <p>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 <tt>getPredictions()[index]</tt> 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. + * <p> + * The returned object has the following members:<ul> + * <li>character: The character to which this arg trace refers. + * <li>arg: The Argument to which this character is assigned. + * <li>part: One of ['prefix'|'text'|suffix'] - how was this char understood + * </ul> + * <p> + * 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). + * <p> + * To get at the arguments applied to the assignments simply call + * <tt>arg.assignment.arg</tt>. If <tt>arg.assignment.arg !== arg</tt> 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 <tt>status</tt> property and a + * <tt>string</tt> 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 <tt>cursor</tt>. + * 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<TAB>' -> '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: '<span onload="${onload}"></span>', + 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: + '<div>\n' + + ' <p class="gcli-help-header">\n' + + ' ${l10n.helpManSynopsis}:\n' + + ' <span class="gcli-out-shortcut" data-command="${command.name}"\n' + + ' onclick="${onclick}" ondblclick="${ondblclick}">\n' + + ' ${command.name}\n' + + ' <span foreach="param in ${command.params}">${getSynopsis(param)} </span>\n' + + ' </span>\n' + + ' </p>\n' + + '\n' + + ' <p class="gcli-help-description">${describe(command)}</p>\n' + + '\n' + + ' <div if="${!command.isParent}">\n' + + ' <div foreach="groupName in ${command.paramGroups}">\n' + + ' <p class="gcli-help-header">${groupName}:</p>\n' + + ' <ul class="gcli-help-parameter">\n' + + ' <li if="${command.params.length === 0}">${l10n.helpManNone}</li>\n' + + ' <li foreach="param in ${command.paramGroups[groupName]}">\n' + + ' <code>${getSynopsis(param)}</code> <em>${getTypeDescription(param)}</em>\n' + + ' <br/>\n' + + ' ${describe(param)}\n' + + ' </li>\n' + + ' </ul>\n' + + ' </div>\n' + + ' </div>\n' + + '\n' + + ' <div if="${command.isParent}">\n' + + ' <p class="gcli-help-header">${l10n.subCommands}:</p>\n' + + ' <ul class="gcli-help-${subcommands}">\n' + + ' <li if="${subcommands.length === 0}">${l10n.subcommandsNone}</li>\n' + + ' <li foreach="subcommand in ${subcommands}">\n' + + ' ${subcommand.name}: ${subcommand.description}\n' + + ' <span class="gcli-out-shortcut" data-command="help ${subcommand.name}"\n' + + ' onclick="${onclick}" ondblclick="${ondblclick}">\n' + + ' help ${subcommand.name}\n' + + ' </span>\n' + + ' </li>\n' + + ' </ul>\n' + + ' </div>\n' + + '\n' + + '</div>\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: + '<div>## ${command.name}\n' + + '\n' + + '# ${l10n.helpManSynopsis}: ${command.name} <loop foreach="param in ${command.params}">${getSynopsis(param)} </loop>\n' + + '\n' + + '# ${l10n.helpManDescription}:\n' + + '\n' + + '${command.manual || command.description}\n' + + '\n' + + '<loop foreach="groupName in ${command.paramGroups}">\n' + + '<span if="${!command.isParent}"># ${groupName}:\n' + + '\n' + + '<span if="${command.params.length === 0}">${l10n.helpManNone}</span><loop foreach="param in ${command.paramGroups[groupName]}">* ${param.name}: ${getTypeDescription(param)}\n' + + ' ${param.manual || param.description}\n' + + '</loop>\n' + + '</span>\n' + + '</loop>\n' + + '\n' + + '<span if="${command.isParent}"># ${l10n.subCommands}:</span>\n' + + '\n' + + '<span if="${subcommands.length === 0}">${l10n.subcommandsNone}</span>\n' + + '<loop foreach="subcommand in ${subcommands}">* ${subcommand.name}: ${subcommand.description}\n' + + '</loop>\n' + + '</div>\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: + '<div>\n' + + ' <div if="${includeIntro}">\n' + + ' <p>${l10n.helpIntro}</p>\n' + + ' </div>\n' + + '\n' + + ' <p>${heading}</p>\n' + + '\n' + + ' <table>\n' + + ' <tr foreach="command in ${matchingCommands}">\n' + + ' <td class="gcli-help-name">${command.name}</td>\n' + + ' <td class="gcli-help-arrow">-</td>\n' + + ' <td>\n' + + ' ${command.description}\n' + + ' <span class="gcli-out-shortcut"\n' + + ' onclick="${onclick}" ondblclick="${ondblclick}"\n' + + ' data-command="help ${command.name}">help ${command.name}</span>\n' + + ' </td>\n' + + ' </tr>\n' + + ' </table>\n' + + '</div>\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: + '<pre><span if="${includeIntro}">## ${l10n.helpIntro}</span>\n' + + '\n' + + '# ${heading}\n' + + '\n' + + '<loop foreach="command in ${matchingCommands}">${command.name} → ${command.description}\n' + + '</loop></pre>', + 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: + '<div ignore="${onLoad(__element)}">\n' + + ' <!-- This is broken, and unimportant. Comment out for now\n' + + ' <div class="gcli-pref-list-filter">\n' + + ' ${l10n.prefOutputFilter}:\n' + + ' <input onKeyUp="${onFilterChange}" value="${search}"/>\n' + + ' </div>\n' + + ' -->\n' + + ' <table class="gcli-pref-list-table">\n' + + ' <colgroup>\n' + + ' <col class="gcli-pref-list-name"/>\n' + + ' <col class="gcli-pref-list-value"/>\n' + + ' </colgroup>\n' + + ' <tr>\n' + + ' <th>${l10n.prefOutputName}</th>\n' + + ' <th>${l10n.prefOutputValue}</th>\n' + + ' </tr>\n' + + ' </table>\n' + + ' <div class="gcli-pref-list-scroller">\n' + + ' <table class="gcli-pref-list-table" save="${table}">\n' + + ' </table>\n' + + ' </div>\n' + + '</div>\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: + '<table>\n' + + ' <colgroup>\n' + + ' <col class="gcli-pref-list-name"/>\n' + + ' <col class="gcli-pref-list-value"/>\n' + + ' </colgroup>\n' + + ' <tr class="gcli-pref-list-row" foreach="setting in ${settings}">\n' + + ' <td>${setting.name}</td>\n' + + ' <td onclick="${onSetClick}" data-command="pref set ${setting.name} ">\n' + + ' ${setting.value}\n' + + ' [Edit]\n' + + ' </td>\n' + + ' </tr>\n' + + '</table>\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: + '<div>\n' + + ' <table class="gcliTestResults">\n' + + ' <thead>\n' + + ' <tr>\n' + + ' <th class="gcliTestSuite">Suite</th>\n' + + ' <th>Test</th>\n' + + ' <th>Results</th>\n' + + ' <th>Checks</th>\n' + + ' <th>Notes</th>\n' + + ' </tr>\n' + + ' </thead>\n' + + ' <tbody foreach="suite in ${suites}">\n' + + ' <tr foreach="test in ${suite.tests}" title="${suite.name}.${test.name}()">\n' + + ' <td class="gcliTestSuite">${suite.name}</td>\n' + + ' <td class="gcliTestTitle">${test.title}</td>\n' + + ' <td class="gcliTest${test.status.name}">${test.status.name}</td>\n' + + ' <td class="gcliTestChecks">${test.checks}</td>\n' + + ' <td class="gcliTestMessages">\n' + + ' <div foreach="failure in ${test.failures}">\n' + + ' ${failure.message}\n' + + ' <ul if="${failure.params}">\n' + + ' <li>P1: ${failure.p1}</li>\n' + + ' <li>P2: ${failure.p2}</li>\n' + + ' </ul>\n' + + ' </div>\n' + + ' </td>\n' + + ' </tr>\n' + + ' </tbody>\n' + + ' <tfoot>\n' + + ' <tr>\n' + + ' <th></th>\n' + + ' <th>Total</th>\n' + + ' <th>${summary.status.name}</th>\n' + + ' <th class="gcliTestChecks">${summary.checks}</th>\n' + + ' <th></th>\n' + + ' </tr>\n' + + ' </tfoot>\n' + + ' </table>\n' + + '</div>', + 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, '<br/>'); + return { + html: '<pre>' + html + '</pre>' + }; + } + }, + { + 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(). + * <code>l10n.lookup("BLAH") === l10n.propertyLookup.BLAH</code> + * This is particularly nice for templates because you can pass + * <code>l10n:l10n.propertyLookup</code> in the template data and use it + * like <code>${l10n.BLAH}</code> + */ +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: + * <pre> + * name: "somecommand", + * hidden: l10n.hiddenByChromePref(), + * exec: function (args, context) { ... } + * </pre> + */ +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 @@ + +<div> + <div class="gcli-row-in" save="${rowinEle}" aria-live="assertive" + onclick="${onclick}" ondblclick="${ondblclick}" + data-command="${output.canonical}"> + <span + save="${promptEle}" + class="gcli-row-prompt ${promptClass}">:</span><span + class="gcli-row-in-typed">${output.typed}</span> + <div class="gcli-row-throbber" save="${throbEle}"></div> + </div> + <div class="gcli-row-out" aria-live="assertive" save="${rowoutEle}"> + </div> +</div> 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 = + '<description\n' + + ' xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">\n' + + ' <loop foreach="member in ${statusMarkup}">\n' + + ' <label class="${member.className}" value="${member.string}"></label>\n' + + ' </loop>\n' + + ' <label class="gcli-in-ontab" value="${directTabText}"/>\n' + + ' <label class="gcli-in-todo" foreach="param in ${emptyParameters}" value="${param}"/>\n' + + ' <label class="gcli-in-ontab" value="${arrowTabText}"/>\n' + + ' <label class="gcli-in-closebrace" if="${unclosedJs}" value="}"/>\n' + + '</description>\n'; + +/** + * Completer is an 'input-like' element that sits an input element annotating + * it with visual goodness. + * @param components Object that links to other UI components. GCLI provided: + * - requisition: A GCLI Requisition object whose state is monitored + * - element: Element to use as root + * - autoResize: (default=false): Should we attempt to sync the dimensions of + * the complete element with the input element. + */ +function Completer(components) { + this.requisition = components.requisition; + this.input = { typed: '', cursor: { start: 0, end: 0 } }; + this.choice = 0; + + this.element = components.element; + this.element.classList.add('gcli-in-complete'); + this.element.setAttribute('tabindex', '-1'); + this.element.setAttribute('aria-live', 'polite'); + + this.document = this.element.ownerDocument; + + this.inputter = components.inputter; + + this.inputter.onInputChange.add(this.update, this); + this.inputter.onAssignmentChange.add(this.update, this); + this.inputter.onChoiceChange.add(this.update, this); + + this.autoResize = components.autoResize; + if (this.autoResize) { + this.inputter.onResize.add(this.resized, this); + + var dimensions = this.inputter.getDimensions(); + if (dimensions) { + this.resized(dimensions); + } + } + + this.template = host.toDom(this.document, completerHtml); + // We want the spans to line up without the spaces in the template + util.removeWhitespace(this.template, true); + + this.update(); +} + +/** + * Avoid memory leaks + */ +Completer.prototype.destroy = function() { + this.inputter.onInputChange.remove(this.update, this); + this.inputter.onAssignmentChange.remove(this.update, this); + this.inputter.onChoiceChange.remove(this.update, this); + + if (this.autoResize) { + this.inputter.onResize.remove(this.resized, this); + } + + this.document = undefined; + this.element = undefined; + this.template = undefined; + this.inputter = undefined; +}; + +/** + * Ensure that the completion element is the same size and the inputter element + */ +Completer.prototype.resized = function(ev) { + this.element.style.top = ev.top + 'px'; + this.element.style.height = ev.height + 'px'; + this.element.style.lineHeight = ev.height + 'px'; + this.element.style.left = ev.left + 'px'; + this.element.style.width = ev.width + 'px'; +}; + +/** + * Bring the completion element up to date with what the requisition says + */ +Completer.prototype.update = function(ev) { + this.choice = (ev && ev.choice != null) ? ev.choice : 0; + + this._getCompleterTemplateData().then(function(data) { + if (this.template == null) { + return; // destroy() has been called + } + + var template = this.template.cloneNode(true); + domtemplate.template(template, data, { stack: 'completer.html' }); + + util.clearElement(this.element); + while (template.hasChildNodes()) { + this.element.appendChild(template.firstChild); + } + }.bind(this)); +}; + +/** + * Calculate the properties required by the template process for completer.html + */ +Completer.prototype._getCompleterTemplateData = function() { + var input = this.inputter.getInputState(); + var start = input.cursor.start; + + return this.requisition.getStateData(start, this.choice).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; + }); +}; + +exports.Completer = Completer; diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/inputter.js b/devtools/shared/gcli/source/lib/gcli/mozui/inputter.js new file mode 100644 index 000000000..3810c2e8c --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/inputter.js @@ -0,0 +1,657 @@ +/* + * 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 KeyEvent = require('../util/util').KeyEvent; + +var Status = require('../types/types').Status; +var History = require('../ui/history').History; + +var RESOLVED = Promise.resolve(true); + +/** + * A wrapper to take care of the functions concerning an input element + * @param components Object that links to other UI components. GCLI provided: + * - requisition + * - focusManager + * - element + */ +function Inputter(components) { + this.requisition = components.requisition; + this.focusManager = components.focusManager; + + this.element = components.element; + this.element.classList.add('gcli-in-input'); + this.element.spellcheck = false; + + this.document = this.element.ownerDocument; + + // Used to distinguish focus from TAB in CLI. See onKeyUp() + this.lastTabDownAt = 0; + + // Used to effect caret changes. See _processCaretChange() + this._caretChange = null; + + // Ensure that TAB/UP/DOWN isn't handled by the browser + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.element.addEventListener('keydown', this.onKeyDown, false); + this.element.addEventListener('keyup', this.onKeyUp, false); + + // Setup History + this.history = new History(); + this._scrollingThroughHistory = false; + + // Used when we're selecting which prediction to complete with + this._choice = null; + this.onChoiceChange = util.createEvent('Inputter.onChoiceChange'); + + // Cursor position affects hint severity + this.onMouseUp = this.onMouseUp.bind(this); + this.element.addEventListener('mouseup', this.onMouseUp, false); + + if (this.focusManager) { + this.focusManager.addMonitoredElement(this.element, 'input'); + } + + // Initially an asynchronous completion isn't in-progress + this._completed = RESOLVED; + + this.textChanged = this.textChanged.bind(this); + + this.outputted = this.outputted.bind(this); + this.requisition.commandOutputManager.onOutput.add(this.outputted, this); + + this.assignment = this.requisition.getAssignmentAt(0); + this.onAssignmentChange = util.createEvent('Inputter.onAssignmentChange'); + this.onInputChange = util.createEvent('Inputter.onInputChange'); + + this.onResize = util.createEvent('Inputter.onResize'); + this.onWindowResize = this.onWindowResize.bind(this); + this.document.defaultView.addEventListener('resize', this.onWindowResize, false); + this.requisition.onExternalUpdate.add(this.textChanged, this); + + this._previousValue = undefined; + this.requisition.update(this.element.value || ''); +} + +/** + * Avoid memory leaks + */ +Inputter.prototype.destroy = function() { + this.document.defaultView.removeEventListener('resize', this.onWindowResize, false); + + this.requisition.commandOutputManager.onOutput.remove(this.outputted, this); + this.requisition.onExternalUpdate.remove(this.textChanged, this); + if (this.focusManager) { + this.focusManager.removeMonitoredElement(this.element, 'input'); + } + + this.element.removeEventListener('mouseup', this.onMouseUp, false); + this.element.removeEventListener('keydown', this.onKeyDown, false); + this.element.removeEventListener('keyup', this.onKeyUp, false); + + this.history.destroy(); + + if (this.style) { + this.style.parentNode.removeChild(this.style); + this.style = undefined; + } + + this.textChanged = undefined; + this.outputted = undefined; + this.onMouseUp = undefined; + this.onKeyDown = undefined; + this.onKeyUp = undefined; + this.onWindowResize = undefined; + this.tooltip = undefined; + this.document = undefined; + this.element = undefined; +}; + +/** + * Make ourselves visually similar to the input element, and make the input + * element transparent so our background shines through + */ +Inputter.prototype.onWindowResize = function() { + // Mochitest sometimes causes resize after shutdown. See Bug 743190 + if (!this.element) { + return; + } + + this.onResize(this.getDimensions()); +}; + +/** + * Make ourselves visually similar to the input element, and make the input + * element transparent so our background shines through + */ +Inputter.prototype.getDimensions = function() { + var fixedLoc = {}; + var currentElement = this.element.parentNode; + while (currentElement && currentElement.nodeName !== '#document') { + var style = this.document.defaultView.getComputedStyle(currentElement, ''); + if (style) { + var position = style.getPropertyValue('position'); + if (position === 'absolute' || position === 'fixed') { + var bounds = currentElement.getBoundingClientRect(); + fixedLoc.top = bounds.top; + fixedLoc.left = bounds.left; + break; + } + } + currentElement = currentElement.parentNode; + } + + var rect = this.element.getBoundingClientRect(); + return { + top: rect.top - (fixedLoc.top || 0) + 1, + height: rect.bottom - rect.top - 1, + left: rect.left - (fixedLoc.left || 0) + 2, + width: rect.right - rect.left + }; +}; + +/** + * Pass 'outputted' events on to the focus manager + */ +Inputter.prototype.outputted = function() { + if (this.focusManager) { + this.focusManager.outputted(); + } +}; + +/** + * Handler for the input-element.onMouseUp event + */ +Inputter.prototype.onMouseUp = function(ev) { + this._checkAssignment(); +}; + +/** + * Function called when we think the text might have changed + */ +Inputter.prototype.textChanged = function() { + if (!this.document) { + return; // This can happen post-destroy() + } + + if (this._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._caretChange = Caret.TO_ARG_END; + } + + var newStr = this.requisition.toString(); + var input = this.getInputState(); + + input.typed = newStr; + this._processCaretChange(input); + + if (this.element.value !== newStr) { + this.element.value = newStr; + } + this.onInputChange({ inputState: input }); + + this.tooltip.textChanged(); +}; + +/** + * 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 = { + /** + * 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 +}; + +/** + * 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 }} + */ +Inputter.prototype._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.element.selectionStart !== start) { + this.element.selectionStart = start; + } + if (this.element.selectionEnd !== end) { + this.element.selectionEnd = end; + } + + this._checkAssignment(start); + + this._caretChange = null; + return newInput; +}; + +/** + * To be called internally whenever we think that the current assignment might + * have changed, typically on mouse-clicks or key presses. + * @param start Optional - if specified, the cursor position to use in working + * out the current assignment. This is needed because setting the element + * selection start is only recognised when the event loop has finished + */ +Inputter.prototype._checkAssignment = function(start) { + if (start == null) { + start = this.element.selectionStart; + } + 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.assignment = newAssignment; + this.onAssignmentChange({ assignment: this.assignment }); + + 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); + } + } + + // This is slightly nasty - the focusManager generally relies on people + // telling it what it needs to know (which makes sense because the event + // system to do it with events would be unnecessarily complex). However + // requisition doesn't know about the focusManager either. So either one + // needs to know about the other, or a third-party needs to break the + // deadlock. These 2 lines are all we're quibbling about, so for now we hack + if (this.focusManager) { + var error = (this.assignment.status === Status.ERROR); + this.focusManager.setError(error); + } +}; + +/** + * Set the input field to a value, for external use. + * This function updates the data model. It sets the caret to the end of the + * input. It does not make any similarity checks so calling this function with + * it's current value resets the cursor position. + * It does not execute the input or affect the history. + * This function should not be called internally, by Inputter and never as a + * result of a keyboard event on this.element or bug 676520 could be triggered. + */ +Inputter.prototype.setInput = function(str) { + this._caretChange = Caret.TO_END; + return this.requisition.update(str).then(function(updated) { + this.textChanged(); + return updated; + }.bind(this)); +}; + +/** + * Counterpart to |setInput| for moving the cursor. + * @param cursor An object shaped like { start: x, end: y } + */ +Inputter.prototype.setCursor = function(cursor) { + this._caretChange = Caret.NO_CHANGE; + this._processCaretChange({ typed: this.element.value, cursor: cursor }); + return RESOLVED; +}; + +/** + * Focus the input element + */ +Inputter.prototype.focus = function() { + this.element.focus(); + this._checkAssignment(); +}; + +/** + * Ensure certain keys (arrows, tab, etc) that we would like to handle + * are not handled by the browser + */ +Inputter.prototype.onKeyDown = function(ev) { + if (ev.keyCode === KeyEvent.DOM_VK_UP || ev.keyCode === KeyEvent.DOM_VK_DOWN) { + ev.preventDefault(); + return; + } + + // The following keys do not affect the state of the command line so we avoid + // informing the focusManager about keyboard events that involve these keys + if (ev.keyCode === KeyEvent.DOM_VK_F1 || + ev.keyCode === KeyEvent.DOM_VK_ESCAPE || + ev.keyCode === KeyEvent.DOM_VK_UP || + ev.keyCode === KeyEvent.DOM_VK_DOWN) { + return; + } + + if (this.focusManager) { + this.focusManager.onInputChange(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_TAB) { + this.lastTabDownAt = 0; + if (!ev.shiftKey) { + ev.preventDefault(); + // Record the timestamp of this TAB down so onKeyUp can distinguish + // focus from TAB in the CLI. + this.lastTabDownAt = ev.timeStamp; + } + if (ev.metaKey || ev.altKey || ev.crtlKey) { + if (this.document.commandDispatcher) { + this.document.commandDispatcher.advanceFocus(); + } + else { + this.element.blur(); + } + } + } +}; + +/** + * Handler for use with DOM events, which just calls the promise enabled + * handleKeyUp function but checks the exit state of the promise so we know + * if something went wrong. + */ +Inputter.prototype.onKeyUp = function(ev) { + this.handleKeyUp(ev).catch(util.errorHandler); +}; + +/** + * The main keyboard processing loop + * @return A promise that resolves (to undefined) when the actions kicked off + * by this handler are completed. + */ +Inputter.prototype.handleKeyUp = function(ev) { + if (this.focusManager && ev.keyCode === KeyEvent.DOM_VK_F1) { + this.focusManager.helpRequest(); + return RESOLVED; + } + + if (this.focusManager && ev.keyCode === KeyEvent.DOM_VK_ESCAPE) { + this.focusManager.removeHelp(); + return RESOLVED; + } + + if (ev.keyCode === KeyEvent.DOM_VK_UP) { + return this._handleUpArrow(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_DOWN) { + return this._handleDownArrow(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_RETURN) { + return this._handleReturn(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_TAB && !ev.shiftKey) { + return this._handleTab(ev); + } + + if (this._previousValue === this.element.value) { + return RESOLVED; + } + + this._scrollingThroughHistory = false; + this._caretChange = Caret.NO_CHANGE; + + this._completed = this.requisition.update(this.element.value); + this._previousValue = this.element.value; + + return this._completed.then(function() { + // Abort UI changes if this UI update has been overtaken + if (this._previousValue === this.element.value) { + this._choice = null; + this.textChanged(); + this.onChoiceChange({ choice: this._choice }); + } + }.bind(this)); +}; + +/** + * See also _handleDownArrow for some symmetry + */ +Inputter.prototype._handleUpArrow = function() { + if (this.tooltip && this.tooltip.isMenuShowing) { + this.changeChoice(-1); + return RESOLVED; + } + + if (this.element.value === '' || this._scrollingThroughHistory) { + this._scrollingThroughHistory = true; + return this.requisition.update(this.history.backward()).then(function(updated) { + this.textChanged(); + return updated; + }.bind(this)); + } + + // 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() { + // See notes on focusManager.onInputChange in onKeyDown + this.textChanged(); + if (this.focusManager) { + this.focusManager.onInputChange(); + } + }.bind(this)); + } + + this.changeChoice(-1); + return RESOLVED; +}; + +/** + * See also _handleUpArrow for some symmetry + */ +Inputter.prototype._handleDownArrow = function() { + if (this.tooltip && this.tooltip.isMenuShowing) { + this.changeChoice(+1); + return RESOLVED; + } + + if (this.element.value === '' || this._scrollingThroughHistory) { + this._scrollingThroughHistory = true; + return this.requisition.update(this.history.forward()).then(function(updated) { + this.textChanged(); + return updated; + }.bind(this)); + } + + // See notes above for the UP key + if (this.assignment.getStatus() === Status.VALID) { + return this.requisition.nudge(this.assignment, -1).then(function() { + // See notes on focusManager.onInputChange in onKeyDown + this.textChanged(); + if (this.focusManager) { + this.focusManager.onInputChange(); + } + }.bind(this)); + } + + this.changeChoice(+1); + return RESOLVED; +}; + +/** + * RETURN checks status and might exec + */ +Inputter.prototype._handleReturn = function() { + // Deny RETURN unless the command might work + if (this.requisition.status === Status.VALID) { + this._scrollingThroughHistory = false; + this.history.add(this.element.value); + + return this.requisition.exec().then(function() { + this.textChanged(); + }.bind(this)); + } + + // If we can't execute the command, but there is a menu choice to use + // then use it. + if (!this.tooltip.selectChoice()) { + this.focusManager.setError(true); + } + + this._choice = null; + return RESOLVED; +}; + +/** + * Warning: We get TAB events for more than just the user pressing TAB in our + * input element. + */ +Inputter.prototype._handleTab = function(ev) { + // Being able to complete 'nothing' is OK if there is some context, but + // when there is nothing on the command line it just looks bizarre. + var hasContents = (this.element.value.length > 0); + + // If the TAB keypress took the cursor from another field to this one, + // then they get the keydown/keypress, and we get the keyup. In this + // case we don't want to do any completion. + // If the time of the keydown/keypress of TAB was close (i.e. within + // 1 second) to the time of the keyup then we assume that we got them + // both, and do the completion. + if (hasContents && this.lastTabDownAt + 1000 > ev.timeStamp) { + // It's possible for TAB to not change the input, in which case the caret + // move will not be processed. So we check that this is done first + this._caretChange = Caret.TO_ARG_END; + var inputState = this.getInputState(); + this._processCaretChange(inputState); + + if (this._choice == null) { + this._choice = 0; + } + + // 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 + this._completed = this.requisition.complete(inputState.cursor, + this._choice); + this._previousValue = this.element.value; + } + this.lastTabDownAt = 0; + this._scrollingThroughHistory = false; + + return this._completed.then(function(updated) { + // Abort UI changes if this UI update has been overtaken + if (updated) { + this.textChanged(); + this._choice = null; + this.onChoiceChange({ choice: this._choice }); + } + }.bind(this)); +}; + +/** + * Used by onKeyUp for UP/DOWN to change the current choice from an options + * menu. + */ +Inputter.prototype.changeChoice = function(amount) { + if (this._choice == null) { + this._choice = 0; + } + // There's an annoying up is down thing here, the menu is presented + // with the zeroth index at the top working down, so the UP arrow needs + // pick the choice below because we're working down + this._choice += amount; + this.onChoiceChange({ choice: this._choice }); +}; + +/** + * Pull together an input object, which may include XUL hacks + */ +Inputter.prototype.getInputState = function() { + var input = { + typed: this.element.value, + cursor: { + start: this.element.selectionStart, + end: this.element.selectionEnd + } + }; + + // Workaround for potential XUL bug 676520 where textbox gives incorrect + // values for its content + if (input.typed == null) { + input = { typed: '', cursor: { start: 0, end: 0 } }; + } + + return input; +}; + +exports.Inputter = Inputter; diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/moz.build b/devtools/shared/gcli/source/lib/gcli/mozui/moz.build new file mode 100644 index 000000000..af76e0d99 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/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( + 'completer.js', + 'inputter.js', + 'tooltip.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/tooltip.js b/devtools/shared/gcli/source/lib/gcli/mozui/tooltip.js new file mode 100644 index 000000000..f72900a80 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/tooltip.js @@ -0,0 +1,298 @@ +/* + * 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 CommandAssignment = require('../cli').CommandAssignment; + +var tooltipHtml = + '<div class="gcli-tt" aria-live="polite">\n' + + ' <div class="gcli-tt-description" save="${descriptionEle}">${description}</div>\n' + + ' ${field.element}\n' + + ' <div class="gcli-tt-error" save="${errorEle}">${assignment.conversion.message}</div>\n' + + ' <div class="gcli-tt-highlight" save="${highlightEle}"></div>\n' + + '</div>'; + +/** + * A widget to display an inline dialog which allows the user to fill out + * the arguments to a command. + * @param components Object that links to other UI components. GCLI provided: + * - requisition: The Requisition to fill out + * - inputter: An instance of Inputter + * - focusManager: Component to manage hiding/showing this element + * - panelElement (optional): The element to show/hide on visibility events + * - element: The root element to populate + */ +function Tooltip(components) { + this.inputter = components.inputter; + this.requisition = components.requisition; + this.focusManager = components.focusManager; + + this.element = components.element; + this.element.classList.add('gcliterm-tooltip'); + this.document = this.element.ownerDocument; + + this.panelElement = components.panelElement; + if (this.panelElement) { + this.panelElement.classList.add('gcli-panel-hide'); + this.focusManager.onVisibilityChange.add(this.visibilityChanged, this); + } + this.focusManager.addMonitoredElement(this.element, 'tooltip'); + + // We cache the fields we create so we can destroy them later + this.fields = []; + + this.template = host.toDom(this.document, tooltipHtml); + this.templateOptions = { blankNullUndefined: true, stack: 'tooltip.html' }; + + this.inputter.onChoiceChange.add(this.choiceChanged, this); + this.inputter.onAssignmentChange.add(this.assignmentChanged, this); + + // We keep a track of which assignment the cursor is in + this.assignment = undefined; + this.assignmentChanged({ assignment: this.inputter.assignment }); + + // We also keep track of the last known arg text for the current assignment + this.lastText = undefined; +} + +/** + * Avoid memory leaks + */ +Tooltip.prototype.destroy = function() { + this.inputter.onAssignmentChange.remove(this.assignmentChanged, this); + this.inputter.onChoiceChange.remove(this.choiceChanged, this); + + if (this.panelElement) { + this.focusManager.onVisibilityChange.remove(this.visibilityChanged, this); + } + this.focusManager.removeMonitoredElement(this.element, 'tooltip'); + + if (this.style) { + this.style.parentNode.removeChild(this.style); + this.style = undefined; + } + + this.field.onFieldChange.remove(this.fieldChanged, this); + this.field.destroy(); + + this.lastText = undefined; + this.assignment = undefined; + + this.errorEle = undefined; + this.descriptionEle = undefined; + this.highlightEle = undefined; + + this.document = undefined; + this.element = undefined; + this.panelElement = undefined; + this.template = undefined; +}; + +/** + * The inputter acts on UP/DOWN if there is a menu showing + */ +Object.defineProperty(Tooltip.prototype, 'isMenuShowing', { + get: function() { + return this.focusManager.isTooltipVisible && + this.field != null && + this.field.menu != null; + }, + enumerable: true +}); + +/** + * Called whenever the assignment that we're providing help with changes + */ +Tooltip.prototype.assignmentChanged = function(ev) { + // This can be kicked off either by requisition doing an assign or by + // inputter noticing a cursor movement out of a command, so we should check + // that this really is a new assignment + if (this.assignment === ev.assignment) { + return; + } + + this.assignment = ev.assignment; + this.lastText = this.assignment.arg.text; + + if (this.field) { + this.field.onFieldChange.remove(this.fieldChanged, this); + this.field.destroy(); + } + + this.field = this.requisition.system.fields.get(this.assignment.param.type, { + document: this.document, + requisition: this.requisition + }); + + this.focusManager.setImportantFieldFlag(this.field.isImportant); + + this.field.onFieldChange.add(this.fieldChanged, this); + this.field.setConversion(this.assignment.conversion); + + // Filled in by the template process + this.errorEle = undefined; + this.descriptionEle = undefined; + this.highlightEle = undefined; + + var contents = this.template.cloneNode(true); + domtemplate.template(contents, this, this.templateOptions); + util.clearElement(this.element); + this.element.appendChild(contents); + this.element.style.display = 'block'; + + this.field.setMessageElement(this.errorEle); + + this._updatePosition(); +}; + +/** + * Forward the event to the current field + */ +Tooltip.prototype.choiceChanged = function(ev) { + if (this.field && this.field.menu) { + var conversion = this.assignment.conversion; + var context = this.requisition.executionContext; + conversion.constrainPredictionIndex(context, ev.choice).then(function(choice) { + this.field.menu._choice = choice; + this.field.menu._updateHighlight(); + }.bind(this)).catch(util.errorHandler); + } +}; + +/** + * Allow the inputter to use RETURN to chose the current menu item when + * it can't execute the command line + * @return true if there was a selection to use, false otherwise + */ +Tooltip.prototype.selectChoice = function(ev) { + if (this.field && this.field.selectChoice) { + return this.field.selectChoice(); + } + return false; +}; + +/** + * Called by the onFieldChange event on the current Field + */ +Tooltip.prototype.fieldChanged = function(ev) { + this.requisition.setAssignment(this.assignment, ev.conversion.arg, + { matchPadding: true }); + + var isError = ev.conversion.message != null && ev.conversion.message !== ''; + this.focusManager.setError(isError); + + // Nasty hack, the inputter won't know about the text change yet, so it will + // get it's calculations wrong. We need to wait until the current set of + // changes has had a chance to propagate + this.document.defaultView.setTimeout(function() { + this.inputter.focus(); + }.bind(this), 10); +}; + +/** + * Called by the Inputter when the text changes + */ +Tooltip.prototype.textChanged = function() { + // 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.field.setConversion(this.assignment.conversion); + util.setTextContent(this.descriptionEle, this.description); + + this._updatePosition(); +}; + +/** + * Called to move the tooltip to the correct horizontal position + */ +Tooltip.prototype._updatePosition = function() { + var dimensions = this.getDimensionsOfAssignment(); + + // 10 is roughly the width of a char + if (this.panelElement) { + this.panelElement.style.left = (dimensions.start * 10) + 'px'; + } + + this.focusManager.updatePosition(dimensions); +}; + +/** + * Returns a object containing 'start' and 'end' properties which identify the + * number of pixels from the left hand edge of the input element that represent + * the text portion of the current assignment. + */ +Tooltip.prototype.getDimensionsOfAssignment = function() { + var before = ''; + var assignments = this.requisition.getAssignments(true); + for (var i = 0; i < assignments.length; i++) { + if (assignments[i] === this.assignment) { + break; + } + before += assignments[i].toString(); + } + before += this.assignment.arg.prefix; + + var startChar = before.length; + before += this.assignment.arg.text; + var endChar = before.length; + + return { start: startChar, end: endChar }; +}; + +/** + * 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(Tooltip.prototype, 'description', { + get: function() { + if (this.assignment instanceof CommandAssignment && + this.assignment.value == null) { + return ''; + } + + return this.assignment.param.manual || this.assignment.param.description; + }, + enumerable: true +}); + +/** + * Tweak CSS to show/hide the output + */ +Tooltip.prototype.visibilityChanged = function(ev) { + if (!this.panelElement) { + return; + } + + if (ev.tooltipVisible) { + this.panelElement.classList.remove('gcli-panel-hide'); + } + else { + this.panelElement.classList.add('gcli-panel-hide'); + } +}; + +exports.Tooltip = Tooltip; diff --git a/devtools/shared/gcli/source/lib/gcli/settings.js b/devtools/shared/gcli/source/lib/gcli/settings.js new file mode 100644 index 000000000..29e608cbd --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/settings.js @@ -0,0 +1,284 @@ +/* + * 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 imports = {}; + +var Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; +var Cu = require('chrome').Cu; + +var XPCOMUtils = Cu.import('resource://gre/modules/XPCOMUtils.jsm', {}).XPCOMUtils; +var Services = require("Services"); + +XPCOMUtils.defineLazyGetter(imports, 'prefBranch', function() { + var prefService = Cc['@mozilla.org/preferences-service;1'] + .getService(Ci.nsIPrefService); + return prefService.getBranch(null).QueryInterface(Ci.nsIPrefBranch2); +}); + +XPCOMUtils.defineLazyGetter(imports, 'supportsString', function() { + return Cc['@mozilla.org/supports-string;1'] + .createInstance(Ci.nsISupportsString); +}); + +var util = require('./util/util'); + +/** + * All local settings have this prefix when used in Firefox + */ +var DEVTOOLS_PREFIX = 'devtools.gcli.'; + +/** + * A manager for the registered Settings + */ +function Settings(types, settingValues) { + this._types = types; + + if (settingValues != null) { + throw new Error('settingValues is not supported when writing to prefs'); + } + + // Collection of preferences for sorted access + this._settingsAll = []; + + // Collection of preferences for fast indexed access + this._settingsMap = new Map(); + + // Flag so we know if we've read the system preferences + this._hasReadSystem = false; + + // Event for use to detect when the list of settings changes + this.onChange = util.createEvent('Settings.onChange'); +} + +/** + * Load system prefs if they've not been loaded already + * @return true + */ +Settings.prototype._readSystem = function() { + if (this._hasReadSystem) { + return; + } + + imports.prefBranch.getChildList('').forEach(function(name) { + var setting = new Setting(this, name); + this._settingsAll.push(setting); + this._settingsMap.set(name, setting); + }.bind(this)); + + this._settingsAll.sort(function(s1, s2) { + return s1.name.localeCompare(s2.name); + }.bind(this)); + + this._hasReadSystem = true; +}; + +/** + * Get an array containing all known Settings filtered to match the given + * filter (string) at any point in the name of the setting + */ +Settings.prototype.getAll = function(filter) { + this._readSystem(); + + if (filter == null) { + return this._settingsAll; + } + + return this._settingsAll.filter(function(setting) { + return setting.name.indexOf(filter) !== -1; + }.bind(this)); +}; + +/** + * Add a new setting + */ +Settings.prototype.add = function(prefSpec) { + var setting = new Setting(this, prefSpec); + + if (this._settingsMap.has(setting.name)) { + // Once exists already, we're going to need to replace it in the array + for (var i = 0; i < this._settingsAll.length; i++) { + if (this._settingsAll[i].name === setting.name) { + this._settingsAll[i] = setting; + } + } + } + + this._settingsMap.set(setting.name, setting); + this.onChange({ added: setting.name }); + + return setting; +}; + +/** + * Getter for an existing setting. Generally use of this function should be + * avoided. Systems that define a setting should export it if they wish it to + * be available to the outside, or not otherwise. Use of this function breaks + * that boundary and also hides dependencies. Acceptable uses include testing + * and embedded uses of GCLI that pre-define all settings (e.g. Firefox) + * @param name The name of the setting to fetch + * @return The found Setting object, or undefined if the setting was not found + */ +Settings.prototype.get = function(name) { + // We might be able to give the answer without needing to read all system + // settings if this is an internal setting + var found = this._settingsMap.get(name); + if (!found) { + found = this._settingsMap.get(DEVTOOLS_PREFIX + name); + } + + if (found) { + return found; + } + + if (this._hasReadSystem) { + return undefined; + } + else { + this._readSystem(); + found = this._settingsMap.get(name); + if (!found) { + found = this._settingsMap.get(DEVTOOLS_PREFIX + name); + } + return found; + } +}; + +/** + * Remove a setting. A no-op in this case + */ +Settings.prototype.remove = function() { +}; + +exports.Settings = Settings; + +/** + * A class to wrap up the properties of a Setting. + * @see toolkit/components/viewconfig/content/config.js + */ +function Setting(settings, prefSpec) { + this._settings = settings; + if (typeof prefSpec === 'string') { + // We're coming from getAll() i.e. a full listing of prefs + this.name = prefSpec; + this.description = ''; + } + else { + // A specific addition by GCLI + this.name = DEVTOOLS_PREFIX + prefSpec.name; + + if (prefSpec.ignoreTypeDifference !== true && prefSpec.type) { + if (this.type.name !== prefSpec.type) { + throw new Error('Locally declared type (' + prefSpec.type + ') != ' + + 'Mozilla declared type (' + this.type.name + ') for ' + this.name); + } + } + + this.description = prefSpec.description; + } + + this.onChange = util.createEvent('Setting.onChange'); +} + +/** + * Reset this setting to it's initial default value + */ +Setting.prototype.setDefault = function() { + imports.prefBranch.clearUserPref(this.name); + Services.prefs.savePrefFile(null); +}; + +/** + * What type is this property: boolean/integer/string? + */ +Object.defineProperty(Setting.prototype, 'type', { + get: function() { + switch (imports.prefBranch.getPrefType(this.name)) { + case imports.prefBranch.PREF_BOOL: + return this._settings._types.createType('boolean'); + + case imports.prefBranch.PREF_INT: + return this._settings._types.createType('number'); + + case imports.prefBranch.PREF_STRING: + return this._settings._types.createType('string'); + + default: + throw new Error('Unknown type for ' + this.name); + } + }, + enumerable: true +}); + +/** + * What type is this property: boolean/integer/string? + */ +Object.defineProperty(Setting.prototype, 'value', { + get: function() { + switch (imports.prefBranch.getPrefType(this.name)) { + case imports.prefBranch.PREF_BOOL: + return imports.prefBranch.getBoolPref(this.name); + + case imports.prefBranch.PREF_INT: + return imports.prefBranch.getIntPref(this.name); + + case imports.prefBranch.PREF_STRING: + var value = imports.prefBranch.getComplexValue(this.name, + Ci.nsISupportsString).data; + // In case of a localized string + if (/^chrome:\/\/.+\/locale\/.+\.properties/.test(value)) { + value = imports.prefBranch.getComplexValue(this.name, + Ci.nsIPrefLocalizedString).data; + } + return value; + + default: + throw new Error('Invalid value for ' + this.name); + } + }, + + set: function(value) { + if (imports.prefBranch.prefIsLocked(this.name)) { + throw new Error('Locked preference ' + this.name); + } + + switch (imports.prefBranch.getPrefType(this.name)) { + case imports.prefBranch.PREF_BOOL: + imports.prefBranch.setBoolPref(this.name, value); + break; + + case imports.prefBranch.PREF_INT: + imports.prefBranch.setIntPref(this.name, value); + break; + + case imports.prefBranch.PREF_STRING: + imports.supportsString.data = value; + imports.prefBranch.setComplexValue(this.name, + Ci.nsISupportsString, + imports.supportsString); + break; + + default: + throw new Error('Invalid value for ' + this.name); + } + + Services.prefs.savePrefFile(null); + }, + + enumerable: true +}); diff --git a/devtools/shared/gcli/source/lib/gcli/system.js b/devtools/shared/gcli/source/lib/gcli/system.js new file mode 100644 index 000000000..5a4719b8d --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/system.js @@ -0,0 +1,370 @@ +/* + * 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 Commands = require('./commands/commands').Commands; +var Connectors = require('./connectors/connectors').Connectors; +var Converters = require('./converters/converters').Converters; +var Fields = require('./fields/fields').Fields; +var Languages = require('./languages/languages').Languages; +var Settings = require('./settings').Settings; +var Types = require('./types/types').Types; + +/** + * This is the heart of the API that we expose to the outside. + * @param options Object that customizes how the system acts. Valid properties: + * - commands, connectors, converters, fields, languages, settings, types: + * Custom configured manager objects for these item types + * - location: a system with a location will ignore commands that don't have a + * matching runAt property. This is principly for client/server setups where + * we import commands from the server to the client, so a system with + * `{ location: 'client' }` will silently ignore commands with + * `{ runAt: 'server' }`. Any system without a location will accept commands + * with any runAt property (including none). + */ +exports.createSystem = function(options) { + options = options || {}; + var location = options.location; + + // The plural/singular thing may make you want to scream, but it allows us + // to say components[getItemType(item)], so a lookup here (and below) saves + // multiple lookups in the middle of the code + var components = { + connector: options.connectors || new Connectors(), + converter: options.converters || new Converters(), + field: options.fields || new Fields(), + language: options.languages || new Languages(), + type: options.types || new Types() + }; + components.setting = new Settings(components.type); + components.command = new Commands(components.type, location); + + var getItemType = function(item) { + if (item.item) { + return item.item; + } + // Some items are registered using the constructor so we need to check + // the prototype for the the type of the item + return (item.prototype && item.prototype.item) ? + item.prototype.item : 'command'; + }; + + var addItem = function(item) { + try { + components[getItemType(item)].add(item); + } + catch (ex) { + if (item != null) { + console.error('While adding: ' + item.name); + } + throw ex; + } + }; + + var removeItem = function(item) { + components[getItemType(item)].remove(item); + }; + + /** + * loadableModules is a lookup of names to module loader functions (like + * the venerable 'require') to which we can pass a name and get back a + * JS object (or a promise of a JS object). This allows us to have custom + * loaders to get stuff from the filesystem etc. + */ + var loadableModules = {}; + + /** + * loadedModules is a lookup by name of the things returned by the functions + * in loadableModules so we can track what we need to unload / reload. + */ + var loadedModules = {}; + + var unloadModule = function(name) { + var existingModule = loadedModules[name]; + if (existingModule != null) { + existingModule.items.forEach(removeItem); + } + delete loadedModules[name]; + }; + + var loadModule = function(name) { + var existingModule = loadedModules[name]; + unloadModule(name); + + // And load the new items + try { + var loader = loadableModules[name]; + return Promise.resolve(loader(name)).then(function(newModule) { + if (existingModule === newModule) { + return; + } + + if (newModule == null) { + throw 'Module \'' + name + '\' not found'; + } + + if (newModule.items == null || typeof newModule.items.forEach !== 'function') { + console.log('Exported properties: ' + Object.keys(newModule).join(', ')); + throw 'Module \'' + name + '\' has no \'items\' array export'; + } + + newModule.items.forEach(addItem); + + loadedModules[name] = newModule; + }); + } + catch (ex) { + console.error('Failed to load module ' + name + ': ' + ex); + console.error(ex.stack); + + return Promise.resolve(); + } + }; + + var pendingChanges = false; + + var system = { + addItems: function(items) { + items.forEach(addItem); + }, + + removeItems: function(items) { + items.forEach(removeItem); + }, + + addItemsByModule: function(names, options) { + var promises = []; + + options = options || {}; + if (!options.delayedLoad) { + // We could be about to add many commands, just report the change once + this.commands.onCommandsChange.holdFire(); + } + + if (typeof names === 'string') { + names = [ names ]; + } + names.forEach(function(name) { + if (options.loader == null) { + options.loader = function(name) { + return require(name); + }; + } + loadableModules[name] = options.loader; + + if (options.delayedLoad) { + pendingChanges = true; + } + else { + promises.push(loadModule(name).catch(console.error)); + } + }); + + if (options.delayedLoad) { + return Promise.resolve(); + } + else { + return Promise.all(promises).then(function() { + this.commands.onCommandsChange.resumeFire(); + }.bind(this)); + } + }, + + removeItemsByModule: function(name) { + this.commands.onCommandsChange.holdFire(); + + delete loadableModules[name]; + unloadModule(name); + + this.commands.onCommandsChange.resumeFire(); + }, + + load: function() { + if (!pendingChanges) { + return Promise.resolve(); + } + this.commands.onCommandsChange.holdFire(); + + // clone loadedModules, so we can remove what is left at the end + var modules = Object.keys(loadedModules).map(function(name) { + return loadedModules[name]; + }); + + var promises = Object.keys(loadableModules).map(function(name) { + delete modules[name]; + return loadModule(name).catch(console.error); + }); + + Object.keys(modules).forEach(unloadModule); + pendingChanges = false; + + return Promise.all(promises).then(function() { + this.commands.onCommandsChange.resumeFire(); + }.bind(this)); + }, + + destroy: function() { + this.commands.onCommandsChange.holdFire(); + + Object.keys(loadedModules).forEach(function(name) { + unloadModule(name); + }); + + this.commands.onCommandsChange.resumeFire(); + }, + + toString: function() { + return 'System [' + + 'commands:' + components.command.getAll().length + ', ' + + 'connectors:' + components.connector.getAll().length + ', ' + + 'converters:' + components.converter.getAll().length + ', ' + + 'fields:' + components.field.getAll().length + ', ' + + 'settings:' + components.setting.getAll().length + ', ' + + 'types:' + components.type.getTypeNames().length + ']'; + } + }; + + Object.defineProperty(system, 'commands', { + get: function() { return components.command; }, + enumerable: true + }); + + Object.defineProperty(system, 'connectors', { + get: function() { return components.connector; }, + enumerable: true + }); + + Object.defineProperty(system, 'converters', { + get: function() { return components.converter; }, + enumerable: true + }); + + Object.defineProperty(system, 'fields', { + get: function() { return components.field; }, + enumerable: true + }); + + Object.defineProperty(system, 'languages', { + get: function() { return components.language; }, + enumerable: true + }); + + Object.defineProperty(system, 'settings', { + get: function() { return components.setting; }, + enumerable: true + }); + + Object.defineProperty(system, 'types', { + get: function() { return components.type; }, + enumerable: true + }); + + return system; +}; + +/** + * Connect a local system with another at the other end of a connector + * @param system System to which we're adding commands + * @param front Front which allows access to the remote system from which we + * import commands + * @param customProps Array of strings specifying additional properties defined + * on remote commands that should be considered part of the metadata for the + * commands imported into the local system + */ +exports.connectFront = function(system, front, customProps) { + system._handleCommandsChanged = function() { + syncItems(system, front, customProps).catch(util.errorHandler); + }; + front.on('commands-changed', system._handleCommandsChanged); + + return syncItems(system, front, customProps); +}; + +/** + * Undo the effect of #connectFront + */ +exports.disconnectFront = function(system, front) { + front.off('commands-changed', system._handleCommandsChanged); + system._handleCommandsChanged = undefined; + removeItemsFromFront(system, front); +}; + +/** + * Remove the items in this system that came from a previous sync action, and + * re-add them. See connectFront() for explanation of properties + */ +function syncItems(system, front, customProps) { + return front.specs(customProps).then(function(specs) { + removeItemsFromFront(system, front); + + var remoteItems = addLocalFunctions(specs, front); + system.addItems(remoteItems); + + return system; + }); +}; + +/** + * Take the data from the 'specs' command (or the 'commands-changed' event) and + * add function to proxy the execution back over the front + */ +function addLocalFunctions(specs, front) { + // Inject an 'exec' function into the commands, and the front into + // all the remote types + specs.forEach(function(commandSpec) { + // HACK: Tack the front to the command so we know how to remove it + // in removeItemsFromFront() below + commandSpec.front = front; + + // Tell the type instances for a command how to contact their counterparts + // Don't confuse this with setting the front on the commandSpec which is + // about associating a proxied command with it's source for later removal. + // This is actually going to be used by the type + commandSpec.params.forEach(function(param) { + if (typeof param.type !== 'string') { + param.type.front = front; + } + }); + + if (!commandSpec.isParent) { + commandSpec.exec = function(args, context) { + var typed = (context.prefix ? context.prefix + ' ' : '') + context.typed; + return front.execute(typed).then(function(reply) { + var typedData = context.typedData(reply.type, reply.data); + return reply.isError ? Promise.reject(typedData) : typedData; + }); + }; + } + + commandSpec.isProxy = true; + }); + + return specs; +} + +/** + * Go through all the commands removing any that are associated with the + * given front. The method of association is the hack in addLocalFunctions. + */ +function removeItemsFromFront(system, front) { + system.commands.getAll().forEach(function(command) { + if (command.front === front) { + system.commands.remove(command); + } + }); +} diff --git a/devtools/shared/gcli/source/lib/gcli/types/array.js b/devtools/shared/gcli/source/lib/gcli/types/array.js new file mode 100644 index 000000000..381bd0b80 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/array.js @@ -0,0 +1,80 @@ +/* + * 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 ArrayConversion = require('./types').ArrayConversion; +var ArrayArgument = require('./types').ArrayArgument; + +exports.items = [ + { + // A set of objects of the same type + item: 'type', + name: 'array', + subtype: undefined, + + constructor: function() { + if (!this.subtype) { + console.error('Array.typeSpec is missing subtype. Assuming string.' + + this.name); + this.subtype = 'string'; + } + this.subtype = this.types.createType(this.subtype); + }, + + getSpec: function(commandName, paramName) { + return { + name: 'array', + subtype: this.subtype.getSpec(commandName, paramName), + }; + }, + + stringify: function(values, context) { + if (values == null) { + return ''; + } + // BUG 664204: Check for strings with spaces and add quotes + return values.join(' '); + }, + + parse: function(arg, context) { + if (arg.type !== 'ArrayArgument') { + console.error('non ArrayArgument to ArrayType.parse', arg); + throw new Error('non ArrayArgument to ArrayType.parse'); + } + + // Parse an argument to a conversion + // Hack alert. ArrayConversion needs to be able to answer questions about + // the status of individual conversions in addition to the overall state. + // |subArg.conversion| allows us to do that easily. + var subArgParse = function(subArg) { + return this.subtype.parse(subArg, context).then(function(conversion) { + subArg.conversion = conversion; + return conversion; + }.bind(this)); + }.bind(this); + + var conversionPromises = arg.getArguments().map(subArgParse); + return Promise.all(conversionPromises).then(function(conversions) { + return new ArrayConversion(conversions, arg); + }); + }, + + getBlank: function(context) { + return new ArrayConversion([], new ArrayArgument()); + } + }, +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/boolean.js b/devtools/shared/gcli/source/lib/gcli/types/boolean.js new file mode 100644 index 000000000..01f5f5022 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/boolean.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 Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var BlankArgument = require('./types').BlankArgument; +var SelectionType = require('./selection').SelectionType; + +exports.items = [ + { + // 'boolean' type + item: 'type', + name: 'boolean', + parent: 'selection', + + getSpec: function() { + return 'boolean'; + }, + + lookup: [ + { name: 'false', value: false }, + { name: 'true', value: true } + ], + + parse: function(arg, context) { + if (arg.type === 'TrueNamedArgument') { + return Promise.resolve(new Conversion(true, arg)); + } + if (arg.type === 'FalseNamedArgument') { + return Promise.resolve(new Conversion(false, arg)); + } + return SelectionType.prototype.parse.call(this, arg, context); + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return '' + value; + }, + + getBlank: function(context) { + return new Conversion(false, new BlankArgument(), Status.VALID, '', + Promise.resolve(this.lookup)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/command.js b/devtools/shared/gcli/source/lib/gcli/types/command.js new file mode 100644 index 000000000..779aa77ab --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/command.js @@ -0,0 +1,255 @@ +/* + * 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 spell = require('../util/spell'); +var SelectionType = require('./selection').SelectionType; +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var cli = require('../cli'); + +exports.items = [ + { + // Select from the available parameters to a command + item: 'type', + name: 'param', + parent: 'selection', + stringifyProperty: 'name', + requisition: undefined, + isIncompleteName: undefined, + + getSpec: function() { + throw new Error('param type is not remotable'); + }, + + lookup: function() { + return exports.getDisplayedParamLookup(this.requisition); + }, + + parse: function(arg, context) { + if (this.isIncompleteName) { + return SelectionType.prototype.parse.call(this, arg, context); + } + else { + var message = l10n.lookup('cliUnusedArg'); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, message)); + } + } + }, + { + // Select from the available commands + // This is very similar to a SelectionType, however the level of hackery in + // SelectionType to make it handle Commands correctly was to high, so we + // simplified. + // If you are making changes to this code, you should check there too. + item: 'type', + name: 'command', + parent: 'selection', + stringifyProperty: 'name', + allowNonExec: true, + + getSpec: function() { + return { + name: 'command', + allowNonExec: this.allowNonExec + }; + }, + + lookup: function(context) { + var commands = cli.getMapping(context).requisition.system.commands; + return exports.getCommandLookup(commands); + }, + + parse: function(arg, context) { + var conversion = exports.parse(context, arg, this.allowNonExec); + return Promise.resolve(conversion); + } + } +]; + +exports.getDisplayedParamLookup = function(requisition) { + var displayedParams = []; + var command = requisition.commandAssignment.value; + if (command != null) { + command.params.forEach(function(param) { + var arg = requisition.getAssignment(param.name).arg; + if (!param.isPositionalAllowed && arg.type === 'BlankArgument') { + displayedParams.push({ name: '--' + param.name, value: param }); + } + }); + } + return displayedParams; +}; + +exports.parse = function(context, arg, allowNonExec) { + var commands = cli.getMapping(context).requisition.system.commands; + var lookup = exports.getCommandLookup(commands); + var predictions = exports.findPredictions(arg, lookup); + return exports.convertPredictions(commands, arg, allowNonExec, predictions); +}; + +exports.getCommandLookup = function(commands) { + var sorted = commands.getAll().sort(function(c1, c2) { + return c1.name.localeCompare(c2.name); + }); + return sorted.map(function(command) { + return { name: command.name, value: command }; + }); +}; + +exports.findPredictions = function(arg, lookup) { + var predictions = []; + var i, option; + var maxPredictions = Conversion.maxPredictions; + var match = arg.text.toLowerCase(); + + // Add an option to our list of predicted options + var addToPredictions = function(option) { + if (arg.text.length === 0) { + // If someone hasn't typed anything, we only show top level commands in + // the menu. i.e. sub-commands (those with a space in their name) are + // excluded. We do this to keep the list at an overview level. + if (option.name.indexOf(' ') === -1) { + predictions.push(option); + } + } + else { + // If someone has typed something, then we exclude parent commands + // (those without an exec). We do this because the user is drilling + // down and doesn't need the summary level. + if (option.value.exec != null) { + predictions.push(option); + } + } + }; + + // If the arg has a suffix then we're kind of 'done'. Only an exact + // match will do. + if (arg.suffix.match(/ +/)) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text || + option.name.indexOf(arg.text + ' ') === 0) { + addToPredictions(option); + } + } + + return predictions; + } + + // Cache lower case versions of all the option names + for (i = 0; i < lookup.length; i++) { + option = lookup[i]; + if (option._gcliLowerName == null) { + option._gcliLowerName = option.name.toLowerCase(); + } + } + + // Exact hidden matches. If 'hidden: true' then we only allow exact matches + // All the tests after here check that !option.value.hidden + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text) { + addToPredictions(option); + } + } + + // Start with prefix matching + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) === 0 && !option.value.hidden) { + if (predictions.indexOf(option) === -1) { + addToPredictions(option); + } + } + } + + // Try infix matching if we get less half max matched + if (predictions.length < (maxPredictions / 2)) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) !== -1 && !option.value.hidden) { + if (predictions.indexOf(option) === -1) { + addToPredictions(option); + } + } + } + } + + // Try fuzzy matching if we don't get a prefix match + if (predictions.length === 0) { + var names = []; + lookup.forEach(function(opt) { + if (!opt.value.hidden) { + names.push(opt.name); + } + }); + var corrected = spell.correct(match, names); + if (corrected) { + lookup.forEach(function(opt) { + if (opt.name === corrected) { + predictions.push(opt); + } + }); + } + } + + return predictions; +}; + +exports.convertPredictions = function(commands, arg, allowNonExec, predictions) { + var command = commands.get(arg.text); + // Helper function - Commands like 'context' work best with parent + // commands which are not executable. However obviously to execute a + // command, it needs an exec function. + var execWhereNeeded = (allowNonExec || + (command != null && typeof command.exec === 'function')); + + var isExact = command && command.name === arg.text && + execWhereNeeded && predictions.length === 1; + var alternatives = isExact ? [] : predictions; + + if (command) { + var status = execWhereNeeded ? Status.VALID : Status.INCOMPLETE; + return new Conversion(command, arg, status, '', alternatives); + } + + if (predictions.length === 0) { + var msg = l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]); + return new Conversion(undefined, arg, Status.ERROR, msg, alternatives); + } + + command = predictions[0].value; + + if (predictions.length === 1) { + // Is it an exact match of an executable command, + // or just the only possibility? + if (command.name === arg.text && execWhereNeeded) { + return new Conversion(command, arg, Status.VALID, ''); + } + + return new Conversion(undefined, arg, Status.INCOMPLETE, '', alternatives); + } + + // It's valid if the text matches, even if there are several options + if (predictions[0].name === arg.text) { + return new Conversion(command, arg, Status.VALID, '', alternatives); + } + + return new Conversion(undefined, arg, Status.INCOMPLETE, '', alternatives); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/types/date.js b/devtools/shared/gcli/source/lib/gcli/types/date.js new file mode 100644 index 000000000..f05569724 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/date.js @@ -0,0 +1,248 @@ +/* + * 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 Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +/** + * Helper for stringify() to left pad a single digit number with a single '0' + * so 1 -> '01', 42 -> '42', etc. + */ +function pad(number) { + var r = String(number); + return r.length === 1 ? '0' + r : r; +} + +/** + * Utility to convert a string to a date, throwing if the date can't be + * parsed rather than having an invalid date + */ +function toDate(str) { + var millis = Date.parse(str); + if (isNaN(millis)) { + throw new Error(l10n.lookupFormat('typesDateNan', [ str ])); + } + return new Date(millis); +} + +/** + * Is |thing| a valid date? + * @see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript + */ +function isDate(thing) { + return Object.prototype.toString.call(thing) === '[object Date]' + && !isNaN(thing.getTime()); +} + +exports.items = [ + { + // ECMA 5.1 Ā§15.9.1.1 + // @see http://stackoverflow.com/questions/11526504/minimum-and-maximum-date + item: 'type', + name: 'date', + step: 1, + min: new Date(-8640000000000000), + max: new Date(8640000000000000), + + constructor: function() { + this._origMin = this.min; + if (this.min != null) { + if (typeof this.min === 'string') { + this.min = toDate(this.min); + } + else if (isDate(this.min) || typeof this.min === 'function') { + this.min = this.min; + } + else { + throw new Error('date min value must be one of string/date/function'); + } + } + + this._origMax = this.max; + if (this.max != null) { + if (typeof this.max === 'string') { + this.max = toDate(this.max); + } + else if (isDate(this.max) || typeof this.max === 'function') { + this.max = this.max; + } + else { + throw new Error('date max value must be one of string/date/function'); + } + } + }, + + getSpec: function() { + var spec = { + name: 'date' + }; + if (this.step !== 1) { + spec.step = this.step; + } + if (this._origMax != null) { + spec.max = this._origMax; + } + if (this._origMin != null) { + spec.min = this._origMin; + } + return spec; + }, + + stringify: function(value, context) { + if (!isDate(value)) { + return ''; + } + + var str = pad(value.getFullYear()) + '-' + + pad(value.getMonth() + 1) + '-' + + pad(value.getDate()); + + // Only add in the time if it's not midnight + if (value.getHours() !== 0 || value.getMinutes() !== 0 || + value.getSeconds() !== 0 || value.getMilliseconds() !== 0) { + + // What string should we use to separate the date from the time? + // There are 3 options: + // 'T': This is the standard from ISO8601. i.e. 2013-05-20T11:05 + // The good news - it's a standard. The bad news - it's weird and + // alien to many if not most users + // ' ': This looks nicest, but needs escaping (which GCLI will do + // automatically) so it would look like: '2013-05-20 11:05' + // Good news: looks best, bad news: on completion we place the + // cursor after the final ', breaking repeated increment/decrement + // '\ ': It's possible that we could find a way to use a \ to escape + // the space, so the output would look like: 2013-05-20\ 11:05 + // This would involve changes to a number of parts, and is + // probably too complex a solution for this problem for now + // In the short term I'm going for ' ', and raising the priority of + // cursor positioning on actions like increment/decrement/tab. + + str += ' ' + pad(value.getHours()); + str += ':' + pad(value.getMinutes()); + + // Only add in seconds/milliseconds if there is anything to report + if (value.getSeconds() !== 0 || value.getMilliseconds() !== 0) { + str += ':' + pad(value.getSeconds()); + if (value.getMilliseconds() !== 0) { + var milliVal = (value.getUTCMilliseconds() / 1000).toFixed(3); + str += '.' + String(milliVal).slice(2, 5); + } + } + } + + return str; + }, + + getMax: function(context) { + if (typeof this.max === 'function') { + return this._max(context); + } + if (isDate(this.max)) { + return this.max; + } + return undefined; + }, + + getMin: function(context) { + if (typeof this.min === 'function') { + return this._min(context); + } + if (isDate(this.min)) { + return this.min; + } + return undefined; + }, + + parse: function(arg, context) { + var value; + + if (arg.text.replace(/\s/g, '').length === 0) { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE, '')); + } + + // Lots of room for improvement here: 1h ago, in two days, etc. + // Should "1h ago" dynamically update the step? + if (arg.text.toLowerCase() === 'now' || + arg.text.toLowerCase() === 'today') { + value = new Date(); + } + else if (arg.text.toLowerCase() === 'yesterday') { + value = new Date(); + value.setDate(value.getDate() - 1); + } + else if (arg.text.toLowerCase() === 'tomorrow') { + value = new Date(); + value.setDate(value.getDate() + 1); + } + else { + // So now actual date parsing. + // Javascript dates are a mess. Like the default date libraries in most + // common languages, but with added browser weirdness. + // There is an argument for saying that the user will expect dates to + // be formatted as JavaScript dates, except that JS dates are of + // themselves very unexpected. + // See http://blog.dygraphs.com/2012/03/javascript-and-dates-what-mess.html + + // The timezone used by Date.parse depends on whether or not the string + // can be interpreted as ISO-8601, so "2000-01-01" is not the same as + // "2000/01/01" (unless your TZ aligns with UTC) because the first is + // ISO-8601 and therefore assumed to be UTC, where the latter is + // assumed to be in the local timezone. + + // First, if the user explicitly includes a 'Z' timezone marker, then + // we assume they know what they are doing with timezones. ISO-8601 + // uses 'Z' as a marker for 'Zulu time', zero hours offset i.e. UTC + if (arg.text.indexOf('Z') !== -1) { + value = new Date(arg.text); + } + else { + // Now we don't want the browser to assume ISO-8601 and therefore use + // UTC so we replace the '-' with '/' + value = new Date(arg.text.replace(/-/g, '/')); + } + + if (isNaN(value.getTime())) { + var msg = l10n.lookupFormat('typesDateNan', [ arg.text ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + } + + return Promise.resolve(new Conversion(value, arg)); + }, + + nudge: function(value, by, context) { + if (!isDate(value)) { + return new Date(); + } + + var newValue = new Date(value); + newValue.setDate(value.getDate() + (by * this.step)); + + if (newValue < this.getMin(context)) { + return this.getMin(context); + } + else if (newValue > this.getMax(context)) { + return this.getMax(); + } + else { + return newValue; + } + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/delegate.js b/devtools/shared/gcli/source/lib/gcli/types/delegate.js new file mode 100644 index 000000000..978718231 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/delegate.js @@ -0,0 +1,158 @@ +/* + * 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 Conversion = require('./types').Conversion; +var Status = require('./types').Status; +var BlankArgument = require('./types').BlankArgument; + +/** + * The types we expose for registration + */ +exports.items = [ + // A type for "we don't know right now, but hope to soon" + { + item: 'type', + name: 'delegate', + + getSpec: function(commandName, paramName) { + return { + name: 'delegate', + param: paramName + }; + }, + + // Child types should implement this method to return an instance of the type + // that should be used. If no type is available, or some sort of temporary + // placeholder is required, BlankType can be used. + delegateType: undefined, + + stringify: function(value, context) { + return this.getType(context).then(function(delegated) { + return delegated.stringify(value, context); + }.bind(this)); + }, + + parse: function(arg, context) { + return this.getType(context).then(function(delegated) { + return delegated.parse(arg, context); + }.bind(this)); + }, + + nudge: function(value, by, context) { + return this.getType(context).then(function(delegated) { + return delegated.nudge ? + delegated.nudge(value, by, context) : + undefined; + }.bind(this)); + }, + + getType: function(context) { + if (this.delegateType === undefined) { + return Promise.resolve(this.types.createType('blank')); + } + + var type = this.delegateType(context); + if (typeof type.parse !== 'function') { + type = this.types.createType(type); + } + return Promise.resolve(type); + }, + + // DelegateType is designed to be inherited from, so DelegateField needs a + // way to check if something works like a delegate without using 'name' + isDelegate: true, + + // Technically we perhaps should proxy this, except that properties are + // inherently synchronous, so we can't. It doesn't seem important enough to + // change the function definition to accommodate this right now + isImportant: false + }, + { + item: 'type', + name: 'remote', + paramName: undefined, + blankIsValid: false, + hasPredictions: true, + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName, + blankIsValid: this.blankIsValid + }; + }, + + getBlank: function(context) { + if (this.blankIsValid) { + return new Conversion({ stringified: '' }, + new BlankArgument(), Status.VALID); + } + else { + return new Conversion(undefined, new BlankArgument(), + Status.INCOMPLETE, ''); + } + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + // remote types are client only, and we don't attempt to transfer value + // objects to the client (we can't be sure the are jsonable) so it is a + // bit strange to be asked to stringify a value object, however since + // parse creates a Conversion with a (fake) value object we might be + // asked to stringify that. We can stringify fake value objects. + if (typeof value.stringified === 'string') { + return value.stringified; + } + throw new Error('Can\'t stringify that value'); + }, + + parse: function(arg, context) { + return this.front.parseType(context.typed, this.paramName).then(function(json) { + var status = Status.fromString(json.status); + return new Conversion(undefined, arg, status, json.message, json.predictions); + }.bind(this)); + }, + + nudge: function(value, by, context) { + return this.front.nudgeType(context.typed, by, this.paramName).then(function(json) { + return { stringified: json.arg }; + }.bind(this)); + } + }, + // 'blank' is a type for use with DelegateType when we don't know yet. + // It should not be used anywhere else. + { + item: 'type', + name: 'blank', + + getSpec: function(commandName, paramName) { + return 'blank'; + }, + + stringify: function(value, context) { + return ''; + }, + + parse: function(arg, context) { + return Promise.resolve(new Conversion(undefined, arg)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/file.js b/devtools/shared/gcli/source/lib/gcli/types/file.js new file mode 100644 index 000000000..004f0108c --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/file.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'; + +/* + * The file type is a bit of a spiders-web, but there isn't a nice solution + * yet. The core of the problem is that the modules used by Firefox and NodeJS + * intersect with the modules used by the web, but not each other. Except here. + * So we have to do something fancy to get the sharing but not mess up the web. + * + * This file requires 'gcli/types/fileparser', and there are 4 implementations + * of this: + * - '/lib/gcli/types/fileparser.js', the default web version that uses XHR to + * talk to the node server + * - '/lib/server/gcli/types/fileparser.js', an NodeJS stub, and ... + * - '/mozilla/gcli/types/fileparser.js', the Firefox implementation both of + * these are shims which import + * - 'gcli/util/fileparser', does the real work, except the actual file access + * + * The file access comes from the 'gcli/util/filesystem' module, and there are + * 2 implementations of this: + * - '/lib/server/gcli/util/filesystem.js', which uses NodeJS APIs + * - '/mozilla/gcli/util/filesystem.js', which uses OS.File APIs + */ + +var fileparser = require('./fileparser'); +var Conversion = require('./types').Conversion; + +exports.items = [ + { + item: 'type', + name: 'file', + + filetype: 'any', // One of 'file', 'directory', 'any' + existing: 'maybe', // Should be one of 'yes', 'no', 'maybe' + matches: undefined, // RegExp to match the file part of the path + + hasPredictions: true, + + constructor: function() { + if (this.filetype !== 'any' && this.filetype !== 'file' && + this.filetype !== 'directory') { + throw new Error('filetype must be one of [any|file|directory]'); + } + + if (this.existing !== 'yes' && this.existing !== 'no' && + this.existing !== 'maybe') { + throw new Error('existing must be one of [yes|no|maybe]'); + } + }, + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName + }; + }, + + stringify: function(file) { + if (file == null) { + return ''; + } + + return file.toString(); + }, + + parse: function(arg, context) { + var options = { + filetype: this.filetype, + existing: this.existing, + matches: this.matches + }; + var promise = fileparser.parse(context, arg.text, options); + + return promise.then(function(reply) { + return new Conversion(reply.value, arg, reply.status, + reply.message, reply.predictor); + }); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/fileparser.js b/devtools/shared/gcli/source/lib/gcli/types/fileparser.js new file mode 100644 index 000000000..5db86dc66 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/fileparser.js @@ -0,0 +1,19 @@ +/* + * 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'; + +exports.parse = require('../util/fileparser').parse; diff --git a/devtools/shared/gcli/source/lib/gcli/types/javascript.js b/devtools/shared/gcli/source/lib/gcli/types/javascript.js new file mode 100644 index 000000000..71324ef2a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/javascript.js @@ -0,0 +1,522 @@ +/* + * 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 Conversion = require('./types').Conversion; +var Type = require('./types').Type; +var Status = require('./types').Status; + +/** + * 'javascript' handles scripted input + */ +function JavascriptType(typeSpec) { +} + +JavascriptType.prototype = Object.create(Type.prototype); + +JavascriptType.prototype.getSpec = function(commandName, paramName) { + return { + name: 'remote', + paramName: paramName + }; +}; + +JavascriptType.prototype.stringify = function(value, context) { + if (value == null) { + return ''; + } + return value; +}; + +/** + * When sorting out completions, there is no point in displaying millions of + * matches - this the number of matches that we aim for + */ +JavascriptType.MAX_COMPLETION_MATCHES = 10; + +JavascriptType.prototype.parse = function(arg, context) { + var typed = arg.text; + var scope = (context.environment.window == null) ? + null : context.environment.window; + + // No input is undefined + if (typed === '') { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE)); + } + // Just accept numbers + if (!isNaN(parseFloat(typed)) && isFinite(typed)) { + return Promise.resolve(new Conversion(typed, arg)); + } + // Just accept constants like true/false/null/etc + if (typed.trim().match(/(null|undefined|NaN|Infinity|true|false)/)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // Analyze the input text and find the beginning of the last part that + // should be completed. + var beginning = this._findCompletionBeginning(typed); + + // There was an error analyzing the string. + if (beginning.err) { + return Promise.resolve(new Conversion(typed, arg, Status.ERROR, beginning.err)); + } + + // If the current state is ParseState.COMPLEX, then we can't do completion. + // so bail out now + if (beginning.state === ParseState.COMPLEX) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // If the current state is not ParseState.NORMAL, then we are inside of a + // string which means that no completion is possible. + if (beginning.state !== ParseState.NORMAL) { + return Promise.resolve(new Conversion(typed, arg, Status.INCOMPLETE, '')); + } + + var completionPart = typed.substring(beginning.startPos); + var properties = completionPart.split('.'); + var matchProp; + var prop; + + if (properties.length > 1) { + matchProp = properties.pop().trimLeft(); + for (var i = 0; i < properties.length; i++) { + prop = properties[i].trim(); + + // We can't complete on null.foo, so bail out + if (scope == null) { + return Promise.resolve(new Conversion(typed, arg, Status.ERROR, + l10n.lookup('jstypeParseScope'))); + } + + if (prop === '') { + return Promise.resolve(new Conversion(typed, arg, Status.INCOMPLETE, '')); + } + + // Check if prop is a getter function on 'scope'. Functions can change + // other stuff so we can't execute them to get the next object. Stop here. + if (this._isSafeProperty(scope, prop)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + try { + scope = scope[prop]; + } + catch (ex) { + // It would be nice to be able to report this error in some way but + // as it can happen just when someone types '{sessionStorage.', it + // almost doesn't really count as an error, so we ignore it + return Promise.resolve(new Conversion(typed, arg, Status.VALID, '')); + } + } + } + else { + matchProp = properties[0].trimLeft(); + } + + // If the reason we just stopped adjusting the scope was a non-simple string, + // then we're not sure if the input is valid or invalid, so accept it + if (prop && !prop.match(/^[0-9A-Za-z]*$/)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // However if the prop was a simple string, it is an error + if (scope == null) { + var msg = l10n.lookupFormat('jstypeParseMissing', [ prop ]); + return Promise.resolve(new Conversion(typed, arg, Status.ERROR, msg)); + } + + // If the thing we're looking for isn't a simple string, then we're not going + // to find it, but we're not sure if it's valid or invalid, so accept it + if (!matchProp.match(/^[0-9A-Za-z]*$/)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // Skip Iterators and Generators. + if (this._isIteratorOrGenerator(scope)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + var matchLen = matchProp.length; + var prefix = matchLen === 0 ? typed : typed.slice(0, -matchLen); + var status = Status.INCOMPLETE; + var message = ''; + + // We really want an array of matches (for sorting) but it's easier to + // detect existing members if we're using a map initially + var matches = {}; + + // We only display a maximum of MAX_COMPLETION_MATCHES, so there is no point + // in digging up the prototype chain for matches that we're never going to + // use. Initially look for matches directly on the object itself and then + // look up the chain to find more + var distUpPrototypeChain = 0; + var root = scope; + try { + while (root != null && + Object.keys(matches).length < JavascriptType.MAX_COMPLETION_MATCHES) { + + /* jshint loopfunc:true */ + Object.keys(root).forEach(function(property) { + // Only add matching properties. Also, as we're walking up the + // prototype chain, properties on 'higher' prototypes don't override + // similarly named properties lower down + if (property.indexOf(matchProp) === 0 && !(property in matches)) { + matches[property] = { + prop: property, + distUpPrototypeChain: distUpPrototypeChain + }; + } + }); + + distUpPrototypeChain++; + root = Object.getPrototypeOf(root); + } + } + catch (ex) { + return Promise.resolve(new Conversion(typed, arg, Status.INCOMPLETE, '')); + } + + // Convert to an array for sorting, and while we're at it, note if we got + // an exact match so we know that this input is valid + matches = Object.keys(matches).map(function(property) { + if (property === matchProp) { + status = Status.VALID; + } + return matches[property]; + }); + + // The sort keys are: + // - Being on the object itself, not in the prototype chain + // - The lack of existence of a vendor prefix + // - The name + matches.sort(function(m1, m2) { + if (m1.distUpPrototypeChain !== m2.distUpPrototypeChain) { + return m1.distUpPrototypeChain - m2.distUpPrototypeChain; + } + // Push all vendor prefixes to the bottom of the list + return isVendorPrefixed(m1.prop) ? + (isVendorPrefixed(m2.prop) ? m1.prop.localeCompare(m2.prop) : 1) : + (isVendorPrefixed(m2.prop) ? -1 : m1.prop.localeCompare(m2.prop)); + }); + + // Trim to size. There is a bug for doing a better job of finding matches + // (bug 682694), but in the mean time there is a performance problem + // associated with creating a large number of DOM nodes that few people will + // ever read, so trim ... + if (matches.length > JavascriptType.MAX_COMPLETION_MATCHES) { + matches = matches.slice(0, JavascriptType.MAX_COMPLETION_MATCHES - 1); + } + + // Decorate the matches with: + // - a description + // - a value (for the menu) and, + // - an incomplete flag which reports if we should assume that the user isn't + // going to carry on the JS expression with this input so far + var predictions = matches.map(function(match) { + var description; + var incomplete = true; + + if (this._isSafeProperty(scope, match.prop)) { + description = '(property getter)'; + } + else { + try { + var value = scope[match.prop]; + + if (typeof value === 'function') { + description = '(function)'; + } + else if (typeof value === 'boolean' || typeof value === 'number') { + description = '= ' + value; + incomplete = false; + } + else if (typeof value === 'string') { + if (value.length > 40) { + value = value.substring(0, 37) + 'ā¦'; + } + description = '= \'' + value + '\''; + incomplete = false; + } + else { + description = '(' + typeof value + ')'; + } + } + catch (ex) { + description = '(' + l10n.lookup('jstypeParseError') + ')'; + } + } + + return { + name: prefix + match.prop, + value: { + name: prefix + match.prop, + description: description + }, + description: description, + incomplete: incomplete + }; + }, this); + + if (predictions.length === 0) { + status = Status.ERROR; + message = l10n.lookupFormat('jstypeParseMissing', [ matchProp ]); + } + + // If the match is the only one possible, and its VALID, predict nothing + if (predictions.length === 1 && status === Status.VALID) { + predictions = []; + } + + return Promise.resolve(new Conversion(typed, arg, status, message, + Promise.resolve(predictions))); +}; + +/** + * Does the given property have a prefix that indicates that it is vendor + * specific? + */ +function isVendorPrefixed(name) { + return name.indexOf('moz') === 0 || + name.indexOf('webkit') === 0 || + name.indexOf('ms') === 0; +} + +/** + * Constants used in return value of _findCompletionBeginning() + */ +var ParseState = { + /** + * We have simple input like window.foo, without any punctuation that makes + * completion prediction be confusing or wrong + */ + NORMAL: 0, + + /** + * The cursor is in some Javascript that makes completion hard to predict, + * like console.log( + */ + COMPLEX: 1, + + /** + * The cursor is inside single quotes (') + */ + QUOTE: 2, + + /** + * The cursor is inside single quotes (") + */ + DQUOTE: 3 +}; + +var OPEN_BODY = '{[('.split(''); +var CLOSE_BODY = '}])'.split(''); +var OPEN_CLOSE_BODY = { + '{': '}', + '[': ']', + '(': ')' +}; + +/** + * How we distinguish between simple and complex JS input. We attempt + * completion against simple JS. + */ +var simpleChars = /[a-zA-Z0-9.]/; + +/** + * Analyzes a given string to find the last statement that is interesting for + * later completion. + * @param text A string to analyze + * @return If there was an error in the string detected, then a object like + * { err: 'ErrorMesssage' } + * is returned, otherwise a object like + * { + * state: ParseState.NORMAL|ParseState.QUOTE|ParseState.DQUOTE, + * startPos: index of where the last statement begins + * } + */ +JavascriptType.prototype._findCompletionBeginning = function(text) { + var bodyStack = []; + + var state = ParseState.NORMAL; + var start = 0; + var c; + var complex = false; + + for (var i = 0; i < text.length; i++) { + c = text[i]; + if (!simpleChars.test(c)) { + complex = true; + } + + switch (state) { + // Normal JS state. + case ParseState.NORMAL: + if (c === '"') { + state = ParseState.DQUOTE; + } + else if (c === '\'') { + state = ParseState.QUOTE; + } + else if (c === ';') { + start = i + 1; + } + else if (c === ' ') { + start = i + 1; + } + else if (OPEN_BODY.indexOf(c) != -1) { + bodyStack.push({ + token: c, + start: start + }); + start = i + 1; + } + else if (CLOSE_BODY.indexOf(c) != -1) { + var last = bodyStack.pop(); + if (!last || OPEN_CLOSE_BODY[last.token] != c) { + return { err: l10n.lookup('jstypeBeginSyntax') }; + } + if (c === '}') { + start = i + 1; + } + else { + start = last.start; + } + } + break; + + // Double quote state > " < + case ParseState.DQUOTE: + if (c === '\\') { + i ++; + } + else if (c === '\n') { + return { err: l10n.lookup('jstypeBeginUnterm') }; + } + else if (c === '"') { + state = ParseState.NORMAL; + } + break; + + // Single quote state > ' < + case ParseState.QUOTE: + if (c === '\\') { + i ++; + } + else if (c === '\n') { + return { err: l10n.lookup('jstypeBeginUnterm') }; + } + else if (c === '\'') { + state = ParseState.NORMAL; + } + break; + } + } + + if (state === ParseState.NORMAL && complex) { + state = ParseState.COMPLEX; + } + + return { + state: state, + startPos: start + }; +}; + +/** + * Return true if the passed object is either an iterator or a generator, and + * false otherwise + * @param obj The object to check + */ +JavascriptType.prototype._isIteratorOrGenerator = function(obj) { + if (obj === null) { + return false; + } + + if (typeof aObject === 'object') { + if (typeof obj.__iterator__ === 'function' || + obj.constructor && obj.constructor.name === 'Iterator') { + return true; + } + + try { + var str = obj.toString(); + if (typeof obj.next === 'function' && + str.indexOf('[object Generator') === 0) { + return true; + } + } + catch (ex) { + // window.history.next throws in the typeof check above. + return false; + } + } + + return false; +}; + +/** + * Would calling 'scope[prop]' cause the invocation of a non-native (i.e. user + * defined) function property? + * Since calling functions can have side effects, it's only safe to do that if + * explicitly requested, rather than because we're trying things out for the + * purposes of completion. + */ +JavascriptType.prototype._isSafeProperty = function(scope, prop) { + if (typeof scope !== 'object') { + return false; + } + + // Walk up the prototype chain of 'scope' looking for a property descriptor + // for 'prop' + var propDesc; + while (scope) { + try { + propDesc = Object.getOwnPropertyDescriptor(scope, prop); + if (propDesc) { + break; + } + } + catch (ex) { + // Native getters throw here. See bug 520882. + if (ex.name === 'NS_ERROR_XPC_BAD_CONVERT_JS' || + ex.name === 'NS_ERROR_XPC_BAD_OP_ON_WN_PROTO') { + return false; + } + return true; + } + scope = Object.getPrototypeOf(scope); + } + + if (!propDesc) { + return false; + } + + if (!propDesc.get) { + return false; + } + + // The property is safe if 'get' isn't a function or if the function has a + // prototype (in which case it's native) + return typeof propDesc.get !== 'function' || 'prototype' in propDesc.get; +}; + +JavascriptType.prototype.name = 'javascript'; + +exports.items = [ JavascriptType ]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/moz.build b/devtools/shared/gcli/source/lib/gcli/types/moz.build new file mode 100644 index 000000000..dc3063594 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/moz.build @@ -0,0 +1,25 @@ +# -*- 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( + 'array.js', + 'boolean.js', + 'command.js', + 'date.js', + 'delegate.js', + 'file.js', + 'fileparser.js', + 'javascript.js', + 'node.js', + 'number.js', + 'resource.js', + 'selection.js', + 'setting.js', + 'string.js', + 'types.js', + 'union.js', + 'url.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/types/node.js b/devtools/shared/gcli/source/lib/gcli/types/node.js new file mode 100644 index 000000000..2f71704e3 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/node.js @@ -0,0 +1,201 @@ +/* + * 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 Highlighter = require('../util/host').Highlighter; +var l10n = require('../util/l10n'); +var util = require('../util/util'); +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var BlankArgument = require('./types').BlankArgument; + +/** + * Helper functions to be attached to the prototypes of NodeType and + * NodeListType to allow terminal to tell us which nodes should be highlighted + */ +function onEnter(assignment) { + // TODO: GCLI doesn't support passing a context to notifications of cursor + // position, so onEnter/onLeave/onChange are disabled below until we fix this + assignment.highlighter = new Highlighter(context.environment.window.document); + assignment.highlighter.nodelist = assignment.conversion.matches; +} + +/** @see #onEnter() */ +function onLeave(assignment) { + if (!assignment.highlighter) { + return; + } + + assignment.highlighter.destroy(); + delete assignment.highlighter; +} +/** @see #onEnter() */ +function onChange(assignment) { + if (assignment.conversion.matches == null) { + return; + } + if (!assignment.highlighter) { + return; + } + + assignment.highlighter.nodelist = assignment.conversion.matches; +} + +/** + * The exported 'node' and 'nodelist' types + */ +exports.items = [ + { + // The 'node' type is a CSS expression that refers to a single node + item: 'type', + name: 'node', + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName + }; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return value.__gcliQuery || 'Error'; + }, + + parse: function(arg, context) { + var reply; + + if (arg.text === '') { + reply = new Conversion(undefined, arg, Status.INCOMPLETE); + } + else { + var nodes; + try { + nodes = context.environment.window.document.querySelectorAll(arg.text); + if (nodes.length === 0) { + reply = new Conversion(undefined, arg, Status.INCOMPLETE, + l10n.lookup('nodeParseNone')); + } + else if (nodes.length === 1) { + var node = nodes.item(0); + node.__gcliQuery = arg.text; + + reply = new Conversion(node, arg, Status.VALID, ''); + } + else { + var msg = l10n.lookupFormat('nodeParseMultiple', [ nodes.length ]); + reply = new Conversion(undefined, arg, Status.ERROR, msg); + } + + reply.matches = nodes; + } + catch (ex) { + reply = new Conversion(undefined, arg, Status.ERROR, + l10n.lookup('nodeParseSyntax')); + } + } + + return Promise.resolve(reply); + }, + + // onEnter: onEnter, + // onLeave: onLeave, + // onChange: onChange + }, + { + // The 'nodelist' type is a CSS expression that refers to a node list + item: 'type', + name: 'nodelist', + + // The 'allowEmpty' option ensures that we do not complain if the entered + // CSS selector is valid, but does not match any nodes. There is some + // overlap between this option and 'defaultValue'. What the user wants, in + // most cases, would be to use 'defaultText' (i.e. what is typed rather than + // the value that it represents). However this isn't a concept that exists + // yet and should probably be a part of GCLI if/when it does. + // All NodeListTypes have an automatic defaultValue of an empty NodeList so + // they can easily be used in named parameters. + allowEmpty: false, + + constructor: function() { + if (typeof this.allowEmpty !== 'boolean') { + throw new Error('Legal values for allowEmpty are [true|false]'); + } + }, + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName, + blankIsValid: true + }; + }, + + getBlank: function(context) { + var emptyNodeList = []; + if (context != null && context.environment.window != null) { + var doc = context.environment.window.document; + emptyNodeList = util.createEmptyNodeList(doc); + } + return new Conversion(emptyNodeList, new BlankArgument(), Status.VALID); + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return value.__gcliQuery || 'Error'; + }, + + parse: function(arg, context) { + var reply; + try { + if (arg.text === '') { + reply = new Conversion(undefined, arg, Status.INCOMPLETE); + } + else { + var nodes = context.environment.window.document.querySelectorAll(arg.text); + + if (nodes.length === 0 && !this.allowEmpty) { + reply = new Conversion(undefined, arg, Status.INCOMPLETE, + l10n.lookup('nodeParseNone')); + } + else { + nodes.__gcliQuery = arg.text; + reply = new Conversion(nodes, arg, Status.VALID, ''); + } + + reply.matches = nodes; + } + } + catch (ex) { + reply = new Conversion(undefined, arg, Status.ERROR, + l10n.lookup('nodeParseSyntax')); + } + + return Promise.resolve(reply); + }, + + // onEnter: onEnter, + // onLeave: onLeave, + // onChange: onChange + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/number.js b/devtools/shared/gcli/source/lib/gcli/types/number.js new file mode 100644 index 000000000..4c67e5807 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/number.js @@ -0,0 +1,181 @@ +/* + * 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 Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +exports.items = [ + { + // 'number' type + // Has custom max / min / step values to control increment and decrement + // and a boolean allowFloat property to clamp values to integers + item: 'type', + name: 'number', + + allowFloat: false, + max: undefined, + min: undefined, + step: 1, + + constructor: function() { + if (!this.allowFloat && + (this._isFloat(this.min) || + this._isFloat(this.max) || + this._isFloat(this.step))) { + throw new Error('allowFloat is false, but non-integer values given in type spec'); + } + }, + + getSpec: function() { + var spec = { + name: 'number' + }; + if (this.step !== 1) { + spec.step = this.step; + } + if (this.max != null) { + spec.max = this.max; + } + if (this.min != null) { + spec.min = this.min; + } + if (this.allowFloat) { + spec.allowFloat = true; + } + return (Object.keys(spec).length === 1) ? 'number' : spec; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return '' + value; + }, + + getMin: function(context) { + if (this.min != null) { + if (typeof this.min === 'function') { + return this.min(context); + } + if (typeof this.min === 'number') { + return this.min; + } + } + return undefined; + }, + + getMax: function(context) { + if (this.max != null) { + if (typeof this.max === 'function') { + return this.max(context); + } + if (typeof this.max === 'number') { + return this.max; + } + } + return undefined; + }, + + parse: function(arg, context) { + var msg; + if (arg.text.replace(/^\s*-?/, '').length === 0) { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE, '')); + } + + if (!this.allowFloat && (arg.text.indexOf('.') !== -1)) { + msg = l10n.lookupFormat('typesNumberNotInt2', [ arg.text ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + var value; + if (this.allowFloat) { + value = parseFloat(arg.text); + } + else { + value = parseInt(arg.text, 10); + } + + if (isNaN(value)) { + msg = l10n.lookupFormat('typesNumberNan', [ arg.text ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + var max = this.getMax(context); + if (max != null && value > max) { + msg = l10n.lookupFormat('typesNumberMax', [ value, max ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + var min = this.getMin(context); + if (min != null && value < min) { + msg = l10n.lookupFormat('typesNumberMin', [ value, min ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + return Promise.resolve(new Conversion(value, arg)); + }, + + nudge: function(value, by, context) { + if (typeof value !== 'number' || isNaN(value)) { + if (by < 0) { + return this.getMax(context) || 1; + } + else { + var min = this.getMin(context); + return min != null ? min : 0; + } + } + + var newValue = value + (by * this.step); + + // Snap to the nearest incremental of the step + if (by < 0) { + newValue = Math.ceil(newValue / this.step) * this.step; + } + else { + newValue = Math.floor(newValue / this.step) * this.step; + if (this.getMax(context) == null) { + return newValue; + } + } + return this._boundsCheck(newValue, context); + }, + + // Return the input value so long as it is within the max/min bounds. + // If it is lower than the minimum, return the minimum. If it is bigger + // than the maximum then return the maximum. + _boundsCheck: function(value, context) { + var min = this.getMin(context); + if (min != null && value < min) { + return min; + } + var max = this.getMax(context); + if (max != null && value > max) { + return max; + } + return value; + }, + + // Return true if the given value is a finite number and not an integer, + // else return false. + _isFloat: function(value) { + return ((typeof value === 'number') && isFinite(value) && (value % 1 !== 0)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/resource.js b/devtools/shared/gcli/source/lib/gcli/types/resource.js new file mode 100644 index 000000000..cd1984824 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/resource.js @@ -0,0 +1,270 @@ +/* + * 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'; + +exports.clearResourceCache = function() { + ResourceCache.clear(); +}; + +/** + * Resources are bits of CSS and JavaScript that the page either includes + * directly or as a result of reading some remote resource. + * Resource should not be used directly, but instead through a sub-class like + * CssResource or ScriptResource. + */ +function Resource(name, type, inline, element) { + this.name = name; + this.type = type; + this.inline = inline; + this.element = element; +} + +/** + * Get the contents of the given resource as a string. + * The base Resource leaves this unimplemented. + */ +Resource.prototype.loadContents = function() { + throw new Error('not implemented'); +}; + +Resource.TYPE_SCRIPT = 'text/javascript'; +Resource.TYPE_CSS = 'text/css'; + +/** + * A CssResource provides an implementation of Resource that works for both + * [style] elements and [link type='text/css'] elements in the [head]. + */ +function CssResource(domSheet) { + this.name = domSheet.href; + if (!this.name) { + this.name = domSheet.ownerNode && domSheet.ownerNode.id ? + 'css#' + domSheet.ownerNode.id : + 'inline-css'; + } + + this.inline = (domSheet.href == null); + this.type = Resource.TYPE_CSS; + this.element = domSheet; +} + +CssResource.prototype = Object.create(Resource.prototype); + +CssResource.prototype.loadContents = function() { + return new Promise(function(resolve, reject) { + resolve(this.element.ownerNode.innerHTML); + }.bind(this)); +}; + +CssResource._getAllStyles = function(context) { + var resources = []; + if (context.environment.window == null) { + return resources; + } + + var doc = context.environment.window.document; + Array.prototype.forEach.call(doc.styleSheets, function(domSheet) { + CssResource._getStyle(domSheet, resources); + }); + + dedupe(resources, function(clones) { + for (var i = 0; i < clones.length; i++) { + clones[i].name = clones[i].name + '-' + i; + } + }); + + return resources; +}; + +CssResource._getStyle = function(domSheet, resources) { + var resource = ResourceCache.get(domSheet); + if (!resource) { + resource = new CssResource(domSheet); + ResourceCache.add(domSheet, resource); + } + resources.push(resource); + + // Look for imported stylesheets + try { + Array.prototype.forEach.call(domSheet.cssRules, function(domRule) { + if (domRule.type == CSSRule.IMPORT_RULE && domRule.styleSheet) { + CssResource._getStyle(domRule.styleSheet, resources); + } + }, this); + } + catch (ex) { + // For system stylesheets + } +}; + +/** + * A ScriptResource provides an implementation of Resource that works for + * [script] elements (both with a src attribute, and used directly). + */ +function ScriptResource(scriptNode) { + this.name = scriptNode.src; + if (!this.name) { + this.name = scriptNode.id ? + 'script#' + scriptNode.id : + 'inline-script'; + } + + this.inline = (scriptNode.src === '' || scriptNode.src == null); + this.type = Resource.TYPE_SCRIPT; + this.element = scriptNode; +} + +ScriptResource.prototype = Object.create(Resource.prototype); + +ScriptResource.prototype.loadContents = function() { + return new Promise(function(resolve, reject) { + if (this.inline) { + resolve(this.element.innerHTML); + } + else { + // It would be good if there was a better way to get the script source + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState !== xhr.DONE) { + return; + } + resolve(xhr.responseText); + }; + xhr.open('GET', this.element.src, true); + xhr.send(); + } + }.bind(this)); +}; + +ScriptResource._getAllScripts = function(context) { + if (context.environment.window == null) { + return []; + } + + var doc = context.environment.window.document; + var scriptNodes = doc.querySelectorAll('script'); + var resources = Array.prototype.map.call(scriptNodes, function(scriptNode) { + var resource = ResourceCache.get(scriptNode); + if (!resource) { + resource = new ScriptResource(scriptNode); + ResourceCache.add(scriptNode, resource); + } + return resource; + }); + + dedupe(resources, function(clones) { + for (var i = 0; i < clones.length; i++) { + clones[i].name = clones[i].name + '-' + i; + } + }); + + return resources; +}; + +/** + * Find resources with the same name, and call onDupe to change the names + */ +function dedupe(resources, onDupe) { + // first create a map of name->[array of resources with same name] + var names = {}; + resources.forEach(function(scriptResource) { + if (names[scriptResource.name] == null) { + names[scriptResource.name] = []; + } + names[scriptResource.name].push(scriptResource); + }); + + // Call the de-dupe function for each set of dupes + Object.keys(names).forEach(function(name) { + var clones = names[name]; + if (clones.length > 1) { + onDupe(clones); + } + }); +} + +/** + * A quick cache of resources against nodes + * TODO: Potential memory leak when the target document has css or script + * resources repeatedly added and removed. Solution might be to use a weak + * hash map or some such. + */ +var ResourceCache = { + _cached: [], + + /** + * Do we already have a resource that was created for the given node + */ + get: function(node) { + for (var i = 0; i < ResourceCache._cached.length; i++) { + if (ResourceCache._cached[i].node === node) { + return ResourceCache._cached[i].resource; + } + } + return null; + }, + + /** + * Add a resource for a given node + */ + add: function(node, resource) { + ResourceCache._cached.push({ node: node, resource: resource }); + }, + + /** + * Drop all cache entries. Helpful to prevent memory leaks + */ + clear: function() { + ResourceCache._cached = []; + } +}; + +/** + * The resource type itself + */ +exports.items = [ + { + item: 'type', + name: 'resource', + parent: 'selection', + cacheable: false, + include: null, + + constructor: function() { + if (this.include !== Resource.TYPE_SCRIPT && + this.include !== Resource.TYPE_CSS && + this.include != null) { + throw new Error('invalid include property: ' + this.include); + } + }, + + lookup: function(context) { + var resources = []; + if (this.include !== Resource.TYPE_SCRIPT) { + Array.prototype.push.apply(resources, + CssResource._getAllStyles(context)); + } + if (this.include !== Resource.TYPE_CSS) { + Array.prototype.push.apply(resources, + ScriptResource._getAllScripts(context)); + } + + return Promise.resolve(resources.map(function(resource) { + return { name: resource.name, value: resource }; + })); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/selection.js b/devtools/shared/gcli/source/lib/gcli/types/selection.js new file mode 100644 index 000000000..0e64c8fa2 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/selection.js @@ -0,0 +1,389 @@ +/* + * 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 spell = require('../util/spell'); +var Type = require('./types').Type; +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var BlankArgument = require('./types').BlankArgument; + +/** + * A selection allows the user to pick a value from known set of options. + * An option is made up of a name (which is what the user types) and a value + * (which is passed to exec) + * @param typeSpec Object containing properties that describe how this + * selection functions. Properties include: + * - lookup: An array of objects, one for each option, which contain name and + * value properties. lookup can be a function which returns this array + * - data: An array of strings - alternative to 'lookup' where the valid values + * are strings. i.e. there is no mapping between what is typed and the value + * that is used by the program + * - stringifyProperty: Conversion from value to string is generally a process + * of looking through all the valid options for a matching value, and using + * the associated name. However the name maybe available directly from the + * value using a property lookup. Setting 'stringifyProperty' allows + * SelectionType to take this shortcut. + * - cacheable: If lookup is a function, then we normally assume that + * the values fetched can change. Setting 'cacheable:true' enables internal + * caching. + */ +function SelectionType(typeSpec) { + if (typeSpec) { + Object.keys(typeSpec).forEach(function(key) { + this[key] = typeSpec[key]; + }, this); + } + + if (this.name !== 'selection' && + this.lookup == null && this.data == null) { + throw new Error(this.name + ' has no lookup or data'); + } + + this._dataToLookup = this._dataToLookup.bind(this); +} + +SelectionType.prototype = Object.create(Type.prototype); + +SelectionType.prototype.getSpec = function(commandName, paramName) { + var spec = { name: 'selection' }; + if (this.lookup != null && typeof this.lookup !== 'function') { + spec.lookup = this.lookup; + } + if (this.data != null && typeof this.data !== 'function') { + spec.data = this.data; + } + if (this.stringifyProperty != null) { + spec.stringifyProperty = this.stringifyProperty; + } + if (this.cacheable) { + spec.cacheable = true; + } + if (typeof this.lookup === 'function' || typeof this.data === 'function') { + spec.commandName = commandName; + spec.paramName = paramName; + spec.remoteLookup = true; + } + return spec; +}; + +SelectionType.prototype.stringify = function(value, context) { + if (value == null) { + return ''; + } + if (this.stringifyProperty != null) { + return value[this.stringifyProperty]; + } + + return this.getLookup(context).then(function(lookup) { + var name = null; + lookup.some(function(item) { + if (item.value === value) { + name = item.name; + return true; + } + return false; + }, this); + return name; + }.bind(this)); +}; + +/** + * If typeSpec contained cacheable:true then calls to parse() work on cached + * data. clearCache() enables the cache to be cleared. + */ +SelectionType.prototype.clearCache = function() { + this._cachedLookup = undefined; +}; + +/** + * There are several ways to get selection data. This unifies them into one + * single function. + * @return An array of objects with name and value properties. + */ +SelectionType.prototype.getLookup = function(context) { + if (this._cachedLookup != null) { + return this._cachedLookup; + } + + var reply; + + if (this.remoteLookup) { + reply = this.front.getSelectionLookup(this.commandName, this.paramName); + reply = resolve(reply, context); + } + else if (typeof this.lookup === 'function') { + reply = resolve(this.lookup.bind(this), context); + } + else if (this.lookup != null) { + reply = resolve(this.lookup, context); + } + else if (this.data != null) { + reply = resolve(this.data, context).then(this._dataToLookup); + } + else { + throw new Error(this.name + ' has no lookup or data'); + } + + if (this.cacheable) { + this._cachedLookup = reply; + } + + if (reply == null) { + console.error(arguments); + } + return reply; +}; + +/** + * Both 'lookup' and 'data' properties (see docs on SelectionType constructor) + * in addition to being real data can be a function or a promise, or even a + * function which returns a promise of real data, etc. This takes a thing and + * returns a promise of actual values. + */ +function resolve(thing, context) { + return Promise.resolve(thing).then(function(resolved) { + if (typeof resolved === 'function') { + return resolve(resolved(context), context); + } + return resolved; + }); +} + +/** + * Selection can be provided with either a lookup object (in the 'lookup' + * property) or an array of strings (in the 'data' property). Internally we + * always use lookup, so we need a way to convert a 'data' array to a lookup. + */ +SelectionType.prototype._dataToLookup = function(data) { + if (!Array.isArray(data)) { + throw new Error('data for ' + this.name + ' resolved to non-array'); + } + + return data.map(function(option) { + return { name: option, value: option }; + }); +}; + +/** + * Return a list of possible completions for the given arg. + * @param arg The initial input to match + * @return A trimmed array of string:value pairs + */ +exports.findPredictions = function(arg, lookup) { + var predictions = []; + var i, option; + var maxPredictions = Conversion.maxPredictions; + var match = arg.text.toLowerCase(); + + // If the arg has a suffix then we're kind of 'done'. Only an exact match + // will do. + if (arg.suffix.length > 0) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text) { + predictions.push(option); + } + } + + return predictions; + } + + // Cache lower case versions of all the option names + for (i = 0; i < lookup.length; i++) { + option = lookup[i]; + if (option._gcliLowerName == null) { + option._gcliLowerName = option.name.toLowerCase(); + } + } + + // Exact hidden matches. If 'hidden: true' then we only allow exact matches + // All the tests after here check that !isHidden(option) + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text) { + predictions.push(option); + } + } + + // Start with prefix matching + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) === 0 && !isHidden(option)) { + if (predictions.indexOf(option) === -1) { + predictions.push(option); + } + } + } + + // Try infix matching if we get less half max matched + if (predictions.length < (maxPredictions / 2)) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) !== -1 && !isHidden(option)) { + if (predictions.indexOf(option) === -1) { + predictions.push(option); + } + } + } + } + + // Try fuzzy matching if we don't get a prefix match + if (predictions.length === 0) { + var names = []; + lookup.forEach(function(opt) { + if (!isHidden(opt)) { + names.push(opt.name); + } + }); + var corrected = spell.correct(match, names); + if (corrected) { + lookup.forEach(function(opt) { + if (opt.name === corrected) { + predictions.push(opt); + } + }, this); + } + } + + return predictions; +}; + +SelectionType.prototype.parse = function(arg, context) { + return Promise.resolve(this.getLookup(context)).then(function(lookup) { + var predictions = exports.findPredictions(arg, lookup); + return exports.convertPredictions(arg, predictions); + }.bind(this)); +}; + +/** + * Decide what sort of conversion to return based on the available predictions + * and how they match the passed arg + */ +exports.convertPredictions = function(arg, predictions) { + if (predictions.length === 0) { + var msg = l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]); + return new Conversion(undefined, arg, Status.ERROR, msg, + Promise.resolve(predictions)); + } + + if (predictions[0].name === arg.text) { + var value = predictions[0].value; + return new Conversion(value, arg, Status.VALID, '', + Promise.resolve(predictions)); + } + + return new Conversion(undefined, arg, Status.INCOMPLETE, '', + Promise.resolve(predictions)); +}; + +/** + * Checking that an option is hidden involves messing in properties on the + * value right now (which isn't a good idea really) we really should be marking + * that on the option, so this encapsulates the problem + */ +function isHidden(option) { + return option.hidden === true || + (option.value != null && option.value.hidden); +} + +SelectionType.prototype.getBlank = function(context) { + var predictFunc = function(context2) { + return Promise.resolve(this.getLookup(context2)).then(function(lookup) { + return lookup.filter(function(option) { + return !isHidden(option); + }).slice(0, Conversion.maxPredictions - 1); + }); + }.bind(this); + + return new Conversion(undefined, new BlankArgument(), Status.INCOMPLETE, '', + predictFunc); +}; + +/** + * Increment and decrement are confusing for selections. +1 is -1 and -1 is +1. + * Given an array e.g. [ 'a', 'b', 'c' ] with the current selection on 'b', + * displayed to the user in the natural way, i.e.: + * + * 'a' + * 'b' <- highlighted as current value + * 'c' + * + * Pressing the UP arrow should take us to 'a', which decrements this index + * (compare pressing UP on a number which would increment the number) + * + * So for selections, we treat +1 as -1 and -1 as +1. + */ +SelectionType.prototype.nudge = function(value, by, context) { + return this.getLookup(context).then(function(lookup) { + var index = this._findValue(lookup, value); + if (index === -1) { + if (by < 0) { + // We're supposed to be doing a decrement (which means +1), but the + // value isn't found, so we reset the index to the top of the list + // which is index 0 + index = 0; + } + else { + // For an increment operation when there is nothing to start from, we + // want to start from the top, i.e. index 0, so the value before we + // 'increment' (see note above) must be 1. + index = 1; + } + } + + // This is where we invert the sense of up/down (see doc comment) + index -= by; + + if (index >= lookup.length) { + index = 0; + } + return lookup[index].value; + }.bind(this)); +}; + +/** + * Walk through an array of { name:.., value:... } objects looking for a + * matching value (using strict equality), returning the matched index (or -1 + * if not found). + * @param lookup Array of objects with name/value properties to search through + * @param value The value to search for + * @return The index at which the match was found, or -1 if no match was found + */ +SelectionType.prototype._findValue = function(lookup, value) { + var index = -1; + for (var i = 0; i < lookup.length; i++) { + var pair = lookup[i]; + if (pair.value === value) { + index = i; + break; + } + } + return index; +}; + +/** + * This is how we indicate to SelectionField that we have predictions that + * might work in a menu. + */ +SelectionType.prototype.hasPredictions = true; + +SelectionType.prototype.name = 'selection'; + +exports.SelectionType = SelectionType; +exports.items = [ SelectionType ]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/setting.js b/devtools/shared/gcli/source/lib/gcli/types/setting.js new file mode 100644 index 000000000..26c6f4063 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/setting.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'; + +exports.items = [ + { + // A type for selecting a known setting + item: 'type', + name: 'setting', + parent: 'selection', + cacheable: true, + lookup: function(context) { + var settings = context.system.settings; + + // Lazily add a settings.onChange listener to clear the cache + if (!this._registeredListener) { + settings.onChange.add(function(ev) { + this.clearCache(); + }, this); + this._registeredListener = true; + } + + return settings.getAll().map(function(setting) { + return { name: setting.name, value: setting }; + }); + } + }, + { + // A type for entering the value of a known setting + // Customizations: + // - settingParamName The name of the setting parameter so we can customize + // the type that we are expecting to read + item: 'type', + name: 'settingValue', + parent: 'delegate', + settingParamName: 'setting', + delegateType: function(context) { + if (context != null) { + var setting = context.getArgsObject()[this.settingParamName]; + if (setting != null) { + return setting.type; + } + } + + return 'blank'; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/string.js b/devtools/shared/gcli/source/lib/gcli/types/string.js new file mode 100644 index 000000000..a3aebacad --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/string.js @@ -0,0 +1,92 @@ +/* + * 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 Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +exports.items = [ + { + // 'string' the most basic string type where all we need to do is to take + // care of converting escaped characters like \t, \n, etc. + // For the full list see + // https://developer.mozilla.org/en-US/docs/JavaScript/Guide/Values,_variables,_and_literals + // The exception is that we ignore \b because replacing '\b' characters in + // stringify() with their escaped version injects '\\b' all over the place + // and the need to support \b seems low) + // Customizations: + // allowBlank: Allow a blank string to be counted as valid + item: 'type', + name: 'string', + allowBlank: false, + + getSpec: function() { + return this.allowBlank ? + { name: 'string', allowBlank: true } : + 'string'; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + + return value + .replace(/\\/g, '\\\\') + .replace(/\f/g, '\\f') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(/\v/g, '\\v') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/ /g, '\\ ') + .replace(/'/g, '\\\'') + .replace(/"/g, '\\"') + .replace(/{/g, '\\{') + .replace(/}/g, '\\}'); + }, + + parse: function(arg, context) { + if (!this.allowBlank && (arg.text == null || arg.text === '')) { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE, '')); + } + + // The string '\\' (i.e. an escaped \ (represented here as '\\\\' because it + // is double escaped)) is first converted to a private unicode character and + // then at the end from \uF000 to a single '\' to avoid the string \\n being + // converted first to \n and then to a <LF> + var value = arg.text + .replace(/\\\\/g, '\uF000') + .replace(/\\f/g, '\f') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\v/g, '\v') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\ /g, ' ') + .replace(/\\'/g, '\'') + .replace(/\\"/g, '"') + .replace(/\\{/g, '{') + .replace(/\\}/g, '}') + .replace(/\uF000/g, '\\'); + + return Promise.resolve(new Conversion(value, arg)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/types.js b/devtools/shared/gcli/source/lib/gcli/types/types.js new file mode 100644 index 000000000..ed5a93d54 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/types.js @@ -0,0 +1,1146 @@ +/* + * 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'); + +/** + * We record where in the input string an argument comes so we can report + * errors against those string positions. + * @param text The string (trimmed) that contains the argument + * @param prefix Knowledge of quotation marks and whitespace used prior to the + * text in the input string allows us to re-generate the original input from + * the arguments. + * @param suffix Any quotation marks and whitespace used after the text. + * Whitespace is normally placed in the prefix to the succeeding argument, but + * can be used here when this is the last argument. + * @constructor + */ +function Argument(text, prefix, suffix) { + if (text === undefined) { + this.text = ''; + this.prefix = ''; + this.suffix = ''; + } + else { + this.text = text; + this.prefix = prefix !== undefined ? prefix : ''; + this.suffix = suffix !== undefined ? suffix : ''; + } +} + +Argument.prototype.type = 'Argument'; + +/** + * Return the result of merging these arguments. + * case and some of the arguments are in quotation marks? + */ +Argument.prototype.merge = function(following) { + // Is it possible that this gets called when we're merging arguments + // for the single string? + return new Argument( + this.text + this.suffix + following.prefix + following.text, + this.prefix, following.suffix); +}; + +/** + * Returns a new Argument like this one but with various items changed. + * @param options Values to use in creating a new Argument. + * Warning: some implementations of beget make additions to the options + * argument. You should be aware of this in the unlikely event that you want to + * reuse 'options' arguments. + * Properties: + * - text: The new text value + * - prefixSpace: Should the prefix be altered to begin with a space? + * - prefixPostSpace: Should the prefix be altered to end with a space? + * - suffixSpace: Should the suffix be altered to end with a space? + * - type: Constructor to use in creating new instances. Default: Argument + * - dontQuote: Should we avoid adding prefix/suffix quotes when the text value + * has a space? Needed when we're completing a sub-command. + */ +Argument.prototype.beget = function(options) { + var text = this.text; + var prefix = this.prefix; + var suffix = this.suffix; + + if (options.text != null) { + text = options.text; + + // We need to add quotes when the replacement string has spaces or is empty + if (!options.dontQuote) { + var needsQuote = text.indexOf(' ') >= 0 || text.length === 0; + var hasQuote = /['"]$/.test(prefix); + if (needsQuote && !hasQuote) { + prefix = prefix + '\''; + suffix = '\'' + suffix; + } + } + } + + if (options.prefixSpace && prefix.charAt(0) !== ' ') { + prefix = ' ' + prefix; + } + + if (options.prefixPostSpace && prefix.charAt(prefix.length - 1) !== ' ') { + prefix = prefix + ' '; + } + + if (options.suffixSpace && suffix.charAt(suffix.length - 1) !== ' ') { + suffix = suffix + ' '; + } + + if (text === this.text && suffix === this.suffix && prefix === this.prefix) { + return this; + } + + var ArgumentType = options.type || Argument; + return new ArgumentType(text, prefix, suffix); +}; + +/** + * We need to keep track of which assignment we've been assigned to + */ +Object.defineProperty(Argument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { this._assignment = assignment; }, + enumerable: true +}); + +/** + * Sub-classes of Argument are collections of arguments, getArgs() gets access + * to the members of the collection in order to do things like re-create input + * command lines. For the simple Argument case it's just an array containing + * only this. + */ +Argument.prototype.getArgs = function() { + return [ this ]; +}; + +/** + * We define equals to mean all arg properties are strict equals. + * Used by Conversion.argEquals and Conversion.equals and ultimately + * Assignment.equals to avoid reporting a change event when a new conversion + * is assigned. + */ +Argument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof Argument)) { + return false; + } + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +/** + * Helper when we're putting arguments back together + */ +Argument.prototype.toString = function() { + // BUG 664207: We should re-escape escaped characters + // But can we do that reliably? + return this.prefix + this.text + this.suffix; +}; + +/** + * Merge an array of arguments into a single argument. + * All Arguments in the array are expected to have the same emitter + */ +Argument.merge = function(argArray, start, end) { + start = (start === undefined) ? 0 : start; + end = (end === undefined) ? argArray.length : end; + + var joined; + for (var i = start; i < end; i++) { + var arg = argArray[i]; + if (!joined) { + joined = arg; + } + else { + joined = joined.merge(arg); + } + } + return joined; +}; + +/** + * 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(Argument.prototype, '_summaryJson', { + get: function() { + var assignStatus = this.assignment == null ? + 'null' : + this.assignment.param.name; + return '<' + this.prefix + ':' + this.text + ':' + this.suffix + '>' + + ' (a=' + assignStatus + ',' + ' t=' + this.type + ')'; + }, + enumerable: true +}); + +exports.Argument = Argument; + + +/** + * BlankArgument is a marker that the argument wasn't typed but is there to + * fill a slot. Assignments begin with their arg set to a BlankArgument. + */ +function BlankArgument() { + this.text = ''; + this.prefix = ''; + this.suffix = ''; +} + +BlankArgument.prototype = Object.create(Argument.prototype); + +BlankArgument.prototype.type = 'BlankArgument'; + +exports.BlankArgument = BlankArgument; + + +/** + * ScriptArgument is a marker that the argument is designed to be JavaScript. + * It also implements the special rules that spaces after the { or before the + * } are part of the pre/suffix rather than the content, and that they are + * never 'blank' so they can be used by Requisition._split() and not raise an + * ERROR status due to being blank. + */ +function ScriptArgument(text, prefix, suffix) { + this.text = text !== undefined ? text : ''; + this.prefix = prefix !== undefined ? prefix : ''; + this.suffix = suffix !== undefined ? suffix : ''; + + ScriptArgument._moveSpaces(this); +} + +ScriptArgument.prototype = Object.create(Argument.prototype); + +ScriptArgument.prototype.type = 'ScriptArgument'; + +/** + * Private/Dangerous: Alters a ScriptArgument to move the spaces at the start + * or end of the 'text' into the prefix/suffix. With a string, " a " is 3 chars + * long, but with a ScriptArgument, { a } is only one char long. + * Arguments are generally supposed to be immutable, so this method should only + * be called on a ScriptArgument that isn't exposed to the outside world yet. + */ +ScriptArgument._moveSpaces = function(arg) { + while (arg.text.charAt(0) === ' ') { + arg.prefix = arg.prefix + ' '; + arg.text = arg.text.substring(1); + } + + while (arg.text.charAt(arg.text.length - 1) === ' ') { + arg.suffix = ' ' + arg.suffix; + arg.text = arg.text.slice(0, -1); + } +}; + +/** + * As Argument.beget that implements the space rule documented in the ctor. + */ +ScriptArgument.prototype.beget = function(options) { + options.type = ScriptArgument; + var begotten = Argument.prototype.beget.call(this, options); + ScriptArgument._moveSpaces(begotten); + return begotten; +}; + +exports.ScriptArgument = ScriptArgument; + + +/** + * Commands like 'echo' with a single string argument, and used with the + * special format like: 'echo a b c' effectively have a number of arguments + * merged together. + */ +function MergedArgument(args, start, end) { + if (!Array.isArray(args)) { + throw new Error('args is not an array of Arguments'); + } + + if (start === undefined) { + this.args = args; + } + else { + this.args = args.slice(start, end); + } + + var arg = Argument.merge(this.args); + this.text = arg.text; + this.prefix = arg.prefix; + this.suffix = arg.suffix; +} + +MergedArgument.prototype = Object.create(Argument.prototype); + +MergedArgument.prototype.type = 'MergedArgument'; + +/** + * Keep track of which assignment we've been assigned to, and allow the + * original args to do the same. + */ +Object.defineProperty(MergedArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.args.forEach(function(arg) { + arg.assignment = assignment; + }, this); + }, + enumerable: true +}); + +MergedArgument.prototype.getArgs = function() { + return this.args; +}; + +MergedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof MergedArgument)) { + return false; + } + + // We might need to add a check that args is the same here + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +exports.MergedArgument = MergedArgument; + + +/** + * TrueNamedArguments are for when we have an argument like --verbose which + * has a boolean value, and thus the opposite of '--verbose' is ''. + */ +function TrueNamedArgument(arg) { + this.arg = arg; + this.text = arg.text; + this.prefix = arg.prefix; + this.suffix = arg.suffix; +} + +TrueNamedArgument.prototype = Object.create(Argument.prototype); + +TrueNamedArgument.prototype.type = 'TrueNamedArgument'; + +Object.defineProperty(TrueNamedArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + if (this.arg) { + this.arg.assignment = assignment; + } + }, + enumerable: true +}); + +TrueNamedArgument.prototype.getArgs = function() { + return [ this.arg ]; +}; + +TrueNamedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof TrueNamedArgument)) { + return false; + } + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +/** + * As Argument.beget that rebuilds nameArg and valueArg + */ +TrueNamedArgument.prototype.beget = function(options) { + if (options.text) { + console.error('Can\'t change text of a TrueNamedArgument', this, options); + } + + options.type = TrueNamedArgument; + var begotten = Argument.prototype.beget.call(this, options); + begotten.arg = new Argument(begotten.text, begotten.prefix, begotten.suffix); + return begotten; +}; + +exports.TrueNamedArgument = TrueNamedArgument; + + +/** + * FalseNamedArguments are for when we don't have an argument like --verbose + * which has a boolean value, and thus the opposite of '' is '--verbose'. + */ +function FalseNamedArgument() { + this.text = ''; + this.prefix = ''; + this.suffix = ''; +} + +FalseNamedArgument.prototype = Object.create(Argument.prototype); + +FalseNamedArgument.prototype.type = 'FalseNamedArgument'; + +FalseNamedArgument.prototype.getArgs = function() { + return [ ]; +}; + +FalseNamedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof FalseNamedArgument)) { + return false; + } + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +exports.FalseNamedArgument = FalseNamedArgument; + + +/** + * A named argument is for cases where we have input in one of the following + * formats: + * <ul> + * <li>--param value + * <li>-p value + * </ul> + * We model this as a normal argument but with a long prefix. + * + * There are 2 ways to construct a NamedArgument. One using 2 Arguments which + * are taken to be the argument for the name (e.g. '--param') and one for the + * value to assign to that parameter. + * Alternatively, you can pass in the text/prefix/suffix values in the same + * way as an Argument is constructed. If you do this then you are expected to + * assign to nameArg and valueArg before exposing the new NamedArgument. + */ +function NamedArgument() { + if (typeof arguments[0] === 'string') { + this.nameArg = null; + this.valueArg = null; + this.text = arguments[0]; + this.prefix = arguments[1]; + this.suffix = arguments[2]; + } + else if (arguments[1] == null) { + this.nameArg = arguments[0]; + this.valueArg = null; + this.text = ''; + this.prefix = this.nameArg.toString(); + this.suffix = ''; + } + else { + this.nameArg = arguments[0]; + this.valueArg = arguments[1]; + this.text = this.valueArg.text; + this.prefix = this.nameArg.toString() + this.valueArg.prefix; + this.suffix = this.valueArg.suffix; + } +} + +NamedArgument.prototype = Object.create(Argument.prototype); + +NamedArgument.prototype.type = 'NamedArgument'; + +Object.defineProperty(NamedArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.nameArg.assignment = assignment; + if (this.valueArg != null) { + this.valueArg.assignment = assignment; + } + }, + enumerable: true +}); + +NamedArgument.prototype.getArgs = function() { + return this.valueArg ? [ this.nameArg, this.valueArg ] : [ this.nameArg ]; +}; + +NamedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null) { + return false; + } + + if (!(that instanceof NamedArgument)) { + return false; + } + + // We might need to add a check that nameArg and valueArg are the same + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +/** + * As Argument.beget that rebuilds nameArg and valueArg + */ +NamedArgument.prototype.beget = function(options) { + options.type = NamedArgument; + var begotten = Argument.prototype.beget.call(this, options); + + // Cut the prefix into |whitespace|non-whitespace|whitespace+quote so we can + // rebuild nameArg and valueArg from the parts + var matches = /^([\s]*)([^\s]*)([\s]*['"]?)$/.exec(begotten.prefix); + + if (this.valueArg == null && begotten.text === '') { + begotten.nameArg = new Argument(matches[2], matches[1], matches[3]); + begotten.valueArg = null; + } + else { + begotten.nameArg = new Argument(matches[2], matches[1], ''); + begotten.valueArg = new Argument(begotten.text, matches[3], begotten.suffix); + } + + return begotten; +}; + +exports.NamedArgument = NamedArgument; + + +/** + * An argument the groups together a number of plain arguments together so they + * can be jointly assigned to a single array parameter + */ +function ArrayArgument() { + this.args = []; +} + +ArrayArgument.prototype = Object.create(Argument.prototype); + +ArrayArgument.prototype.type = 'ArrayArgument'; + +ArrayArgument.prototype.addArgument = function(arg) { + this.args.push(arg); +}; + +ArrayArgument.prototype.addArguments = function(args) { + Array.prototype.push.apply(this.args, args); +}; + +ArrayArgument.prototype.getArguments = function() { + return this.args; +}; + +Object.defineProperty(ArrayArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.args.forEach(function(arg) { + arg.assignment = assignment; + }, this); + }, + enumerable: true +}); + +ArrayArgument.prototype.getArgs = function() { + return this.args; +}; + +ArrayArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null) { + return false; + } + + if (that.type !== 'ArrayArgument') { + return false; + } + + if (this.args.length !== that.args.length) { + return false; + } + + for (var i = 0; i < this.args.length; i++) { + if (!this.args[i].equals(that.args[i])) { + return false; + } + } + + return true; +}; + +/** + * Helper when we're putting arguments back together + */ +ArrayArgument.prototype.toString = function() { + return '{' + this.args.map(function(arg) { + return arg.toString(); + }, this).join(',') + '}'; +}; + +exports.ArrayArgument = ArrayArgument; + +/** + * Some types can detect validity, that is to say they can distinguish between + * valid and invalid values. + * We might want to change these constants to be numbers for better performance + */ +var Status = { + /** + * The conversion process worked without any problem, and the value is + * valid. There are a number of failure states, so the best way to check + * for failure is (x !== Status.VALID) + */ + VALID: { + toString: function() { return 'VALID'; }, + valueOf: function() { return 0; } + }, + + /** + * A conversion process failed, however it was noted that the string + * provided to 'parse()' could be VALID by the addition of more characters, + * so the typing may not be actually incorrect yet, just unfinished. + * @see Status.ERROR + */ + INCOMPLETE: { + toString: function() { return 'INCOMPLETE'; }, + valueOf: function() { return 1; } + }, + + /** + * The conversion process did not work, the value should be null and a + * reason for failure should have been provided. In addition some + * completion values may be available. + * @see Status.INCOMPLETE + */ + ERROR: { + toString: function() { return 'ERROR'; }, + valueOf: function() { return 2; } + }, + + /** + * A combined status is the worser of the provided statuses. The statuses + * can be provided either as a set of arguments or a single array + */ + combine: function() { + var combined = Status.VALID; + for (var i = 0; i < arguments.length; i++) { + var status = arguments[i]; + if (Array.isArray(status)) { + status = Status.combine.apply(null, status); + } + if (status > combined) { + combined = status; + } + } + return combined; + }, + + fromString: function(str) { + switch (str) { + case Status.VALID.toString(): + return Status.VALID; + case Status.INCOMPLETE.toString(): + return Status.INCOMPLETE; + case Status.ERROR.toString(): + return Status.ERROR; + default: + throw new Error('\'' + str + '\' is not a status'); + } + } +}; + +exports.Status = Status; + + +/** + * The type.parse() method converts an Argument into a value, Conversion is + * a wrapper to that value. + * Conversion is needed to collect a number of properties related to that + * conversion in one place, i.e. to handle errors and provide traceability. + * @param value The result of the conversion. null if status == VALID + * @param arg The data from which the conversion was made + * @param status See the Status values [VALID|INCOMPLETE|ERROR] defined above. + * The default status is Status.VALID. + * @param message If status=ERROR, there should be a message to describe the + * error. A message is not needed unless for other statuses, but could be + * present for any status including VALID (in the case where we want to note a + * warning, for example). + * See BUG 664676: GCLI conversion error messages should be localized + * @param predictions If status=INCOMPLETE, there could be predictions as to + * the options available to complete the input. + * We generally expect there to be about 7 predictions (to match human list + * comprehension ability) however it is valid to provide up to about 20, + * or less. It is the job of the predictor to decide a smart cut-off. + * For example if there are 4 very good matches and 4 very poor ones, + * probably only the 4 very good matches should be presented. + * The predictions are presented either as an array of prediction objects or as + * a function which returns this array when called with no parameters. + * Each prediction object has the following shape: + * { + * name: '...', // textual completion. i.e. what the cli uses + * value: { ... }, // value behind the textual completion + * incomplete: true // this completion is only partial (optional) + * } + * The 'incomplete' property could be used to denote a valid completion which + * could have sub-values (e.g. for tree navigation). + */ +function Conversion(value, arg, status, message, predictions) { + if (arg == null) { + throw new Error('Missing arg'); + } + + if (predictions != null && typeof predictions !== 'function' && + !Array.isArray(predictions) && typeof predictions.then !== 'function') { + throw new Error('predictions exists but is not a promise, function or array'); + } + + if (status === Status.ERROR && !message) { + throw new Error('Conversion has status=ERROR but no message'); + } + + this.value = value; + this.arg = arg; + this._status = status || Status.VALID; + this.message = message; + this.predictions = predictions; +} + +/** + * Ensure that all arguments that are part of this conversion know what they + * are assigned to. + * @param assignment The Assignment (param/conversion link) to inform the + * argument about. + */ +Object.defineProperty(Conversion.prototype, 'assignment', { + get: function() { return this.arg.assignment; }, + set: function(assignment) { this.arg.assignment = assignment; }, + enumerable: true +}); + +/** + * Work out if there is information provided in the contained argument. + */ +Conversion.prototype.isDataProvided = function() { + return this.arg.type !== 'BlankArgument'; +}; + +/** + * 2 conversions are equal if and only if their args are equal (argEquals) and + * their values are equal (valueEquals). + * @param that The conversion object to compare against. + */ +Conversion.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null) { + return false; + } + return this.valueEquals(that) && this.argEquals(that); +}; + +/** + * Check that the value in this conversion is strict equal to the value in the + * provided conversion. + * @param that The conversion to compare values with + */ +Conversion.prototype.valueEquals = function(that) { + return that != null && this.value === that.value; +}; + +/** + * Check that the argument in this conversion is equal to the value in the + * provided conversion as defined by the argument (i.e. arg.equals). + * @param that The conversion to compare arguments with + */ +Conversion.prototype.argEquals = function(that) { + return that == null ? false : this.arg.equals(that.arg); +}; + +/** + * Accessor for the status of this conversion + */ +Conversion.prototype.getStatus = function(arg) { + return this._status; +}; + +/** + * Defined by the toString() value provided by the argument + */ +Conversion.prototype.toString = function() { + return this.arg.toString(); +}; + +/** + * If status === INCOMPLETE, then we may be able to provide predictions as to + * how the argument can be completed. + * @return An array of items, or a promise of an array of items, where each + * item is an object with the following properties: + * - name (mandatory): Displayed to the user, and typed in. No whitespace + * - description (optional): Short string for display in a tool-tip + * - manual (optional): Longer description which details usage + * - incomplete (optional): Indicates that the prediction if used should not + * be considered necessarily sufficient, which typically will mean that the + * UI should not append a space to the completion + * - value (optional): If a value property is present, this will be used as the + * value of the conversion, otherwise the item itself will be used. + */ +Conversion.prototype.getPredictions = function(context) { + if (typeof this.predictions === 'function') { + return this.predictions(context); + } + return Promise.resolve(this.predictions || []); +}; + +/** + * Return a promise of an index constrained by the available predictions. + * i.e. (index % predicitons.length) + * This code can probably be removed when the Firefox developer toolbar isn't + * needed any more. + */ +Conversion.prototype.constrainPredictionIndex = function(context, index) { + if (index == null) { + return Promise.resolve(); + } + + return this.getPredictions(context).then(function(value) { + if (value.length === 0) { + return undefined; + } + + index = index % value.length; + if (index < 0) { + index = value.length + index; + } + return index; + }.bind(this)); +}; + +/** + * Constant to allow everyone to agree on the maximum number of predictions + * that should be provided. We actually display 1 less than this number. + */ +Conversion.maxPredictions = 9; + +exports.Conversion = Conversion; + + +/** + * ArrayConversion is a special Conversion, needed because arrays are converted + * member by member rather then as a whole, which means we can track the + * conversion if individual array elements. So an ArrayConversion acts like a + * normal Conversion (which is needed as Assignment requires a Conversion) but + * it can also be devolved into a set of Conversions for each array member. + */ +function ArrayConversion(conversions, arg) { + this.arg = arg; + this.conversions = conversions; + this.value = conversions.map(function(conversion) { + return conversion.value; + }, this); + + this._status = Status.combine(conversions.map(function(conversion) { + return conversion.getStatus(); + })); + + // This message is just for reporting errors like "not enough values" + // rather that for problems with individual values. + this.message = ''; + + // Predictions are generally provided by individual values + this.predictions = []; +} + +ArrayConversion.prototype = Object.create(Conversion.prototype); + +Object.defineProperty(ArrayConversion.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.conversions.forEach(function(conversion) { + conversion.assignment = assignment; + }, this); + }, + enumerable: true +}); + +ArrayConversion.prototype.getStatus = function(arg) { + if (arg && arg.conversion) { + return arg.conversion.getStatus(); + } + return this._status; +}; + +ArrayConversion.prototype.isDataProvided = function() { + return this.conversions.length > 0; +}; + +ArrayConversion.prototype.valueEquals = function(that) { + if (that == null) { + return false; + } + + if (!(that instanceof ArrayConversion)) { + throw new Error('Can\'t compare values with non ArrayConversion'); + } + + if (this.value === that.value) { + return true; + } + + if (this.value.length !== that.value.length) { + return false; + } + + for (var i = 0; i < this.conversions.length; i++) { + if (!this.conversions[i].valueEquals(that.conversions[i])) { + return false; + } + } + + return true; +}; + +ArrayConversion.prototype.toString = function() { + return '[ ' + this.conversions.map(function(conversion) { + return conversion.toString(); + }, this).join(', ') + ' ]'; +}; + +exports.ArrayConversion = ArrayConversion; + + +/** + * Most of our types are 'static' e.g. there is only one type of 'string', + * however some types like 'selection' and 'delegate' are customizable. + * The basic Type type isn't useful, but does provide documentation about what + * types do. + */ +function Type() { +} + +/** + * Get a JSONable data structure that entirely describes this type. + * commandName and paramName are the names of the command and parameter which + * we are remoting to help the server get back to the remoted action. + */ +Type.prototype.getSpec = function(commandName, paramName) { + throw new Error('Not implemented'); +}; + +/** + * Convert the given <tt>value</tt> to a string representation. + * Where possible, there should be round-tripping between values and their + * string representations. + * @param value The object to convert into a string + * @param context An ExecutionContext to allow basic Requisition access + */ +Type.prototype.stringify = function(value, context) { + throw new Error('Not implemented'); +}; + +/** + * Convert the given <tt>arg</tt> to an instance of this type. + * Where possible, there should be round-tripping between values and their + * string representations. + * @param arg An instance of <tt>Argument</tt> to convert. + * @param context An ExecutionContext to allow basic Requisition access + * @return Conversion + */ +Type.prototype.parse = function(arg, context) { + throw new Error('Not implemented'); +}; + +/** + * A convenience method for times when you don't have an argument to parse + * but instead have a string. + * @see #parse(arg) + */ +Type.prototype.parseString = function(str, context) { + return this.parse(new Argument(str), context); +}; + +/** + * 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. + */ +Type.prototype.name = undefined; + +/** + * If there is some concept of a lower or higher value, return it, + * otherwise return undefined. + * @param by number indicating how much to nudge by, usually +1 or -1 which is + * caused by the user pressing the UP/DOWN keys with the cursor in this type + */ +Type.prototype.nudge = function(value, by, context) { + return undefined; +}; + +/** + * The 'blank value' of most types is 'undefined', but there are exceptions; + * This allows types to specify a better conversion from empty string than + * 'undefined'. + * 2 known examples of this are boolean -> false and array -> [] + */ +Type.prototype.getBlank = function(context) { + return new Conversion(undefined, new BlankArgument(), Status.INCOMPLETE, ''); +}; + +/** + * This is something of a hack for the benefit of DelegateType which needs to + * be able to lie about it's type for fields to accept it as one of their own. + * Sub-types can ignore this unless they're DelegateType. + * @param context An ExecutionContext to allow basic Requisition access + */ +Type.prototype.getType = function(context) { + return this; +}; + +/** + * addItems allows registrations of a number of things. This allows it to know + * what type of item, and how it should be registered. + */ +Type.prototype.item = 'type'; + +exports.Type = Type; + +/** + * 'Types' represents a registry of types + */ +function Types() { + // Invariant: types[name] = type.name + this._registered = {}; +} + +exports.Types = Types; + +/** + * Get an array of the names of registered types + */ +Types.prototype.getTypeNames = function() { + return Object.keys(this._registered); +}; + +/** + * Add a new type to the list available to the system. + * You can pass 2 things to this function - either an instance of Type, in + * which case we return this instance when #getType() is called with a 'name' + * that matches type.name. + * Also you can pass in a constructor (i.e. function) in which case when + * #getType() is called with a 'name' that matches Type.prototype.name we will + * pass the typeSpec into this constructor. + */ +Types.prototype.add = function(type) { + if (typeof type === 'object') { + if (!type.name) { + throw new Error('All registered types must have a name'); + } + + if (type instanceof Type) { + this._registered[type.name] = type; + } + else { + var name = type.name; + var parent = type.parent; + type.name = parent; + delete type.parent; + + this._registered[name] = this.createType(type); + + type.name = name; + type.parent = parent; + } + } + else if (typeof type === 'function') { + if (!type.prototype.name) { + throw new Error('All registered types must have a name'); + } + this._registered[type.prototype.name] = type; + } + else { + throw new Error('Unknown type: ' + type); + } +}; + +/** + * Remove a type from the list available to the system + */ +Types.prototype.remove = function(type) { + delete this._registered[type.name]; +}; + +/** + * Find a previously registered type + */ +Types.prototype.createType = function(typeSpec) { + if (typeof typeSpec === 'string') { + typeSpec = { name: typeSpec }; + } + + if (typeof typeSpec !== 'object') { + throw new Error('Can\'t extract type from ' + typeSpec); + } + + var NewTypeCtor, newType; + if (typeSpec.name == null || typeSpec.name == 'type') { + NewTypeCtor = Type; + } + else { + NewTypeCtor = this._registered[typeSpec.name]; + } + + if (!NewTypeCtor) { + console.error('Known types: ' + Object.keys(this._registered).join(', ')); + throw new Error('Unknown type: \'' + typeSpec.name + '\''); + } + + if (typeof NewTypeCtor === 'function') { + newType = new NewTypeCtor(typeSpec); + } + else { + // clone 'type' + newType = {}; + util.copyProperties(NewTypeCtor, newType); + } + + // Copy the properties of typeSpec onto the new type + util.copyProperties(typeSpec, newType); + + // Several types need special powers to create child types + newType.types = this; + + if (typeof NewTypeCtor !== 'function') { + if (typeof newType.constructor === 'function') { + newType.constructor(); + } + } + + return newType; +}; diff --git a/devtools/shared/gcli/source/lib/gcli/types/union.js b/devtools/shared/gcli/source/lib/gcli/types/union.js new file mode 100644 index 000000000..c98d3411b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/union.js @@ -0,0 +1,117 @@ +/* + * Copyright 2014, 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 Conversion = require('./types').Conversion; +var Status = require('./types').Status; + +exports.items = [ + { + // The union type allows for a combination of different parameter types. + item: 'type', + name: 'union', + hasPredictions: true, + + constructor: function() { + // Get the properties of the type. Later types in the list should always + // be more general, so 'catch all' types like string must be last + this.alternatives = this.alternatives.map(function(typeData) { + return this.types.createType(typeData); + }.bind(this)); + }, + + getSpec: function(command, param) { + var spec = { name: 'union', alternatives: [] }; + this.alternatives.forEach(function(type) { + spec.alternatives.push(type.getSpec(command, param)); + }.bind(this)); + return spec; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + + var type = this.alternatives.find(function(typeData) { + return typeData.name === value.type; + }); + + return type.stringify(value[value.type], context); + }, + + parse: function(arg, context) { + var conversionPromises = this.alternatives.map(function(type) { + return type.parse(arg, context); + }.bind(this)); + + return Promise.all(conversionPromises).then(function(conversions) { + // Find a list of the predictions made by any conversion + var predictionPromises = conversions.map(function(conversion) { + return conversion.getPredictions(context); + }.bind(this)); + + return Promise.all(predictionPromises).then(function(allPredictions) { + // Take one prediction from each set of predictions, ignoring + // duplicates, until we've got up to Conversion.maxPredictions + var maxIndex = allPredictions.reduce(function(prev, prediction) { + return Math.max(prev, prediction.length); + }.bind(this), 0); + var predictions = []; + + indexLoop: + for (var index = 0; index < maxIndex; index++) { + for (var p = 0; p <= allPredictions.length; p++) { + if (predictions.length >= Conversion.maxPredictions) { + break indexLoop; + } + + if (allPredictions[p] != null) { + var prediction = allPredictions[p][index]; + if (prediction != null && predictions.indexOf(prediction) === -1) { + predictions.push(prediction); + } + } + } + } + + var bestStatus = Status.ERROR; + var value; + for (var i = 0; i < conversions.length; i++) { + var conversion = conversions[i]; + var thisStatus = conversion.getStatus(arg); + if (thisStatus < bestStatus) { + bestStatus = thisStatus; + } + if (bestStatus === Status.VALID) { + var type = this.alternatives[i].name; + value = { type: type }; + value[type] = conversion.value; + break; + } + } + + var msg = (bestStatus === Status.VALID) ? + '' : + l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]); + return new Conversion(value, arg, bestStatus, msg, predictions); + }.bind(this)); + }.bind(this)); + }, + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/url.js b/devtools/shared/gcli/source/lib/gcli/types/url.js new file mode 100644 index 000000000..73895d66b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/url.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 Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +exports.items = [ + { + item: 'type', + name: 'url', + + getSpec: function() { + return 'url'; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return value.href; + }, + + parse: function(arg, context) { + var conversion; + + try { + var url = host.createUrl(arg.text); + conversion = new Conversion(url, arg); + } + catch (ex) { + var predictions = []; + var status = Status.ERROR; + + // Maybe the URL was missing a scheme? + if (arg.text.indexOf('://') === -1) { + [ 'http', 'https' ].forEach(function(scheme) { + try { + var http = host.createUrl(scheme + '://' + arg.text); + predictions.push({ name: http.href, value: http }); + } + catch (ex) { + // Ignore + } + }.bind(this)); + + // Try to create a URL with the current page as a base ref + if ('window' in context.environment) { + try { + var base = context.environment.window.location.href; + var localized = host.createUrl(arg.text, base); + predictions.push({ name: localized.href, value: localized }); + } + catch (ex) { + // Ignore + } + } + } + + if (predictions.length > 0) { + status = Status.INCOMPLETE; + } + + conversion = new Conversion(undefined, arg, status, + ex.message, predictions); + } + + return Promise.resolve(conversion); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/focus.js b/devtools/shared/gcli/source/lib/gcli/ui/focus.js new file mode 100644 index 000000000..6d3761cca --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/focus.js @@ -0,0 +1,403 @@ +/* + * 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'); + +/** + * Record how much help the user wants from the tooltip + */ +var Eagerness = { + NEVER: 1, + SOMETIMES: 2, + ALWAYS: 3 +}; + +/** + * Export the eagerHelper setting + */ +exports.items = [ + { + item: 'setting', + name: 'eagerHelper', + type: { + name: 'selection', + lookup: [ + { name: 'never', value: Eagerness.NEVER }, + { name: 'sometimes', value: Eagerness.SOMETIMES }, + { name: 'always', value: Eagerness.ALWAYS } + ] + }, + defaultValue: Eagerness.SOMETIMES, + description: l10n.lookup('eagerHelperDesc'), + ignoreTypeDifference: true + } +]; + +/** + * FocusManager solves the problem of tracking focus among a set of nodes. + * The specific problem we are solving is when the hint element must be visible + * if either the command line or any of the inputs in the hint element has the + * focus, and invisible at other times, without hiding and showing the hint + * element even briefly as the focus changes between them. + * It does this simply by postponing the hide events by 250ms to see if + * something else takes focus. + */ +function FocusManager(document, settings) { + if (document == null) { + throw new Error('document == null'); + } + + this.document = document; + this.settings = settings; + this.debug = false; + this.blurDelay = 150; + this.window = this.document.defaultView; + + this._blurDelayTimeout = null; // Result of setTimeout in delaying a blur + this._monitoredElements = []; // See addMonitoredElement() + + this._isError = false; + this._hasFocus = false; + this._helpRequested = false; + this._recentOutput = false; + + this.onVisibilityChange = util.createEvent('FocusManager.onVisibilityChange'); + + this._focused = this._focused.bind(this); + if (this.document.addEventListener) { + this.document.addEventListener('focus', this._focused, true); + } + + var eagerHelper = this.settings.get('eagerHelper'); + eagerHelper.onChange.add(this._eagerHelperChanged, this); + + this.isTooltipVisible = undefined; + this.isOutputVisible = undefined; + this._checkShow(); +} + +/** + * Avoid memory leaks + */ +FocusManager.prototype.destroy = function() { + var eagerHelper = this.settings.get('eagerHelper'); + eagerHelper.onChange.remove(this._eagerHelperChanged, this); + + this.document.removeEventListener('focus', this._focused, true); + + for (var i = 0; i < this._monitoredElements.length; i++) { + var monitor = this._monitoredElements[i]; + console.error('Hanging monitored element: ', monitor.element); + + monitor.element.removeEventListener('focus', monitor.onFocus, true); + monitor.element.removeEventListener('blur', monitor.onBlur, true); + } + + if (this._blurDelayTimeout) { + this.window.clearTimeout(this._blurDelayTimeout); + this._blurDelayTimeout = null; + } + + this._focused = undefined; + this.document = undefined; + this.settings = undefined; + this.window = undefined; +}; + +/** + * The easy way to include an element in the set of things that are part of the + * aggregate focus. Using [add|remove]MonitoredElement() is a simpler way of + * option than calling report[Focus|Blur]() + * @param element The element on which to track focus|blur events + * @param where Optional source string for debugging only + */ +FocusManager.prototype.addMonitoredElement = function(element, where) { + if (this.debug) { + console.log('FocusManager.addMonitoredElement(' + (where || 'unknown') + ')'); + } + + var monitor = { + element: element, + where: where, + onFocus: function() { this._reportFocus(where); }.bind(this), + onBlur: function() { this._reportBlur(where); }.bind(this) + }; + + element.addEventListener('focus', monitor.onFocus, true); + element.addEventListener('blur', monitor.onBlur, true); + + if (this.document.activeElement === element) { + this._reportFocus(where); + } + + this._monitoredElements.push(monitor); +}; + +/** + * Undo the effects of addMonitoredElement() + * @param element The element to stop tracking + * @param where Optional source string for debugging only + */ +FocusManager.prototype.removeMonitoredElement = function(element, where) { + if (this.debug) { + console.log('FocusManager.removeMonitoredElement(' + (where || 'unknown') + ')'); + } + + this._monitoredElements = this._monitoredElements.filter(function(monitor) { + if (monitor.element === element) { + element.removeEventListener('focus', monitor.onFocus, true); + element.removeEventListener('blur', monitor.onBlur, true); + return false; + } + return true; + }); +}; + +/** + * Monitor for new command executions + */ +FocusManager.prototype.updatePosition = function(dimensions) { + var ev = { + tooltipVisible: this.isTooltipVisible, + outputVisible: this.isOutputVisible, + dimensions: dimensions + }; + this.onVisibilityChange(ev); +}; + +/** + * Monitor for new command executions + */ +FocusManager.prototype.outputted = function() { + this._recentOutput = true; + this._helpRequested = false; + this._checkShow(); +}; + +/** + * We take a focus event anywhere to be an indication that we might be about + * to lose focus + */ +FocusManager.prototype._focused = function() { + this._reportBlur('document'); +}; + +/** + * Some component has received a 'focus' event. This sets the internal status + * straight away and informs the listeners + * @param where Optional source string for debugging only + */ +FocusManager.prototype._reportFocus = function(where) { + if (this.debug) { + console.log('FocusManager._reportFocus(' + (where || 'unknown') + ')'); + } + + if (this._blurDelayTimeout) { + if (this.debug) { + console.log('FocusManager.cancelBlur'); + } + this.window.clearTimeout(this._blurDelayTimeout); + this._blurDelayTimeout = null; + } + + if (!this._hasFocus) { + this._hasFocus = true; + } + this._checkShow(); +}; + +/** + * Some component has received a 'blur' event. This waits for a while to see if + * we are going to get any subsequent 'focus' events and then sets the internal + * status and informs the listeners + * @param where Optional source string for debugging only + */ +FocusManager.prototype._reportBlur = function(where) { + if (this.debug) { + console.log('FocusManager._reportBlur(' + where + ')'); + } + + if (this._hasFocus) { + if (this._blurDelayTimeout) { + if (this.debug) { + console.log('FocusManager.blurPending'); + } + return; + } + + this._blurDelayTimeout = this.window.setTimeout(function() { + if (this.debug) { + console.log('FocusManager.blur'); + } + this._hasFocus = false; + this._checkShow(); + this._blurDelayTimeout = null; + }.bind(this), this.blurDelay); + } +}; + +/** + * The setting has changed + */ +FocusManager.prototype._eagerHelperChanged = function() { + this._checkShow(); +}; + +/** + * The terminal tells us about keyboard events so we can decide to delay + * showing the tooltip element + */ +FocusManager.prototype.onInputChange = function() { + this._recentOutput = false; + this._checkShow(); +}; + +/** + * Generally called for something like a F1 key press, when the user explicitly + * wants help + */ +FocusManager.prototype.helpRequest = function() { + if (this.debug) { + console.log('FocusManager.helpRequest'); + } + + this._helpRequested = true; + this._recentOutput = false; + this._checkShow(); +}; + +/** + * Generally called for something like a ESC key press, when the user explicitly + * wants to get rid of the help + */ +FocusManager.prototype.removeHelp = function() { + if (this.debug) { + console.log('FocusManager.removeHelp'); + } + + this._importantFieldFlag = false; + this._isError = false; + this._helpRequested = false; + this._recentOutput = false; + this._checkShow(); +}; + +/** + * Set to true whenever a field thinks it's output is important + */ +FocusManager.prototype.setImportantFieldFlag = function(flag) { + if (this.debug) { + console.log('FocusManager.setImportantFieldFlag', flag); + } + this._importantFieldFlag = flag; + this._checkShow(); +}; + +/** + * Set to true whenever a field thinks it's output is important + */ +FocusManager.prototype.setError = function(isError) { + if (this.debug) { + console.log('FocusManager._isError', isError); + } + this._isError = isError; + this._checkShow(); +}; + +/** + * Helper to compare the current showing state with the value calculated by + * _shouldShow() and take appropriate action + */ +FocusManager.prototype._checkShow = function() { + var fire = false; + var ev = { + tooltipVisible: this.isTooltipVisible, + outputVisible: this.isOutputVisible + }; + + var showTooltip = this._shouldShowTooltip(); + if (this.isTooltipVisible !== showTooltip.visible) { + ev.tooltipVisible = this.isTooltipVisible = showTooltip.visible; + fire = true; + } + + var showOutput = this._shouldShowOutput(); + if (this.isOutputVisible !== showOutput.visible) { + ev.outputVisible = this.isOutputVisible = showOutput.visible; + fire = true; + } + + if (fire) { + if (this.debug) { + console.log('FocusManager.onVisibilityChange', ev); + } + this.onVisibilityChange(ev); + } +}; + +/** + * Calculate if we should be showing or hidden taking into account all the + * available inputs + */ +FocusManager.prototype._shouldShowTooltip = function() { + var eagerHelper = this.settings.get('eagerHelper'); + if (eagerHelper.value === Eagerness.NEVER) { + return { visible: false, reason: 'eagerHelperNever' }; + } + + if (eagerHelper.value === Eagerness.ALWAYS) { + return { visible: true, reason: 'eagerHelperAlways' }; + } + + if (!this._hasFocus) { + return { visible: false, reason: 'notHasFocus' }; + } + + if (this._isError) { + return { visible: true, reason: 'isError' }; + } + + if (this._helpRequested) { + return { visible: true, reason: 'helpRequested' }; + } + + if (this._importantFieldFlag) { + return { visible: true, reason: 'importantFieldFlag' }; + } + + return { visible: false, reason: 'default' }; +}; + +/** + * Calculate if we should be showing or hidden taking into account all the + * available inputs + */ +FocusManager.prototype._shouldShowOutput = function() { + if (!this._hasFocus) { + return { visible: false, reason: 'notHasFocus' }; + } + + if (this._recentOutput) { + return { visible: true, reason: 'recentOutput' }; + } + + return { visible: false, reason: 'default' }; +}; + +exports.FocusManager = FocusManager; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/history.js b/devtools/shared/gcli/source/lib/gcli/ui/history.js new file mode 100644 index 000000000..a9d4b868c --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/history.js @@ -0,0 +1,71 @@ +/* + * 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'; + +/** + * A History object remembers commands that have been entered in the past and + * provides an API for accessing them again. + * See Bug 681340: Search through history (like C-r in bash)? + */ +function History() { + // This is the actual buffer where previous commands are kept. + // 'this._buffer[0]' should always be equal the empty string. This is so + // that when you try to go in to the "future", you will just get an empty + // command. + this._buffer = ['']; + + // This is an index in to the history buffer which points to where we + // currently are in the history. + this._current = 0; +} + +/** + * Avoid memory leaks + */ +History.prototype.destroy = function() { + this._buffer = undefined; +}; + +/** + * Record and save a new command in the history. + */ +History.prototype.add = function(command) { + this._buffer.splice(1, 0, command); + this._current = 0; +}; + +/** + * Get the next (newer) command from history. + */ +History.prototype.forward = function() { + if (this._current > 0 ) { + this._current--; + } + return this._buffer[this._current]; +}; + +/** + * Get the previous (older) item from history. + */ +History.prototype.backward = function() { + if (this._current < this._buffer.length - 1) { + this._current++; + } + return this._buffer[this._current]; +}; + +exports.History = History; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/intro.js b/devtools/shared/gcli/source/lib/gcli/ui/intro.js new file mode 100644 index 000000000..9abf51db6 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/intro.js @@ -0,0 +1,90 @@ +/* + * 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 Output = require('../cli').Output; +var view = require('./view'); + +/** + * Record if the user has clicked on 'Got It!' + */ +exports.items = [ + { + item: 'setting', + name: 'hideIntro', + type: 'boolean', + description: l10n.lookup('hideIntroDesc'), + defaultValue: false + } +]; + +/** + * Called when the UI is ready to add a welcome message to the output + */ +exports.maybeShowIntro = function (commandOutputManager, conversionContext, + outputPanel) { + var hideIntro = conversionContext.system.settings.get('hideIntro'); + if (hideIntro.value) { + return; + } + + var output = new Output(conversionContext); + output.type = 'view'; + commandOutputManager.onOutput({ output: output }); + + var viewData = this.createView(null, conversionContext, true, outputPanel); + + output.complete({ isTypedData: true, type: 'view', data: viewData }); +}; + +/** + * Called when the UI is ready to add a welcome message to the output + */ +exports.createView = function (ignoreArgs, conversionContext, showHideButton, + outputPanel) { + return view.createView({ + html: + '<div save="${mainDiv}">\n' + + ' <p>${l10n.introTextOpening3}</p>\n' + + '\n' + + ' <p>\n' + + ' ${l10n.introTextCommands}\n' + + ' <span class="gcli-out-shortcut" onclick="${onclick}"\n' + + ' ondblclick="${ondblclick}"\n' + + ' data-command="help">help</span>${l10n.introTextKeys2}\n' + + ' <code>${l10n.introTextF1Escape}</code>.\n' + + ' </p>\n' + + '\n' + + ' <button onclick="${onGotIt}"\n' + + ' if="${showHideButton}">${l10n.introTextGo}</button>\n' + + '</div>', + options: { stack: 'intro.html' }, + data: { + l10n: l10n.propertyLookup, + onclick: conversionContext.update, + ondblclick: conversionContext.updateExec, + showHideButton: showHideButton, + onGotIt: function(ev) { + var settings = conversionContext.system.settings; + var hideIntro = settings.get('hideIntro'); + hideIntro.value = true; + outputPanel.remove(); + } + } + }); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/menu.css b/devtools/shared/gcli/source/lib/gcli/ui/menu.css new file mode 100644 index 000000000..913ee1eec --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/menu.css @@ -0,0 +1,69 @@ + +.gcli-menu { + overflow: hidden; + font-size: 90%; +} + +.gcli-menu:not(:first-of-type) { + padding-top: 5px; +} + +.gcli-menu-vert { + white-space: nowrap; + max-width: 22em; + display: inline-flex; + padding-inline-end: 20px; + -webkit-padding-end: 20px; +} + +.gcli-menu-names { + white-space: nowrap; + flex-grow: 0; + flex-shrink: 0; +} + +.gcli-menu-descs { + flex-grow: 1; + flex-shrink: 1; +} + +.gcli-menu-name, +.gcli-menu-desc { + white-space: nowrap; +} + +.gcli-menu-name { + padding-inline-start: 2px; + -webkit-padding-start: 2px; + padding-inline-end: 8px; + -webkit-padding-end: 8px; +} + +.gcli-menu-desc { + padding-inline-end: 2px; + -webkit-padding-end: 2px; + color: #777; + text-overflow: ellipsis; + overflow: hidden; +} + +.gcli-menu-name:hover, +.gcli-menu-desc:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.gcli-menu-highlight, +.gcli-menu-highlight.gcli-menu-option:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.gcli-menu-typed { + color: #FF6600; +} + +.gcli-menu-more { + font-size: 80%; + width: 8em; + display: inline-flex; + vertical-align: bottom; +} diff --git a/devtools/shared/gcli/source/lib/gcli/ui/menu.html b/devtools/shared/gcli/source/lib/gcli/ui/menu.html new file mode 100644 index 000000000..ab6a690f4 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/menu.html @@ -0,0 +1,20 @@ + +<div> + <div class="gcli-menu-template" aria-live="polite"> + <div class="gcli-menu-names"> + <div class="gcli-menu-name" + foreach="item in ${items}" + data-name="${item.name}" + onclick="${onItemClickInternal}" + title="${item.manual}">${item.highlight}</div> + </div> + <div class="gcli-menu-descs"> + <div class="gcli-menu-desc" + foreach="item in ${items}" + data-name="${item.name}" + onclick="${onItemClickInternal}" + title="${item.manual}">${item.description}</div> + </div> + </div> + <div class="gcli-menu-more" if="${hasMore}">${l10n.fieldMenuMore}</div> +</div> diff --git a/devtools/shared/gcli/source/lib/gcli/ui/menu.js b/devtools/shared/gcli/source/lib/gcli/ui/menu.js new file mode 100644 index 000000000..52b415384 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/menu.js @@ -0,0 +1,328 @@ +/* + * 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'); +var domtemplate = require('../util/domtemplate'); +var host = require('../util/host'); + +/** + * Shared promises for loading resource files + */ +var menuCssPromise; +var menuHtmlPromise; + +/** + * Menu is a display of the commands that are possible given the state of a + * requisition. + * @param options A way to customize the menu display. + * - document: The document to use in creating widgets + * - maxPredictions (default=8): The maximum predictions to show at one time + * If more are requested, a message will be displayed asking the user to + * continue typing to narrow the list of options + */ +function Menu(options) { + options = options || {}; + this.document = options.document || document; + this.maxPredictions = options.maxPredictions || 8; + + // Keep track of any highlighted items + this._choice = null; + + // FF can be really hard to debug if doc is null, so we check early on + if (!this.document) { + throw new Error('No document'); + } + + this.element = util.createElement(this.document, 'div'); + this.element.classList.add('gcli-menu'); + + if (menuCssPromise == null) { + menuCssPromise = host.staticRequire(module, './menu.css'); + } + menuCssPromise.then(function(menuCss) { + // Pull the HTML into the DOM, but don't add it to the document + if (menuCss != null) { + util.importCss(menuCss, this.document, 'gcli-menu'); + } + }.bind(this), console.error); + + this.templateOptions = { blankNullUndefined: true, stack: 'menu.html' }; + if (menuHtmlPromise == null) { + menuHtmlPromise = host.staticRequire(module, './menu.html'); + } + menuHtmlPromise.then(function(menuHtml) { + if (this.document == null) { + return; // destroy() has been called + } + + this.template = host.toDom(this.document, menuHtml); + }.bind(this), console.error); + + // Contains the items that should be displayed + this.items = []; + + this.onItemClick = util.createEvent('Menu.onItemClick'); +} + +/** + * Allow the template engine to get at localization strings + */ +Menu.prototype.l10n = l10n.propertyLookup; + +/** + * Avoid memory leaks + */ +Menu.prototype.destroy = function() { + this.element = undefined; + this.template = undefined; + this.document = undefined; + this.items = undefined; +}; + +/** + * The default is to do nothing when someone clicks on the menu. + * This is called from template.html + * @param ev The click event from the browser + */ +Menu.prototype.onItemClickInternal = function(ev) { + var name = ev.currentTarget.getAttribute('data-name'); + if (!name) { + var named = ev.currentTarget.querySelector('[data-name]'); + name = named.getAttribute('data-name'); + } + this.onItemClick({ name: name }); +}; + +/** + * Act as though someone clicked on the selected item + */ +Menu.prototype.clickSelected = function() { + this.onItemClick({ name: this.selected }); +}; + +/** + * What is the currently selected item? + */ +Object.defineProperty(Menu.prototype, 'isSelected', { + get: function() { + return this.selected != null; + }, + enumerable: true +}); + +/** + * What is the currently selected item? + */ +Object.defineProperty(Menu.prototype, 'selected', { + get: function() { + var item = this.element.querySelector('.gcli-menu-name.gcli-menu-highlight'); + if (!item) { + return null; + } + return item.textContent; + }, + enumerable: true +}); + +/** + * Display a number of items in the menu (or hide the menu if there is nothing + * to display) + * @param items The items to show in the menu + * @param match Matching text to highlight in the output + */ +Menu.prototype.show = function(items, match) { + // If the HTML hasn't loaded yet then just don't show a menu + if (this.template == null) { + return; + } + + this.items = items.filter(function(item) { + return item.hidden === undefined || item.hidden !== true; + }.bind(this)); + + this.items = this.items.map(function(item) { + return getHighlightingProxy(item, match, this.template.ownerDocument); + }.bind(this)); + + if (this.items.length === 0) { + this.element.style.display = 'none'; + return; + } + + if (this.items.length >= this.maxPredictions) { + this.items.splice(-1); + this.hasMore = true; + } + else { + this.hasMore = false; + } + + var options = this.template.cloneNode(true); + domtemplate.template(options, this, this.templateOptions); + + util.clearElement(this.element); + this.element.appendChild(options); + + this.element.style.display = 'block'; +}; + +var MAX_ITEMS = 3; + +/** + * Takes an array of items and cuts it into an array of arrays to help us + * to place the items into columns. + * The inner arrays will have at most MAX_ITEMS in them, with the number of + * outer arrays expanding to accommodate. + */ +Object.defineProperty(Menu.prototype, 'itemsSubdivided', { + get: function() { + var reply = []; + + var taken = 0; + while (taken < this.items.length) { + reply.push(this.items.slice(taken, taken + MAX_ITEMS)); + taken += MAX_ITEMS; + } + + return reply; + }, + enumerable: true +}); + +/** + * Create a proxy around an item that highlights matching text + */ +function getHighlightingProxy(item, match, document) { + var proxy = {}; + Object.defineProperties(proxy, { + highlight: { + get: function() { + if (!match) { + return item.name; + } + + var value = item.name; + var startMatch = value.indexOf(match); + if (startMatch === -1) { + return value; + } + + var before = value.substr(0, startMatch); + var after = value.substr(startMatch + match.length); + var parent = util.createElement(document, 'span'); + parent.appendChild(document.createTextNode(before)); + var highlight = util.createElement(document, 'span'); + highlight.classList.add('gcli-menu-typed'); + highlight.appendChild(document.createTextNode(match)); + parent.appendChild(highlight); + parent.appendChild(document.createTextNode(after)); + return parent; + }, + enumerable: true + }, + + name: { + value: item.name, + enumerable: true + }, + + manual: { + value: item.manual, + enumerable: true + }, + + description: { + value: item.description, + enumerable: true + } + }); + return proxy; +} + +/** + * @return {int} current choice index + */ +Menu.prototype.getChoiceIndex = function() { + return this._choice == null ? 0 : this._choice; +}; + +/** + * Highlight the next (for by=1) or previous (for by=-1) option + */ +Menu.prototype.nudgeChoice = function(by) { + if (this._choice == null) { + this._choice = 0; + } + + // There's an annoying up is down thing here, the menu is presented + // with the zeroth index at the top working down, so the UP arrow needs + // pick the choice below because we're working down + this._choice -= by; + this._updateHighlight(); +}; + +/** + * Highlight nothing + */ +Menu.prototype.unsetChoice = function() { + this._choice = null; + this._updateHighlight(); +}; + +/** + * Internal option to update the currently highlighted option + */ +Menu.prototype._updateHighlight = function() { + var names = this.element.querySelectorAll('.gcli-menu-name'); + var descs = this.element.querySelectorAll('.gcli-menu-desc'); + for (var i = 0; i < names.length; i++) { + names[i].classList.remove('gcli-menu-highlight'); + } + for (i = 0; i < descs.length; i++) { + descs[i].classList.remove('gcli-menu-highlight'); + } + + if (this._choice == null || names.length === 0) { + return; + } + + var index = this._choice % names.length; + if (index < 0) { + index = names.length + index; + } + + names.item(index).classList.add('gcli-menu-highlight'); + descs.item(index).classList.add('gcli-menu-highlight'); +}; + +/** + * Hide the menu + */ +Menu.prototype.hide = function() { + this.element.style.display = 'none'; +}; + +/** + * Change how much vertical space this menu can take up + */ +Menu.prototype.setMaxHeight = function(height) { + this.element.style.maxHeight = height + 'px'; +}; + +exports.Menu = Menu; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/moz.build b/devtools/shared/gcli/source/lib/gcli/ui/moz.build new file mode 100644 index 000000000..70ac666f0 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/moz.build @@ -0,0 +1,15 @@ +# -*- 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( + 'focus.js', + 'history.js', + 'intro.js', + 'menu.css', + 'menu.html', + 'menu.js', + 'view.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/ui/view.js b/devtools/shared/gcli/source/lib/gcli/ui/view.js new file mode 100644 index 000000000..193fb2d96 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/view.js @@ -0,0 +1,87 @@ +/* + * 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'); + + +/** + * We want to avoid commands having to create DOM structures because that's + * messy and because we're going to need to have command output displayed in + * different documents. A View is a way to wrap an HTML template (for + * domtemplate) in with the data and options to render the template, so anyone + * can later run the template in the context of any document. + * View also cuts out a chunk of boiler place code. + * @param options The information needed to create the DOM from HTML. Includes: + * - html (required): The HTML source, probably from a call to require + * - options (default={}): The domtemplate options. See domtemplate for details + * - data (default={}): The data to domtemplate. See domtemplate for details. + * - css (default=none): Some CSS to be added to the final document. If 'css' + * is used, use of cssId is strongly recommended. + * - cssId (default=none): An ID to prevent multiple CSS additions. See + * util.importCss for more details. + * @return An object containing a single function 'appendTo()' which runs the + * template adding the result to the specified element. Takes 2 parameters: + * - element (required): the element to add to + * - clear (default=false): if clear===true then remove all pre-existing + * children of 'element' before appending the results of this template. + */ +exports.createView = function(options) { + if (options.html == null) { + throw new Error('options.html is missing'); + } + + return { + /** + * RTTI. Yeah. + */ + isView: true, + + /** + * Run the template against the document to which element belongs. + * @param element The element to append the result to + * @param clear Set clear===true to remove all children of element + */ + appendTo: function(element, clear) { + // Strict check on the off-chance that we later think of other options + // and want to replace 'clear' with an 'options' parameter, but want to + // support backwards compat. + if (clear === true) { + util.clearElement(element); + } + + element.appendChild(this.toDom(element.ownerDocument)); + }, + + /** + * Actually convert the view data into a DOM suitable to be appended to + * an element + * @param document to use in realizing the template + */ + toDom: function(document) { + if (options.css) { + util.importCss(options.css, document, options.cssId); + } + + var child = host.toDom(document, options.html); + domtemplate.template(child, options.data || {}, options.options || {}); + return child; + } + }; +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/domtemplate.js b/devtools/shared/gcli/source/lib/gcli/util/domtemplate.js new file mode 100644 index 000000000..d8979db3b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/domtemplate.js @@ -0,0 +1,20 @@ +/* + * 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 {template} = require("devtools/shared/gcli/templater"); +exports.template = template; diff --git a/devtools/shared/gcli/source/lib/gcli/util/fileparser.js b/devtools/shared/gcli/source/lib/gcli/util/fileparser.js new file mode 100644 index 000000000..4c470e638 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/fileparser.js @@ -0,0 +1,281 @@ +/* + * 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'); +var l10n = require('./l10n'); +var spell = require('./spell'); +var filesystem = require('./filesystem'); +var Status = require('../types/types').Status; + +/* + * An implementation of the functions that call the filesystem, designed to + * support the file type. + */ + +/** + * Helper for the parse() function from the file type. + * See gcli/util/filesystem.js for details + */ +exports.parse = function(context, typed, options) { + return filesystem.stat(typed).then(function(stats) { + // The 'save-as' case - the path should not exist but does + if (options.existing === 'no' && stats.exists) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrExists', [ typed ]), + predictor: undefined // No predictions that we can give here + }; + } + + if (stats.exists) { + // The path exists - check it's the correct file type ... + if (options.filetype === 'file' && !stats.isFile) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrIsNotFile', [ typed ]), + predictor: getPredictor(typed, options) + }; + } + + if (options.filetype === 'directory' && !stats.isDir) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrIsNotDirectory', [ typed ]), + predictor: getPredictor(typed, options) + }; + } + + // ... and that it matches any 'match' RegExp + if (options.matches != null && !options.matches.test(typed)) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrDoesntMatch', + [ typed, options.source ]), + predictor: getPredictor(typed, options) + }; + } + } + else { + if (options.existing === 'yes') { + // We wanted something that exists, but it doesn't. But we don't know + // if the path so far is an ERROR or just INCOMPLETE + var parentName = filesystem.dirname(typed); + return filesystem.stat(parentName).then(function(stats) { + return { + value: undefined, + status: stats.isDir ? Status.INCOMPLETE : Status.ERROR, + message: l10n.lookupFormat('fileErrNotExists', [ typed ]), + predictor: getPredictor(typed, options) + }; + }); + } + } + + // We found no problems + return { + value: typed, + status: Status.VALID, + message: undefined, + predictor: getPredictor(typed, options) + }; + }); +}; + +var RANK_OPTIONS = { noSort: true, prefixZero: true }; + +/** + * We want to be able to turn predictions off in Firefox + */ +exports.supportsPredictions = false; + +/** + * Get a function which creates predictions of files that match the given + * path + */ +function getPredictor(typed, options) { + if (!exports.supportsPredictions) { + return undefined; + } + + return function() { + var allowFile = (options.filetype !== 'directory'); + var parts = filesystem.split(typed); + + var absolute = (typed.indexOf('/') === 0); + var roots; + if (absolute) { + roots = [ { name: '/', dist: 0, original: '/' } ]; + } + else { + roots = dirHistory.getCommonDirectories().map(function(root) { + return { name: root, dist: 0, original: root }; + }); + } + + // Add each part of the typed pathname onto each of the roots in turn, + // Finding options from each of those paths, and using these options as + // our roots for the next part + var partsAdded = util.promiseEach(parts, function(part, index) { + + var partsSoFar = filesystem.join.apply(filesystem, parts.slice(0, index + 1)); + + // We allow this file matches in this pass if we're allowed files at all + // (i.e this isn't 'cd') and if this is the last part of the path + var allowFileForPart = (allowFile && index >= parts.length - 1); + + var rootsPromise = util.promiseEach(roots, function(root) { + + // Extend each roots to a list of all the files in each of the roots + var matchFile = allowFileForPart ? options.matches : null; + var promise = filesystem.ls(root.name, matchFile); + + var onSuccess = function(entries) { + // Unless this is the final part filter out the non-directories + if (!allowFileForPart) { + entries = entries.filter(function(entry) { + return entry.isDir; + }); + } + var entryMap = {}; + entries.forEach(function(entry) { + entryMap[entry.pathname] = entry; + }); + return entryMap; + }; + + var onError = function(err) { + // We expect errors due to the path not being a directory, not being + // accessible, or removed since the call to 'readdir' + return {}; + }; + + promise = promise.then(onSuccess, onError); + + // We want to compare all the directory entries with the original root + // plus the partsSoFar + var compare = filesystem.join(root.original, partsSoFar); + + return promise.then(function(entryMap) { + + var ranks = spell.rank(compare, Object.keys(entryMap), RANK_OPTIONS); + // penalize each path by the distance of it's parent + ranks.forEach(function(rank) { + rank.original = root.original; + rank.stats = entryMap[rank.name]; + }); + return ranks; + }); + }); + + return rootsPromise.then(function(data) { + // data is an array of arrays of ranking objects. Squash down. + data = data.reduce(function(prev, curr) { + return prev.concat(curr); + }, []); + + data.sort(function(r1, r2) { + return r1.dist - r2.dist; + }); + + // Trim, but by how many? + // If this is the last run through, we want to present the user with + // a sensible set of predictions. Otherwise we want to trim the tree + // to a reasonable set of matches, so we're happy with 1 + // We look through x +/- 3 roots, and find the one with the biggest + // distance delta, and cut below that + // x=5 for the last time through, and x=8 otherwise + var isLast = index >= parts.length - 1; + var start = isLast ? 1 : 5; + var end = isLast ? 7 : 10; + + var maxDeltaAt = start; + var maxDelta = data[start].dist - data[start - 1].dist; + + for (var i = start + 1; i < end; i++) { + var delta = data[i].dist - data[i - 1].dist; + if (delta >= maxDelta) { + maxDelta = delta; + maxDeltaAt = i; + } + } + + // Update the list of roots for the next time round + roots = data.slice(0, maxDeltaAt); + }); + }); + + return partsAdded.then(function() { + var predictions = roots.map(function(root) { + var isFile = root.stats && root.stats.isFile; + var isDir = root.stats && root.stats.isDir; + + var name = root.name; + if (isDir && name.charAt(name.length) !== filesystem.sep) { + name += filesystem.sep; + } + + return { + name: name, + incomplete: !(allowFile && isFile), + isFile: isFile, // Added for describe, below + dist: root.dist, // TODO: Remove - added for debug in describe + }; + }); + + return util.promiseEach(predictions, function(prediction) { + if (!prediction.isFile) { + prediction.description = '(' + prediction.dist + ')'; + prediction.dist = undefined; + prediction.isFile = undefined; + return prediction; + } + + return filesystem.describe(prediction.name).then(function(description) { + prediction.description = description; + prediction.dist = undefined; + prediction.isFile = undefined; + return prediction; + }); + }); + }); + }; +} + +// ============================================================================= + +/* + * The idea is that we maintain a list of 'directories that the user is + * interested in'. We store directories in a most-frequently-used cache + * of some description. + * But for now we're just using / and ~/ + */ +var dirHistory = { + getCommonDirectories: function() { + return [ + filesystem.sep, // i.e. the root directory + filesystem.home // i.e. the users home directory + ]; + }, + addCommonDirectory: function(ignore) { + // Not implemented yet + } +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/filesystem.js b/devtools/shared/gcli/source/lib/gcli/util/filesystem.js new file mode 100644 index 000000000..a7b22a8f7 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/filesystem.js @@ -0,0 +1,130 @@ +/* + * 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 Cu = require('chrome').Cu; +var Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; + +var OS = Cu.import('resource://gre/modules/osfile.jsm', {}).OS; + +/** + * A set of functions that don't really belong in 'fs' (because they're not + * really universal in scope) but also kind of do (because they're not specific + * to GCLI + */ + +exports.join = OS.Path.join; +exports.sep = OS.Path.sep; +exports.dirname = OS.Path.dirname; + +// On B2G, there is no home folder +var home = null; +try { + var dirService = Cc['@mozilla.org/file/directory_service;1'] + .getService(Ci.nsIProperties); + home = dirService.get('Home', Ci.nsIFile).path; +} catch(e) {} +exports.home = home; + +if ('winGetDrive' in OS.Path) { + exports.sep = '\\'; +} +else { + exports.sep = '/'; +} + +/** + * Split a path into its components. + * @param pathname (string) The part to cut up + * @return An array of path components + */ +exports.split = function(pathname) { + return OS.Path.split(pathname).components; +}; + +/** + * @param pathname string, path of an existing directory + * @param matches optional regular expression - filter output to include only + * the files that match the regular expression. The regexp is applied to the + * filename only not to the full path + * @return A promise of an array of stat objects for each member of the + * directory pointed to by ``pathname``, each containing 2 extra properties: + * - pathname: The full pathname of the file + * - filename: The final filename part of the pathname + */ +exports.ls = function(pathname, matches) { + var iterator = new OS.File.DirectoryIterator(pathname); + var entries = []; + + var iteratePromise = iterator.forEach(function(entry) { + entries.push({ + exists: true, + isDir: entry.isDir, + isFile: !entry.isFile, + filename: entry.name, + pathname: entry.path + }); + }); + + return iteratePromise.then(function onSuccess() { + iterator.close(); + return entries; + }, + function onFailure(reason) { + iterator.close(); + throw reason; + } + ); +}; + +/** + * stat() is annoying because it considers stat('/doesnt/exist') to be an + * error, when the point of stat() is to *find* *out*. So this wrapper just + * converts 'ENOENT' i.e. doesn't exist to { exists:false } and adds + * exists:true to stat blocks from existing paths + */ +exports.stat = function(pathname) { + var onResolve = function(stats) { + return { + exists: true, + isDir: stats.isDir, + isFile: !stats.isFile + }; + }; + + var onReject = function(err) { + if (err instanceof OS.File.Error && err.becauseNoSuchFile) { + return { + exists: false, + isDir: false, + isFile: false + }; + } + throw err; + }; + + return OS.File.stat(pathname).then(onResolve, onReject); +}; + +/** + * We may read the first line of a file to describe it? + * Right now, however, we do nothing. + */ +exports.describe = function(pathname) { + return Promise.resolve(''); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/host.js b/devtools/shared/gcli/source/lib/gcli/util/host.js new file mode 100644 index 000000000..00fefa4f6 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/host.js @@ -0,0 +1,230 @@ +/* + * 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 { Task } = require("devtools/shared/task"); + +var util = require('./util'); + +function Highlighter(document) { + this._document = document; + this._nodes = util.createEmptyNodeList(this._document); +} + +Object.defineProperty(Highlighter.prototype, 'nodelist', { + set: function(nodes) { + Array.prototype.forEach.call(this._nodes, this._unhighlightNode, this); + this._nodes = (nodes == null) ? + util.createEmptyNodeList(this._document) : + nodes; + Array.prototype.forEach.call(this._nodes, this._highlightNode, this); + }, + get: function() { + return this._nodes; + }, + enumerable: true +}); + +Highlighter.prototype.destroy = function() { + this.nodelist = null; +}; + +Highlighter.prototype._highlightNode = function(node) { + // Enable when the highlighter rewrite is done +}; + +Highlighter.prototype._unhighlightNode = function(node) { + // Enable when the highlighter rewrite is done +}; + +exports.Highlighter = Highlighter; + +/** + * See docs in lib/gcli/util/host.js + */ +exports.exec = function(task) { + return Task.spawn(task); +}; + +/** + * The URL API is new enough that we need specific platform help + */ +exports.createUrl = function(uristr, base) { + return new URL(uristr, base); +}; + +/** + * Load some HTML into the given document and return a DOM element. + * This utility assumes that the html has a single root (other than whitespace) + */ +exports.toDom = function(document, html) { + var div = util.createElement(document, 'div'); + util.setContents(div, html); + return div.children[0]; +}; + +/** + * When dealing with module paths on windows we want to use the unix + * directory separator rather than the windows one, so we avoid using + * OS.Path.dirname, and use unix version on all platforms. + */ +var resourceDirName = function(path) { + var index = path.lastIndexOf('/'); + if (index == -1) { + return '.'; + } + while (index >= 0 && path[index] == '/') { + --index; + } + return path.slice(0, index + 1); +}; + +/** + * Asynchronously load a text resource + * @see lib/gcli/util/host.js + */ +exports.staticRequire = function(requistingModule, name) { + if (name.match(/\.css$/)) { + return Promise.resolve(''); + } + else { + return new Promise(function(resolve, reject) { + var filename = resourceDirName(requistingModule.id) + '/' + name; + filename = filename.replace(/\/\.\//g, '/'); + filename = 'resource://devtools/shared/gcli/source/lib/' + filename; + + var xhr = Cc['@mozilla.org/xmlextras/xmlhttprequest;1'] + .createInstance(Ci.nsIXMLHttpRequest); + + xhr.onload = function onload() { + resolve(xhr.responseText); + }.bind(this); + + xhr.onabort = xhr.onerror = xhr.ontimeout = function(err) { + reject(err); + }.bind(this); + + xhr.open('GET', filename); + xhr.send(); + }.bind(this)); + } +}; + +/** + * A group of functions to help scripting. Small enough that it doesn't need + * a separate module (it's basically a wrapper around 'eval' in some contexts) + */ +var client; +var target; +var consoleActor; +var webConsoleClient; + +exports.script = { }; + +exports.script.onOutput = util.createEvent('Script.onOutput'); + +/** + * Setup the environment to eval JavaScript + */ +exports.script.useTarget = function(tgt) { + target = tgt; + + // Local debugging needs to make the target remote. + var targetPromise = target.isRemote ? + Promise.resolve(target) : + target.makeRemote(); + + return targetPromise.then(function() { + return new Promise(function(resolve, reject) { + client = target._client; + + client.addListener('pageError', function(packet) { + if (packet.from === consoleActor) { + // console.log('pageError', packet.pageError); + exports.script.onOutput({ + level: 'exception', + message: packet.exception.class + }); + } + }); + + client.addListener('consoleAPICall', function(type, packet) { + if (packet.from === consoleActor) { + var data = packet.message; + + var ev = { + level: data.level, + arguments: data.arguments, + }; + + if (data.filename !== 'debugger eval code') { + ev.source = { + filename: data.filename, + lineNumber: data.lineNumber, + functionName: data.functionName + }; + } + + exports.script.onOutput(ev); + } + }); + + consoleActor = target._form.consoleActor; + + var onAttach = function(response, wcc) { + webConsoleClient = wcc; + + if (response.error != null) { + reject(response); + } + else { + resolve(response); + } + + // TODO: add _onTabNavigated code? + }; + + var listeners = [ 'PageError', 'ConsoleAPI' ]; + client.attachConsole(consoleActor, listeners, onAttach); + }.bind(this)); + }); +}; + +/** + * Execute some JavaScript + */ +exports.script.evaluate = function(javascript) { + return new Promise(function(resolve, reject) { + var onResult = function(response) { + var output = response.result; + if (typeof output === 'object' && output.type === 'undefined') { + output = undefined; + } + + resolve({ + input: response.input, + output: output, + exception: response.exception + }); + }; + + webConsoleClient.evaluateJS(javascript, onResult, {}); + }.bind(this)); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/l10n.js b/devtools/shared/gcli/source/lib/gcli/util/l10n.js new file mode 100644 index 000000000..6d0c7c8f4 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/l10n.js @@ -0,0 +1,80 @@ +/* + * 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"; + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/shared/locales/gcli.properties"); + +/* + * Not supported when embedded - we"re doing things the Mozilla way not the + * require.js way. + */ +exports.registerStringsSource = function (modulePath) { + throw new Error("registerStringsSource is not available in mozilla"); +}; + +exports.unregisterStringsSource = function (modulePath) { + throw new Error("unregisterStringsSource is not available in mozilla"); +}; + +exports.lookupSwap = function (key, swaps) { + throw new Error("lookupSwap is not available in mozilla"); +}; + +exports.lookupPlural = function (key, ord, swaps) { + throw new Error("lookupPlural is not available in mozilla"); +}; + +exports.getPreferredLocales = function () { + return [ "root" ]; +}; + +/** @see lookup() in lib/gcli/util/l10n.js */ +exports.lookup = function (key) { + try { + // Our memory leak hunter walks reachable objects trying to work out what + // type of thing they are using object.constructor.name. If that causes + // problems then we can avoid the unknown-key-exception with the following: + /* + if (key === "constructor") { + return { name: "l10n-mem-leak-defeat" }; + } + */ + + return L10N.getStr(key); + } catch (ex) { + console.error("Failed to lookup ", key, ex); + return key; + } +}; + +/** @see propertyLookup in lib/gcli/util/l10n.js */ +exports.propertyLookup = new Proxy({}, { + get: function (rcvr, name) { + return exports.lookup(name); + } +}); + +/** @see lookupFormat in lib/gcli/util/l10n.js */ +exports.lookupFormat = function (key, swaps) { + try { + return L10N.getFormatStr(key, ...swaps); + } catch (ex) { + console.error("Failed to format ", key, ex); + return key; + } +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/legacy.js b/devtools/shared/gcli/source/lib/gcli/util/legacy.js new file mode 100644 index 000000000..07b0fd71a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/legacy.js @@ -0,0 +1,147 @@ +/* + * 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'; + +/** + * Fake a console for IE9 + */ +if (typeof window !== 'undefined' && window.console == null) { + window.console = {}; +} +'debug,log,warn,error,trace,group,groupEnd'.split(',').forEach(function(f) { + if (typeof window !== 'undefined' && !window.console[f]) { + window.console[f] = function() {}; + } +}); + +/** + * Fake Element.classList for IE9 + * Based on https://gist.github.com/1381839 by Devon Govett + */ +if (typeof document !== 'undefined' && typeof HTMLElement !== 'undefined' && + !('classList' in document.documentElement) && Object.defineProperty) { + Object.defineProperty(HTMLElement.prototype, 'classList', { + get: function() { + var self = this; + function update(fn) { + return function(value) { + var classes = self.className.split(/\s+/); + var index = classes.indexOf(value); + fn(classes, index, value); + self.className = classes.join(' '); + }; + } + + var ret = { + add: update(function(classes, index, value) { + ~index || classes.push(value); + }), + remove: update(function(classes, index) { + ~index && classes.splice(index, 1); + }), + toggle: update(function(classes, index, value) { + ~index ? classes.splice(index, 1) : classes.push(value); + }), + contains: function(value) { + return !!~self.className.split(/\s+/).indexOf(value); + }, + item: function(i) { + return self.className.split(/\s+/)[i] || null; + } + }; + + Object.defineProperty(ret, 'length', { + get: function() { + return self.className.split(/\s+/).length; + } + }); + + return ret; + } + }); +} + +/** + * Array.find + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find + */ +if (!Array.prototype.find) { + Object.defineProperty(Array.prototype, 'find', { + enumerable: false, + configurable: true, + writable: true, + value: function(predicate) { + if (this == null) { + throw new TypeError('Array.prototype.find called on null or undefined'); + } + if (typeof predicate !== 'function') { + throw new TypeError('predicate must be a function'); + } + var list = Object(this); + var length = list.length >>> 0; + var thisArg = arguments[1]; + var value; + + for (var i = 0; i < length; i++) { + if (i in list) { + value = list[i]; + if (predicate.call(thisArg, value, i, list)) { + return value; + } + } + } + return undefined; + } + }); +} + +/** + * String.prototype.trimLeft is non-standard, but it works in Firefox, + * Chrome and Opera. It's easiest to create a shim here. + */ +if (!String.prototype.trimLeft) { + String.prototype.trimLeft = function() { + return String(this).replace(/\s*$/, ''); + }; +} + +/** + * Polyfil taken from + * https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind + */ +if (!Function.prototype.bind) { + Function.prototype.bind = function(oThis) { + if (typeof this !== 'function') { + // closest thing possible to the ECMAScript 5 internal IsCallable function + throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable'); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function () {}, + fBound = function () { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + return fBound; + }; +} diff --git a/devtools/shared/gcli/source/lib/gcli/util/moz.build b/devtools/shared/gcli/source/lib/gcli/util/moz.build new file mode 100644 index 000000000..0fdeb96ec --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/moz.build @@ -0,0 +1,17 @@ +# -*- 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( + 'domtemplate.js', + 'fileparser.js', + 'filesystem.js', + 'host.js', + 'l10n.js', + 'legacy.js', + 'prism.js', + 'spell.js', + 'util.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/util/prism.js b/devtools/shared/gcli/source/lib/gcli/util/prism.js new file mode 100644 index 000000000..6f457cf23 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/prism.js @@ -0,0 +1,361 @@ +/** + * Prism: Lightweight, robust, elegant syntax highlighting + * MIT license http://www.opensource.org/licenses/mit-license.php/ + * @author Lea Verou http://lea.verou.me + */ + +'use strict'; + +// Private helper vars +var lang = /\blang(?:uage)?-(?!\*)(\w+)\b/i; + +var Prism = exports.Prism = { + util: { + type: function (o) { + return Object.prototype.toString.call(o).match(/\[object (\w+)\]/)[1]; + }, + + // Deep clone a language definition (e.g. to extend it) + clone: function (o) { + var type = Prism.util.type(o); + + switch (type) { + case 'Object': + var clone = {}; + + for (var key in o) { + if (o.hasOwnProperty(key)) { + clone[key] = Prism.util.clone(o[key]); + } + } + + return clone; + + case 'Array': + return o.slice(); + } + + return o; + } + }, + + languages: { + extend: function (id, redef) { + var lang = Prism.util.clone(Prism.languages[id]); + + for (var key in redef) { + lang[key] = redef[key]; + } + + return lang; + }, + + // Insert a token before another token in a language literal + insertBefore: function (inside, before, insert, root) { + root = root || Prism.languages; + var grammar = root[inside]; + var ret = {}; + + for (var token in grammar) { + + if (grammar.hasOwnProperty(token)) { + + if (token == before) { + + for (var newToken in insert) { + + if (insert.hasOwnProperty(newToken)) { + ret[newToken] = insert[newToken]; + } + } + } + + ret[token] = grammar[token]; + } + } + + root[inside] = ret; + return ret; + }, + + // Traverse a language definition with Depth First Search + DFS: function(o, callback) { + for (var i in o) { + callback.call(o, i, o[i]); + + if (Prism.util.type(o) === 'Object') { + Prism.languages.DFS(o[i], callback); + } + } + } + }, + + highlightAll: function(async, callback) { + var elements = document.querySelectorAll('code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'); + + elements.forEach(function(element) { + Prism.highlightElement(element, async === true, callback); + }); + }, + + highlightElement: function(element, async, callback) { + // Find language + var language; + var grammar; + + var parent = element; + while (parent && !lang.test(parent.className)) { + parent = parent.parentNode; + } + + if (parent) { + language = (parent.className.match(lang) || [,''])[1]; + grammar = Prism.languages[language]; + } + + if (!grammar) { + return; + } + + // Set language on the element, if not present + element.className = element.className.replace(lang, '').replace(/\s+/g, ' ') + ' language-' + language; + + // Set language on the parent, for styling + parent = element.parentNode; + + if (/pre/i.test(parent.nodeName)) { + parent.className = parent.className.replace(lang, '').replace(/\s+/g, ' ') + ' language-' + language; + } + + var code = element.textContent; + + if (!code) { + return; + } + + code = code.replace(/&/g, '&').replace(/</g, '<').replace(/\u00a0/g, ' '); + + var env = { + element: element, + language: language, + grammar: grammar, + code: code + }; + + Prism.hooks.run('before-highlight', env); + + env.highlightedCode = Prism.highlight(env.code, env.grammar, env.language); + + Prism.hooks.run('before-insert', env); + + env.element.innerHTML = env.highlightedCode; + + if (callback) { + callback.call(element); + } + + Prism.hooks.run('after-highlight', env); + }, + + highlight: function (text, grammar, language) { + return Token.stringify(Prism.tokenize(text, grammar), language); + }, + + tokenize: function(text, grammar, language) { + var Token = Prism.Token; + + var strarr = [text]; + + var rest = grammar.rest; + var token; + if (rest) { + for (token in rest) { + grammar[token] = rest[token]; + } + + delete grammar.rest; + } + + tokenloop: + for (token in grammar) { + if (!grammar.hasOwnProperty(token) || !grammar[token]) { + continue; + } + + var pattern = grammar[token], + inside = pattern.inside, + lookbehind = !!pattern.lookbehind, + lookbehindLength = 0; + + pattern = pattern.pattern || pattern; + + for (var i=0; i<strarr.length; i++) { // Donāt cache length as it changes during the loop + + var str = strarr[i]; + + if (strarr.length > text.length) { + // Something went terribly wrong, ABORT, ABORT! + break tokenloop; + } + + if (str instanceof Token) { + continue; + } + + pattern.lastIndex = 0; + + var match = pattern.exec(str); + + if (match) { + if (lookbehind) { + lookbehindLength = match[1].length; + } + + var from = match.index - 1 + lookbehindLength; + match = match[0].slice(lookbehindLength); + var len = match.length; + var to = from + len; + var before = str.slice(0, from + 1); + var after = str.slice(to + 1); + + var args = [i, 1]; + + if (before) { + args.push(before); + } + + var wrapped = new Token(token, inside? Prism.tokenize(match, inside) : match); + + args.push(wrapped); + + if (after) { + args.push(after); + } + + Array.prototype.splice.apply(strarr, args); + } + } + } + + return strarr; + }, + + hooks: { + all: {}, + + add: function (name, callback) { + var hooks = Prism.hooks.all; + + hooks[name] = hooks[name] || []; + + hooks[name].push(callback); + }, + + run: function (name, env) { + var callbacks = Prism.hooks.all[name]; + + if (!callbacks || !callbacks.length) { + return; + } + + callbacks.forEach(function(callback) { + callback(env); + }); + } + } +}; + +var Token = Prism.Token = function(type, content) { + this.type = type; + this.content = content; +}; + +Token.stringify = function(o, language, parent) { + if (typeof o == 'string') { + return o; + } + + if (Object.prototype.toString.call(o) == '[object Array]') { + return o.map(function(element) { + return Token.stringify(element, language, o); + }).join(''); + } + + var env = { + type: o.type, + content: Token.stringify(o.content, language, parent), + tag: 'span', + classes: ['token', o.type], + attributes: {}, + language: language, + parent: parent + }; + + if (env.type == 'comment') { + env.attributes.spellcheck = 'true'; + } + + Prism.hooks.run('wrap', env); + + var attributes = ''; + + for (var name in env.attributes) { + attributes += name + '="' + (env.attributes[name] || '') + '"'; + } + + return '<' + env.tag + ' class="' + env.classes.join(' ') + '" ' + attributes + '>' + env.content + '</' + env.tag + '>'; +}; + +Prism.languages.clike = { + 'comment': { + pattern: /(^|[^\\])(\/\*[\w\W]*?\*\/|(^|[^:])\/\/.*?(\r?\n|$))/g, + lookbehind: true + }, + 'string': /("|')(\\?.)*?\1/g, + 'class-name': { + pattern: /((?:(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/ig, + lookbehind: true, + inside: { + punctuation: /(\.|\\)/ + } + }, + 'keyword': /\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/g, + 'boolean': /\b(true|false)\b/g, + 'function': { + pattern: /[a-z0-9_]+\(/ig, + inside: { + punctuation: /\(/ + } + }, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?)\b/g, + 'operator': /[-+]{1,2}|!|<=?|>=?|={1,3}|(&){1,2}|\|?\||\?|\*|\/|\~|\^|\%/g, + 'ignore': /&(lt|gt|amp);/gi, + 'punctuation': /[{}[\];(),.:]/g +}; + +Prism.languages.javascript = Prism.languages.extend('clike', { + 'keyword': /\b(var|let|if|else|while|do|for|return|in|instanceof|function|new|with|typeof|try|throw|catch|finally|null|break|continue)\b/g, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?|NaN|-?Infinity)\b/g +}); + +Prism.languages.insertBefore('javascript', 'keyword', { + 'regex': { + pattern: /(^|[^/])\/(?!\/)(\[.+?]|\\.|[^/\r\n])+\/[gim]{0,3}(?=\s*($|[\r\n,.;})]))/g, + lookbehind: true + } +}); + +if (Prism.languages.markup) { + Prism.languages.insertBefore('markup', 'tag', { + 'script': { + pattern: /(<|<)script[\w\W]*?(>|>)[\w\W]*?(<|<)\/script(>|>)/ig, + inside: { + 'tag': { + pattern: /(<|<)script[\w\W]*?(>|>)|(<|<)\/script(>|>)/ig, + inside: Prism.languages.markup.tag.inside + }, + rest: Prism.languages.javascript + } + } + }); +} diff --git a/devtools/shared/gcli/source/lib/gcli/util/spell.js b/devtools/shared/gcli/source/lib/gcli/util/spell.js new file mode 100644 index 000000000..f16724f2a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/spell.js @@ -0,0 +1,197 @@ +/* + * 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'; + +/* + * A spell-checker based on Damerau-Levenshtein distance. + */ + +var CASE_CHANGE_COST = 1; +var INSERTION_COST = 10; +var DELETION_COST = 10; +var SWAP_COST = 10; +var SUBSTITUTION_COST = 20; +var MAX_EDIT_DISTANCE = 40; + +/** + * Compute Damerau-Levenshtein Distance, with a modification to allow a low + * case-change cost (1/10th of a swap-cost) + * @see http://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance + */ +var distance = exports.distance = function(wordi, wordj) { + var wordiLen = wordi.length; + var wordjLen = wordj.length; + + // We only need to store three rows of our dynamic programming matrix. + // (Without swap, it would have been two.) + var row0 = new Array(wordiLen+1); + var row1 = new Array(wordiLen+1); + var row2 = new Array(wordiLen+1); + var tmp; + + var i, j; + + // The distance between the empty string and a string of size i is the cost + // of i insertions. + for (i = 0; i <= wordiLen; i++) { + row1[i] = i * INSERTION_COST; + } + + // Row-by-row, we're computing the edit distance between substrings wordi[0..i] + // and wordj[0..j]. + for (j = 1; j <= wordjLen; j++) { + // Edit distance between wordi[0..0] and wordj[0..j] is the cost of j + // insertions. + row0[0] = j * INSERTION_COST; + + for (i = 1; i <= wordiLen; i++) { + // Handle deletion, insertion and substitution: we can reach each cell + // from three other cells corresponding to those three operations. We + // want the minimum cost. + var dc = row0[i - 1] + DELETION_COST; + var ic = row1[i] + INSERTION_COST; + var sc0; + if (wordi[i-1] === wordj[j-1]) { + sc0 = 0; + } + else { + if (wordi[i-1].toLowerCase() === wordj[j-1].toLowerCase()) { + sc0 = CASE_CHANGE_COST; + } + else { + sc0 = SUBSTITUTION_COST; + } + } + var sc = row1[i-1] + sc0; + + row0[i] = Math.min(dc, ic, sc); + + // We handle swap too, eg. distance between help and hlep should be 1. If + // we find such a swap, there's a chance to update row0[1] to be lower. + if (i > 1 && j > 1 && wordi[i-1] === wordj[j-2] && wordj[j-1] === wordi[i-2]) { + row0[i] = Math.min(row0[i], row2[i-2] + SWAP_COST); + } + } + + tmp = row2; + row2 = row1; + row1 = row0; + row0 = tmp; + } + + return row1[wordiLen]; +}; + +/** + * As distance() except that we say that if word is a prefix of name then we + * only count the case changes. This allows us to use words that can be + * completed by typing as more likely than short words + */ +var distancePrefix = exports.distancePrefix = function(word, name) { + var dist = 0; + + for (var i = 0; i < word.length; i++) { + if (name[i] !== word[i]) { + if (name[i].toLowerCase() === word[i].toLowerCase()) { + dist++; + } + else { + // name does not start with word, even ignoring case, use + // Damerau-Levenshtein + return exports.distance(word, name); + } + } + } + + return dist; +}; + +/** + * A function that returns the correction for the specified word. + */ +exports.correct = function(word, names) { + if (names.length === 0) { + return undefined; + } + + var distances = {}; + var sortedCandidates; + + names.forEach(function(candidate) { + distances[candidate] = exports.distance(word, candidate); + }); + + sortedCandidates = names.sort(function(worda, wordb) { + if (distances[worda] !== distances[wordb]) { + return distances[worda] - distances[wordb]; + } + else { + // if the score is the same, always return the first string + // in the lexicographical order + return worda < wordb; + } + }); + + if (distances[sortedCandidates[0]] <= MAX_EDIT_DISTANCE) { + return sortedCandidates[0]; + } + else { + return undefined; + } +}; + +/** + * Return a ranked list of matches: + * + * spell.rank('fred', [ 'banana', 'fred', 'ed', 'red' ]); + * ā + * [ + * { name: 'fred', dist: 0 }, + * { name: 'red', dist: 1 }, + * { name: 'ed', dist: 2 }, + * { name: 'banana', dist: 10 }, + * ] + * + * @param word The string that we're comparing names against + * @param names An array of strings to compare word against + * @param options Comparison options: + * - noSort: Do not sort the output by distance + * - prefixZero: Count prefix matches as edit distance 0 (i.e. word='bana' and + * names=['banana'], would return { name:'banana': dist: 0 }) This is useful + * if someone is typing the matches and may not have finished yet + */ +exports.rank = function(word, names, options) { + options = options || {}; + + var reply = names.map(function(name) { + // If any name starts with the word then the distance is based on the + // number of case changes rather than Damerau-Levenshtein + var algo = options.prefixZero ? distancePrefix : distance; + return { + name: name, + dist: algo(word, name) + }; + }); + + if (!options.noSort) { + reply = reply.sort(function(d1, d2) { + return d1.dist - d2.dist; + }); + } + + return reply; +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/util.js b/devtools/shared/gcli/source/lib/gcli/util/util.js new file mode 100644 index 000000000..065bf36c0 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/util.js @@ -0,0 +1,685 @@ +/* + * 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'; + +/* + * A number of DOM manipulation and event handling utilities. + */ + +//------------------------------------------------------------------------------ + +var eventDebug = false; + +/** + * Patch up broken console API from node + */ +if (eventDebug) { + if (console.group == null) { + console.group = function() { console.log(arguments); }; + } + if (console.groupEnd == null) { + console.groupEnd = function() { console.log(arguments); }; + } +} + +/** + * Useful way to create a name for a handler, used in createEvent() + */ +function nameFunction(handler) { + var scope = handler.scope ? handler.scope.constructor.name + '.' : ''; + var name = handler.func.name; + if (name) { + return scope + name; + } + for (var prop in handler.scope) { + if (handler.scope[prop] === handler.func) { + return scope + prop; + } + } + return scope + handler.func; +} + +/** + * Create an event. + * For use as follows: + * + * function Hat() { + * this.putOn = createEvent('Hat.putOn'); + * ... + * } + * Hat.prototype.adorn = function(person) { + * this.putOn({ hat: hat, person: person }); + * ... + * } + * + * var hat = new Hat(); + * hat.putOn.add(function(ev) { + * console.log('The hat ', ev.hat, ' has is worn by ', ev.person); + * }, scope); + * + * @param name Optional name to help with debugging + */ +exports.createEvent = function(name) { + var handlers = []; + var fireHoldCount = 0; + var heldEvents = []; + var eventCombiner; + + /** + * This is how the event is triggered. + * @param ev The event object to be passed to the event listeners + */ + var event = function(ev) { + if (fireHoldCount > 0) { + heldEvents.push(ev); + if (eventDebug) { + console.log('Held fire: ' + name, ev); + } + return; + } + + if (eventDebug) { + console.group('Fire: ' + name + ' to ' + handlers.length + ' listeners', ev); + } + + // Use for rather than forEach because it step debugs better, which is + // important for debugging events + for (var i = 0; i < handlers.length; i++) { + var handler = handlers[i]; + if (eventDebug) { + console.log(nameFunction(handler)); + } + handler.func.call(handler.scope, ev); + } + + if (eventDebug) { + console.groupEnd(); + } + }; + + /** + * Add a new handler function + * @param func The function to call when this event is triggered + * @param scope Optional 'this' object for the function call + */ + event.add = function(func, scope) { + if (typeof func !== 'function') { + throw new Error(name + ' add(func,...), 1st param is ' + typeof func); + } + + if (eventDebug) { + console.log('Adding listener to ' + name); + } + + handlers.push({ func: func, scope: scope }); + }; + + /** + * Remove a handler function added through add(). Both func and scope must + * be strict equals (===) the values used in the call to add() + * @param func The function to call when this event is triggered + * @param scope Optional 'this' object for the function call + */ + event.remove = function(func, scope) { + if (eventDebug) { + console.log('Removing listener from ' + name); + } + + var found = false; + handlers = handlers.filter(function(test) { + var match = (test.func === func && test.scope === scope); + if (match) { + found = true; + } + return !match; + }); + if (!found) { + console.warn('Handler not found. Attached to ' + name); + } + }; + + /** + * Remove all handlers. + * Reset the state of this event back to it's post create state + */ + event.removeAll = function() { + handlers = []; + }; + + /** + * Fire an event just once using a promise. + */ + event.once = function() { + if (arguments.length !== 0) { + throw new Error('event.once uses promise return values'); + } + + return new Promise(function(resolve, reject) { + var handler = function(arg) { + event.remove(handler); + resolve(arg); + }; + + event.add(handler); + }); + }; + + /** + * Temporarily prevent this event from firing. + * @see resumeFire(ev) + */ + event.holdFire = function() { + if (eventDebug) { + console.group('Holding fire: ' + name); + } + + fireHoldCount++; + }; + + /** + * Resume firing events. + * If there are heldEvents, then we fire one event to cover them all. If an + * event combining function has been provided then we use that to combine the + * events. Otherwise the last held event is used. + * @see holdFire() + */ + event.resumeFire = function() { + if (eventDebug) { + console.groupEnd('Resume fire: ' + name); + } + + if (fireHoldCount === 0) { + throw new Error('fireHoldCount === 0 during resumeFire on ' + name); + } + + fireHoldCount--; + if (heldEvents.length === 0) { + return; + } + + if (heldEvents.length === 1) { + event(heldEvents[0]); + } + else { + var first = heldEvents[0]; + var last = heldEvents[heldEvents.length - 1]; + if (eventCombiner) { + event(eventCombiner(first, last, heldEvents)); + } + else { + event(last); + } + } + + heldEvents = []; + }; + + /** + * When resumeFire has a number of events to combine, by default it just + * picks the last, however you can provide an eventCombiner which returns a + * combined event. + * eventCombiners will be passed 3 parameters: + * - first The first event to be held + * - last The last event to be held + * - all An array containing all the held events + * The return value from an eventCombiner is expected to be an event object + */ + Object.defineProperty(event, 'eventCombiner', { + set: function(newEventCombiner) { + if (typeof newEventCombiner !== 'function') { + throw new Error('eventCombiner is not a function'); + } + eventCombiner = newEventCombiner; + }, + + enumerable: true + }); + + return event; +}; + +//------------------------------------------------------------------------------ + +/** + * promiseEach is roughly like Array.forEach except that the action is taken to + * be something that completes asynchronously, returning a promise, so we wait + * for the action to complete for each array element before moving onto the + * next. + * @param array An array of objects to enumerate + * @param action A function to call for each member of the array + * @param scope Optional object to use as 'this' for the function calls + * @return A promise which is resolved (with an array of resolution values) + * when all the array members have been passed to the action function, and + * rejected as soon as any of the action function calls failsĀ + */ +exports.promiseEach = function(array, action, scope) { + if (array.length === 0) { + return Promise.resolve([]); + } + + var allReply = []; + var promise = Promise.resolve(); + + array.forEach(function(member, i) { + promise = promise.then(function() { + var reply = action.call(scope, member, i, array); + return Promise.resolve(reply).then(function(data) { + allReply[i] = data; + }); + }); + }); + + return promise.then(function() { + return allReply; + }); +}; + +/** + * Catching errors from promises isn't as simple as: + * promise.then(handler, console.error); + * for a number of reasons: + * - chrome's console doesn't have bound functions (why?) + * - we don't get stack traces out from console.error(ex); + */ +exports.errorHandler = function(ex) { + if (ex instanceof Error) { + // V8 weirdly includes the exception message in the stack + if (ex.stack.indexOf(ex.message) !== -1) { + console.error(ex.stack); + } + else { + console.error('' + ex); + console.error(ex.stack); + } + } + else { + console.error(ex); + } +}; + + +//------------------------------------------------------------------------------ + +/** + * Copy the properties from one object to another in a way that preserves + * function properties as functions rather than copying the calculated value + * as copy time + */ +exports.copyProperties = function(src, dest) { + for (var key in src) { + var descriptor; + var obj = src; + while (true) { + descriptor = Object.getOwnPropertyDescriptor(obj, key); + if (descriptor != null) { + break; + } + obj = Object.getPrototypeOf(obj); + if (obj == null) { + throw new Error('Can\'t find descriptor of ' + key); + } + } + + if ('value' in descriptor) { + dest[key] = src[key]; + } + else if ('get' in descriptor) { + Object.defineProperty(dest, key, { + get: descriptor.get, + set: descriptor.set, + enumerable: descriptor.enumerable + }); + } + else { + throw new Error('Don\'t know how to copy ' + key + ' property.'); + } + } +}; + +//------------------------------------------------------------------------------ + +/** + * XHTML namespace + */ +exports.NS_XHTML = 'http://www.w3.org/1999/xhtml'; + +/** + * XUL namespace + */ +exports.NS_XUL = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; + +/** + * Create an HTML or XHTML element depending on whether the document is HTML + * or XML based. Where HTML/XHTML elements are distinguished by whether they + * are created using doc.createElementNS('http://www.w3.org/1999/xhtml', tag) + * or doc.createElement(tag) + * If you want to create a XUL element then you don't have a problem knowing + * what namespace you want. + * @param doc The document in which to create the element + * @param tag The name of the tag to create + * @returns The created element + */ +exports.createElement = function(doc, tag) { + if (exports.isXmlDocument(doc)) { + return doc.createElementNS(exports.NS_XHTML, tag); + } + else { + return doc.createElement(tag); + } +}; + +/** + * Remove all the child nodes from this node + * @param elem The element that should have it's children removed + */ +exports.clearElement = function(elem) { + while (elem.hasChildNodes()) { + elem.removeChild(elem.firstChild); + } +}; + +var isAllWhitespace = /^\s*$/; + +/** + * Iterate over the children of a node looking for TextNodes that have only + * whitespace content and remove them. + * This utility is helpful when you have a template which contains whitespace + * so it looks nice, but where the whitespace interferes with the rendering of + * the page + * @param elem The element which should have blank whitespace trimmed + * @param deep Should this node removal include child elements + */ +exports.removeWhitespace = function(elem, deep) { + var i = 0; + while (i < elem.childNodes.length) { + var child = elem.childNodes.item(i); + if (child.nodeType === 3 /*Node.TEXT_NODE*/ && + isAllWhitespace.test(child.textContent)) { + elem.removeChild(child); + } + else { + if (deep && child.nodeType === 1 /*Node.ELEMENT_NODE*/) { + exports.removeWhitespace(child, deep); + } + i++; + } + } +}; + +/** + * Create a style element in the document head, and add the given CSS text to + * it. + * @param cssText The CSS declarations to append + * @param doc The document element to work from + * @param id Optional id to assign to the created style tag. If the id already + * exists on the document, we do not add the CSS again. + */ +exports.importCss = function(cssText, doc, id) { + if (!cssText) { + return undefined; + } + + doc = doc || document; + + if (!id) { + id = 'hash-' + hash(cssText); + } + + var found = doc.getElementById(id); + if (found) { + if (found.tagName.toLowerCase() !== 'style') { + console.error('Warning: importCss passed id=' + id + + ', but that pre-exists (and isn\'t a style tag)'); + } + return found; + } + + var style = exports.createElement(doc, 'style'); + style.id = id; + style.appendChild(doc.createTextNode(cssText)); + + var head = doc.getElementsByTagName('head')[0] || doc.documentElement; + head.appendChild(style); + + return style; +}; + +/** + * Simple hash function which happens to match Java's |String.hashCode()| + * Done like this because I we don't need crypto-security, but do need speed, + * and I don't want to spend a long time working on it. + * @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ + */ +function hash(str) { + var h = 0; + if (str.length === 0) { + return h; + } + for (var i = 0; i < str.length; i++) { + var character = str.charCodeAt(i); + h = ((h << 5) - h) + character; + h = h & h; // Convert to 32bit integer + } + return h; +} + +/** + * Shortcut for clearElement/createTextNode/appendChild to make up for the lack + * of standards around textContent/innerText + */ +exports.setTextContent = function(elem, text) { + exports.clearElement(elem); + var child = elem.ownerDocument.createTextNode(text); + elem.appendChild(child); +}; + +/** + * There are problems with innerHTML on XML documents, so we need to do a dance + * using document.createRange().createContextualFragment() when in XML mode + */ +exports.setContents = function(elem, contents) { + if (typeof HTMLElement !== 'undefined' && contents instanceof HTMLElement) { + exports.clearElement(elem); + elem.appendChild(contents); + return; + } + + if ('innerHTML' in elem) { + elem.innerHTML = contents; + } + else { + try { + var ns = elem.ownerDocument.documentElement.namespaceURI; + if (!ns) { + ns = exports.NS_XHTML; + } + exports.clearElement(elem); + contents = '<div xmlns="' + ns + '">' + contents + '</div>'; + var range = elem.ownerDocument.createRange(); + var child = range.createContextualFragment(contents).firstChild; + while (child.hasChildNodes()) { + elem.appendChild(child.firstChild); + } + } + catch (ex) { + console.error('Bad XHTML', ex); + console.trace(); + throw ex; + } + } +}; + +/** + * How to detect if we're in an XML document. + * In a Mozilla we check that document.xmlVersion = null, however in Chrome + * we use document.contentType = undefined. + * @param doc The document element to work from (defaulted to the global + * 'document' if missing + */ +exports.isXmlDocument = function(doc) { + doc = doc || document; + // Best test for Firefox + if (doc.contentType && doc.contentType != 'text/html') { + return true; + } + // Best test for Chrome + if (doc.xmlVersion != null) { + return true; + } + return false; +}; + +/** + * We'd really like to be able to do 'new NodeList()' + */ +exports.createEmptyNodeList = function(doc) { + if (doc.createDocumentFragment) { + return doc.createDocumentFragment().childNodes; + } + return doc.querySelectorAll('x>:root'); +}; + +//------------------------------------------------------------------------------ + +/** + * Keyboard handling is a mess. http://unixpapa.com/js/key.html + * It would be good to use DOM L3 Keyboard events, + * http://www.w3.org/TR/2010/WD-DOM-Level-3-Events-20100907/#events-keyboardevents + * however only Webkit supports them, and there isn't a shim on Modernizr: + * https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-browser-Polyfills + * and when the code that uses this KeyEvent was written, nothing was clear, + * so instead, we're using this unmodern shim: + * http://stackoverflow.com/questions/5681146/chrome-10-keyevent-or-something-similar-to-firefoxs-keyevent + * See BUG 664991: GCLI's keyboard handling should be updated to use DOM-L3 + * https://bugzilla.mozilla.org/show_bug.cgi?id=664991 + */ +exports.KeyEvent = { + DOM_VK_CANCEL: 3, + DOM_VK_HELP: 6, + DOM_VK_BACK_SPACE: 8, + DOM_VK_TAB: 9, + DOM_VK_CLEAR: 12, + DOM_VK_RETURN: 13, + DOM_VK_SHIFT: 16, + DOM_VK_CONTROL: 17, + DOM_VK_ALT: 18, + DOM_VK_PAUSE: 19, + DOM_VK_CAPS_LOCK: 20, + DOM_VK_ESCAPE: 27, + DOM_VK_SPACE: 32, + DOM_VK_PAGE_UP: 33, + DOM_VK_PAGE_DOWN: 34, + DOM_VK_END: 35, + DOM_VK_HOME: 36, + DOM_VK_LEFT: 37, + DOM_VK_UP: 38, + DOM_VK_RIGHT: 39, + DOM_VK_DOWN: 40, + DOM_VK_PRINTSCREEN: 44, + DOM_VK_INSERT: 45, + DOM_VK_DELETE: 46, + DOM_VK_0: 48, + DOM_VK_1: 49, + DOM_VK_2: 50, + DOM_VK_3: 51, + DOM_VK_4: 52, + DOM_VK_5: 53, + DOM_VK_6: 54, + DOM_VK_7: 55, + DOM_VK_8: 56, + DOM_VK_9: 57, + DOM_VK_SEMICOLON: 59, + DOM_VK_EQUALS: 61, + DOM_VK_A: 65, + DOM_VK_B: 66, + DOM_VK_C: 67, + DOM_VK_D: 68, + DOM_VK_E: 69, + DOM_VK_F: 70, + DOM_VK_G: 71, + DOM_VK_H: 72, + DOM_VK_I: 73, + DOM_VK_J: 74, + DOM_VK_K: 75, + DOM_VK_L: 76, + DOM_VK_M: 77, + DOM_VK_N: 78, + DOM_VK_O: 79, + DOM_VK_P: 80, + DOM_VK_Q: 81, + DOM_VK_R: 82, + DOM_VK_S: 83, + DOM_VK_T: 84, + DOM_VK_U: 85, + DOM_VK_V: 86, + DOM_VK_W: 87, + DOM_VK_X: 88, + DOM_VK_Y: 89, + DOM_VK_Z: 90, + DOM_VK_CONTEXT_MENU: 93, + DOM_VK_NUMPAD0: 96, + DOM_VK_NUMPAD1: 97, + DOM_VK_NUMPAD2: 98, + DOM_VK_NUMPAD3: 99, + DOM_VK_NUMPAD4: 100, + DOM_VK_NUMPAD5: 101, + DOM_VK_NUMPAD6: 102, + DOM_VK_NUMPAD7: 103, + DOM_VK_NUMPAD8: 104, + DOM_VK_NUMPAD9: 105, + DOM_VK_MULTIPLY: 106, + DOM_VK_ADD: 107, + DOM_VK_SEPARATOR: 108, + DOM_VK_SUBTRACT: 109, + DOM_VK_DECIMAL: 110, + DOM_VK_DIVIDE: 111, + DOM_VK_F1: 112, + DOM_VK_F2: 113, + DOM_VK_F3: 114, + DOM_VK_F4: 115, + DOM_VK_F5: 116, + DOM_VK_F6: 117, + DOM_VK_F7: 118, + DOM_VK_F8: 119, + DOM_VK_F9: 120, + DOM_VK_F10: 121, + DOM_VK_F11: 122, + DOM_VK_F12: 123, + DOM_VK_F13: 124, + DOM_VK_F14: 125, + DOM_VK_F15: 126, + DOM_VK_F16: 127, + DOM_VK_F17: 128, + DOM_VK_F18: 129, + DOM_VK_F19: 130, + DOM_VK_F20: 131, + DOM_VK_F21: 132, + DOM_VK_F22: 133, + DOM_VK_F23: 134, + DOM_VK_F24: 135, + DOM_VK_NUM_LOCK: 144, + DOM_VK_SCROLL_LOCK: 145, + DOM_VK_COMMA: 188, + DOM_VK_PERIOD: 190, + DOM_VK_SLASH: 191, + DOM_VK_BACK_QUOTE: 192, + DOM_VK_OPEN_BRACKET: 219, + DOM_VK_BACK_SLASH: 220, + DOM_VK_CLOSE_BRACKET: 221, + DOM_VK_QUOTE: 222, + DOM_VK_META: 224 +}; diff --git a/devtools/shared/gcli/templater.js b/devtools/shared/gcli/templater.js new file mode 100644 index 000000000..c5e01b6cf --- /dev/null +++ b/devtools/shared/gcli/templater.js @@ -0,0 +1,602 @@ +/* + * 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"; + +/* globals document */ + +/** + * For full documentation, see: + * https://github.com/mozilla/domtemplate/blob/master/README.md + */ + +/** + * Begin a new templating process. + * @param node A DOM element or string referring to an element's id + * @param data Data to use in filling out the template + * @param options Options to customize the template processing. One of: + * - allowEval: boolean (default false) Basic template interpolations are + * either property paths (e.g. ${a.b.c.d}), or if allowEval=true then we + * allow arbitrary JavaScript + * - stack: string or array of strings (default empty array) The template + * engine maintains a stack of tasks to help debug where it is. This allows + * this stack to be prefixed with a template name + * - blankNullUndefined: By default DOMTemplate exports null and undefined + * values using the strings 'null' and 'undefined', which can be helpful for + * debugging, but can introduce unnecessary extra logic in a template to + * convert null/undefined to ''. By setting blankNullUndefined:true, this + * conversion is handled by DOMTemplate + */ +var template = function (node, data, options) { + let state = { + options: options || {}, + // We keep a track of the nodes that we've passed through so we can keep + // data.__element pointing to the correct node + nodes: [] + }; + + state.stack = state.options.stack; + + if (!Array.isArray(state.stack)) { + if (typeof state.stack === "string") { + state.stack = [ options.stack ]; + } else { + state.stack = []; + } + } + + processNode(state, node, data); +}; + +if (typeof exports !== "undefined") { + exports.template = template; +} +this.template = template; + +/** + * Helper for the places where we need to act asynchronously and keep track of + * where we are right now + */ +function cloneState(state) { + return { + options: state.options, + stack: state.stack.slice(), + nodes: state.nodes.slice() + }; +} + +/** + * Regex used to find ${...} sections in some text. + * Performance note: This regex uses ( and ) to capture the 'script' for + * further processing. Not all of the uses of this regex use this feature so + * if use of the capturing group is a performance drain then we should split + * this regex in two. + */ +var TEMPLATE_REGION = /\$\{([^}]*)\}/g; + +/** + * Recursive function to walk the tree processing the attributes as it goes. + * @param node the node to process. If you pass a string in instead of a DOM + * element, it is assumed to be an id for use with document.getElementById() + * @param data the data to use for node processing. + */ +function processNode(state, node, data) { + if (typeof node === "string") { + node = document.getElementById(node); + } + if (data == null) { + data = {}; + } + state.stack.push(node.nodeName + (node.id ? "#" + node.id : "")); + let pushedNode = false; + try { + // Process attributes + if (node.attributes && node.attributes.length) { + // We need to handle 'foreach' and 'if' first because they might stop + // some types of processing from happening, and foreach must come first + // because it defines new data on which 'if' might depend. + if (node.hasAttribute("foreach")) { + processForEach(state, node, data); + return; + } + if (node.hasAttribute("if")) { + if (!processIf(state, node, data)) { + return; + } + } + // Only make the node available once we know it's not going away + state.nodes.push(data.__element); + data.__element = node; + pushedNode = true; + // It's good to clean up the attributes when we've processed them, + // but if we do it straight away, we mess up the array index + let attrs = Array.prototype.slice.call(node.attributes); + for (let i = 0; i < attrs.length; i++) { + let value = attrs[i].value; + let name = attrs[i].name; + + state.stack.push(name); + try { + if (name === "save") { + // Save attributes are a setter using the node + value = stripBraces(state, value); + property(state, value, data, node); + node.removeAttribute("save"); + } else if (name.substring(0, 2) === "on") { + // If this attribute value contains only an expression + if (value.substring(0, 2) === "${" && value.slice(-1) === "}" && + value.indexOf("${", 2) === -1) { + value = stripBraces(state, value); + let func = property(state, value, data); + if (typeof func === "function") { + node.removeAttribute(name); + let capture = node.hasAttribute("capture" + name.substring(2)); + node.addEventListener(name.substring(2), func, capture); + if (capture) { + node.removeAttribute("capture" + name.substring(2)); + } + } else { + // Attribute value is not a function - use as a DOM-L0 string + node.setAttribute(name, func); + } + } else { + // Attribute value is not a single expression use as DOM-L0 + node.setAttribute(name, processString(state, value, data)); + } + } else { + node.removeAttribute(name); + // Remove '_' prefix of attribute names so the DOM won't try + // to use them before we've processed the template + if (name.charAt(0) === "_") { + name = name.substring(1); + } + + // Async attributes can only work if the whole attribute is async + let replacement; + if (value.indexOf("${") === 0 && + value.charAt(value.length - 1) === "}") { + replacement = envEval(state, value.slice(2, -1), data, value); + if (replacement && typeof replacement.then === "function") { + node.setAttribute(name, ""); + /* jshint loopfunc:true */ + replacement.then(function (newValue) { + node.setAttribute(name, newValue); + }).then(null, console.error); + } else { + if (state.options.blankNullUndefined && replacement == null) { + replacement = ""; + } + node.setAttribute(name, replacement); + } + } else { + node.setAttribute(name, processString(state, value, data)); + } + } + } finally { + state.stack.pop(); + } + } + } + + // Loop through our children calling processNode. First clone them, so the + // set of nodes that we visit will be unaffected by additions or removals. + let childNodes = Array.prototype.slice.call(node.childNodes); + for (let j = 0; j < childNodes.length; j++) { + processNode(state, childNodes[j], data); + } + + /* 3 === Node.TEXT_NODE */ + if (node.nodeType === 3) { + processTextNode(state, node, data); + } + } finally { + if (pushedNode) { + data.__element = state.nodes.pop(); + } + state.stack.pop(); + } +} + +/** + * Handle attribute values where the output can only be a string + */ +function processString(state, value, data) { + return value.replace(TEMPLATE_REGION, function (path) { + let insert = envEval(state, path.slice(2, -1), data, value); + return state.options.blankNullUndefined && insert == null ? "" : insert; + }); +} + +/** + * Handle <x if="${...}"> + * @param node An element with an 'if' attribute + * @param data The data to use with envEval() + * @returns true if processing should continue, false otherwise + */ +function processIf(state, node, data) { + state.stack.push("if"); + try { + let originalValue = node.getAttribute("if"); + let value = stripBraces(state, originalValue); + let recurse = true; + try { + let reply = envEval(state, value, data, originalValue); + recurse = !!reply; + } catch (ex) { + handleError(state, "Error with '" + value + "'", ex); + recurse = false; + } + if (!recurse) { + node.parentNode.removeChild(node); + } + node.removeAttribute("if"); + return recurse; + } finally { + state.stack.pop(); + } +} + +/** + * Handle <x foreach="param in ${array}"> and the special case of + * <loop foreach="param in ${array}">. + * This function is responsible for extracting what it has to do from the + * attributes, and getting the data to work on (including resolving promises + * in getting the array). It delegates to processForEachLoop to actually + * unroll the data. + * @param node An element with a 'foreach' attribute + * @param data The data to use with envEval() + */ +function processForEach(state, node, data) { + state.stack.push("foreach"); + try { + let originalValue = node.getAttribute("foreach"); + let value = originalValue; + + let paramName = "param"; + if (value.charAt(0) === "$") { + // No custom loop variable name. Use the default: 'param' + value = stripBraces(state, value); + } else { + // Extract the loop variable name from 'NAME in ${ARRAY}' + let nameArr = value.split(" in "); + paramName = nameArr[0].trim(); + value = stripBraces(state, nameArr[1].trim()); + } + node.removeAttribute("foreach"); + try { + let evaled = envEval(state, value, data, originalValue); + let cState = cloneState(state); + handleAsync(evaled, node, function (reply, siblingNode) { + processForEachLoop(cState, reply, node, siblingNode, data, paramName); + }); + node.parentNode.removeChild(node); + } catch (ex) { + handleError(state, "Error with " + value + "'", ex); + } + } finally { + state.stack.pop(); + } +} + +/** + * Called by processForEach to handle looping over the data in a foreach loop. + * This works with both arrays and objects. + * Calls processForEachMember() for each member of 'set' + * @param set The object containing the data to loop over + * @param templNode The node to copy for each set member + * @param sibling The sibling node to which we add things + * @param data the data to use for node processing + * @param paramName foreach loops have a name for the parameter currently being + * processed. The default is 'param'. e.g. <loop foreach="param in ${x}">... + */ +function processForEachLoop(state, set, templNode, sibling, data, paramName) { + if (Array.isArray(set)) { + set.forEach(function (member, i) { + processForEachMember(state, member, templNode, sibling, + data, paramName, "" + i); + }); + } else { + for (let member in set) { + if (set.hasOwnProperty(member)) { + processForEachMember(state, member, templNode, sibling, + data, paramName, member); + } + } + } +} + +/** + * Called by processForEachLoop() to resolve any promises in the array (the + * array itself can also be a promise, but that is resolved by + * processForEach()). Handle <LOOP> elements (which are taken out of the DOM), + * clone the template node, and pass the processing on to processNode(). + * @param member The data item to use in templating + * @param templNode The node to copy for each set member + * @param siblingNode The parent node to which we add things + * @param data the data to use for node processing + * @param paramName The name given to 'member' by the foreach attribute + * @param frame A name to push on the stack for debugging + */ +function processForEachMember(state, member, templNode, siblingNode, data, + paramName, frame) { + state.stack.push(frame); + try { + let cState = cloneState(state); + handleAsync(member, siblingNode, function (reply, node) { + // Clone data because we can't be sure that we can safely mutate it + let newData = Object.create(null); + Object.keys(data).forEach(function (key) { + newData[key] = data[key]; + }); + newData[paramName] = reply; + if (node.parentNode != null) { + let clone; + if (templNode.nodeName.toLowerCase() === "loop") { + for (let i = 0; i < templNode.childNodes.length; i++) { + clone = templNode.childNodes[i].cloneNode(true); + node.parentNode.insertBefore(clone, node); + processNode(cState, clone, newData); + } + } else { + clone = templNode.cloneNode(true); + clone.removeAttribute("foreach"); + node.parentNode.insertBefore(clone, node); + processNode(cState, clone, newData); + } + } + }); + } finally { + state.stack.pop(); + } +} + +/** + * Take a text node and replace it with another text node with the ${...} + * sections parsed out. We replace the node by altering node.parentNode but + * we could probably use a DOM Text API to achieve the same thing. + * @param node The Text node to work on + * @param data The data to use in calls to envEval() + */ +function processTextNode(state, node, data) { + // Replace references in other attributes + let value = node.data; + // We can't use the string.replace() with function trick (see generic + // attribute processing in processNode()) because we need to support + // functions that return DOM nodes, so we can't have the conversion to a + // string. + // Instead we process the string as an array of parts. In order to split + // the string up, we first replace '${' with '\uF001$' and '}' with '\uF002' + // We can then split using \uF001 or \uF002 to get an array of strings + // where scripts are prefixed with $. + // \uF001 and \uF002 are just unicode chars reserved for private use. + value = value.replace(TEMPLATE_REGION, "\uF001$$$1\uF002"); + // Split a string using the unicode chars F001 and F002. + let parts = value.split(/\uF001|\uF002/); + if (parts.length > 1) { + parts.forEach(function (part) { + if (part === null || part === undefined || part === "") { + return; + } + if (part.charAt(0) === "$") { + part = envEval(state, part.slice(1), data, node.data); + } + let cState = cloneState(state); + handleAsync(part, node, function (reply, siblingNode) { + let doc = siblingNode.ownerDocument; + if (reply == null) { + reply = cState.options.blankNullUndefined ? "" : "" + reply; + } + if (typeof reply.cloneNode === "function") { + // i.e. if (reply instanceof Element) { ... + reply = maybeImportNode(cState, reply, doc); + siblingNode.parentNode.insertBefore(reply, siblingNode); + } else if (typeof reply.item === "function" && reply.length) { + // NodeLists can be live, in which case maybeImportNode can + // remove them from the document, and thus the NodeList, which in + // turn breaks iteration. So first we clone the list + let list = Array.prototype.slice.call(reply, 0); + list.forEach(function (child) { + let imported = maybeImportNode(cState, child, doc); + siblingNode.parentNode.insertBefore(imported, siblingNode); + }); + } else { + // if thing isn't a DOM element then wrap its string value in one + reply = doc.createTextNode(reply.toString()); + siblingNode.parentNode.insertBefore(reply, siblingNode); + } + }); + }); + node.parentNode.removeChild(node); + } +} + +/** + * Return node or a import of node, if it's not in the given document + * @param node The node that we want to be properly owned + * @param doc The document that the given node should belong to + * @return A node that belongs to the given document + */ +function maybeImportNode(state, node, doc) { + return node.ownerDocument === doc ? node : doc.importNode(node, true); +} + +/** + * A function to handle the fact that some nodes can be promises, so we check + * and resolve if needed using a marker node to keep our place before calling + * an inserter function. + * @param thing The object which could be real data or a promise of real data + * we use it directly if it's not a promise, or resolve it if it is. + * @param siblingNode The element before which we insert new elements. + * @param inserter The function to to the insertion. If thing is not a promise + * then handleAsync() is just 'inserter(thing, siblingNode)' + */ +function handleAsync(thing, siblingNode, inserter) { + if (thing != null && typeof thing.then === "function") { + // Placeholder element to be replaced once we have the real data + let tempNode = siblingNode.ownerDocument.createElement("span"); + siblingNode.parentNode.insertBefore(tempNode, siblingNode); + thing.then(function (delayed) { + inserter(delayed, tempNode); + if (tempNode.parentNode != null) { + tempNode.parentNode.removeChild(tempNode); + } + }).then(null, function (error) { + console.error(error.stack); + }); + } else { + inserter(thing, siblingNode); + } +} + +/** + * Warn of string does not begin '${' and end '}' + * @param str the string to check. + * @return The string stripped of ${ and }, or untouched if it does not match + */ +function stripBraces(state, str) { + if (!str.match(TEMPLATE_REGION)) { + handleError(state, "Expected " + str + " to match ${...}"); + return str; + } + return str.slice(2, -1); +} + +/** + * Combined getter and setter that works with a path through some data set. + * For example: + * <ul> + * <li>property(state, 'a.b', { a: { b: 99 }}); // returns 99 + * <li>property(state, 'a', { a: { b: 99 }}); // returns { b: 99 } + * <li>property(state, 'a', { a: { b: 99 }}, 42); // returns 99 and alters the + * input data to be { a: { b: 42 }} + * </ul> + * @param path An array of strings indicating the path through the data, or + * a string to be cut into an array using <tt>split('.')</tt> + * @param data the data to use for node processing + * @param newValue (optional) If defined, this value will replace the + * original value for the data at the path specified. + * @return The value pointed to by <tt>path</tt> before any + * <tt>newValue</tt> is applied. + */ +function property(state, path, data, newValue) { + try { + if (typeof path === "string") { + path = path.split("."); + } + let value = data[path[0]]; + if (path.length === 1) { + if (newValue !== undefined) { + data[path[0]] = newValue; + } + if (typeof value === "function") { + return value.bind(data); + } + return value; + } + if (!value) { + handleError(state, "\"" + path[0] + "\" is undefined"); + return null; + } + return property(state, path.slice(1), value, newValue); + } catch (ex) { + handleError(state, "Path error with '" + path + "'", ex); + return "${" + path + "}"; + } +} + +/** + * Like eval, but that creates a context of the variables in <tt>env</tt> in + * which the script is evaluated. + * @param script The string to be evaluated. + * @param data The environment in which to eval the script. + * @param frame Optional debugging string in case of failure. + * @return The return value of the script, or the error message if the script + * execution failed. + */ +function envEval(state, script, data, frame) { + try { + state.stack.push(frame.replace(/\s+/g, " ")); + // Detect if a script is capable of being interpreted using property() + if (/^[_a-zA-Z0-9.]*$/.test(script)) { + return property(state, script, data); + } + if (!state.options.allowEval) { + handleError(state, "allowEval is not set, however '" + script + "'" + + " can not be resolved using a simple property path."); + return "${" + script + "}"; + } + + // What we're looking to do is basically: + // with(data) { return eval(script); } + // except in strict mode where 'with' is banned. + // So we create a function which has a parameter list the same as the + // keys in 'data' and with 'script' as its function body. + // We then call this function with the values in 'data' + let keys = allKeys(data); + let func = Function.apply(null, keys.concat("return " + script)); + + let values = keys.map((key) => data[key]); + return func.apply(null, values); + + // TODO: The 'with' method is different from the code above in the value + // of 'this' when calling functions. For example: + // envEval(state, 'foo()', { foo: function () { return this; } }, ...); + // The global for 'foo' when using 'with' is the data object. However the + // code above, the global is null. (Using 'func.apply(data, values)' + // changes 'this' in the 'foo()' frame, but not in the inside the body + // of 'foo', so that wouldn't help) + } catch (ex) { + handleError(state, "Template error evaluating '" + script + "'", ex); + return "${" + script + "}"; + } finally { + state.stack.pop(); + } +} + +/** + * Object.keys() that respects the prototype chain + */ +function allKeys(data) { + let keys = []; + for (let key in data) { + keys.push(key); + } + return keys; +} + +/** + * A generic way of reporting errors, for easy overloading in different + * environments. + * @param message the error message to report. + * @param ex optional associated exception. + */ +function handleError(state, message, ex) { + logError(message + " (In: " + state.stack.join(" > ") + ")"); + if (ex) { + logError(ex); + } +} + +/** + * A generic way of reporting errors, for easy overloading in different + * environments. + * @param message the error message to report. + */ +function logError(message) { + console.error(message); +} + +exports.template = template; |