diff options
Diffstat (limited to 'devtools/client/projecteditor')
67 files changed, 6082 insertions, 0 deletions
diff --git a/devtools/client/projecteditor/chrome/content/projecteditor-loader.js b/devtools/client/projecteditor/chrome/content/projecteditor-loader.js new file mode 100644 index 000000000..adee8f143 --- /dev/null +++ b/devtools/client/projecteditor/chrome/content/projecteditor-loader.js @@ -0,0 +1,176 @@ +var Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {}); +const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {}); +const promise = require("promise"); +const ProjectEditor = require("devtools/client/projecteditor/lib/projecteditor"); + +const SAMPLE_PATH = buildTempDirectoryStructure(); +const SAMPLE_NAME = "DevTools Content Application Name"; +const SAMPLE_PROJECT_URL = "data:text/html;charset=utf-8,<body><h1>Project Overview</h1></body>"; +const SAMPLE_ICON = "chrome://devtools/skin/images/tool-debugger.svg"; + +/** + * Create a workspace for working on projecteditor, available at + * chrome://devtools/content/projecteditor/chrome/content/projecteditor-loader.xul. + * This emulates the integration points that the app manager uses. + */ +var appManagerEditor; + +// Log a message to the project overview URL to make development easier +function log(msg) { + if (!appManagerEditor) { + return; + } + + let doc = appManagerEditor.iframe.contentDocument; + let el = doc.createElement("p"); + el.textContent = msg; + doc.body.appendChild(el); +} + +document.addEventListener("DOMContentLoaded", function onDOMReady(e) { + document.removeEventListener("DOMContentLoaded", onDOMReady, false); + let iframe = document.getElementById("projecteditor-iframe"); + window.projecteditor = ProjectEditor.ProjectEditor(iframe); + + projecteditor.on("onEditorCreated", (editor, a) => { + log("editor created: " + editor); + if (editor.label === "app-manager") { + appManagerEditor = editor; + appManagerEditor.on("load", function foo() { + appManagerEditor.off("load", foo); + log("Working on: " + SAMPLE_PATH); + }); + } + }); + projecteditor.on("onEditorDestroyed", (editor) => { + log("editor destroyed: " + editor); + }); + projecteditor.on("onEditorSave", (editor, resource) => { + log("editor saved: " + editor, resource.path); + }); + projecteditor.on("onTreeSelected", (resource) => { + log("tree selected: " + resource.path); + }); + projecteditor.on("onEditorLoad", (editor) => { + log("editor loaded: " + editor); + }); + projecteditor.on("onEditorActivated", (editor) => { + log("editor focused: " + editor); + }); + projecteditor.on("onEditorDeactivated", (editor) => { + log("editor blur: " + editor); + }); + projecteditor.on("onEditorChange", (editor) => { + log("editor changed: " + editor); + }); + projecteditor.on("onCommand", (cmd) => { + log("Command: " + cmd); + }); + + projecteditor.loaded.then(() => { + projecteditor.setProjectToAppPath(SAMPLE_PATH, { + name: SAMPLE_NAME, + iconUrl: SAMPLE_ICON, + projectOverviewURL: SAMPLE_PROJECT_URL, + validationStatus: "valid" + }).then(() => { + let allResources = projecteditor.project.allResources(); + console.log("All resources have been loaded", allResources, allResources.map(r=>r.basename).join("|")); + }); + + }); + +}, false); + +/** + * Build a temporary directory as a workspace for this loader + * https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O + */ +function buildTempDirectoryStructure() { + + // First create (and remove) the temp dir to discard any changes + let TEMP_DIR = FileUtils.getDir("TmpD", ["ProjectEditor"], true); + TEMP_DIR.remove(true); + + // Now rebuild our fake project. + TEMP_DIR = FileUtils.getDir("TmpD", ["ProjectEditor"], true); + + FileUtils.getDir("TmpD", ["ProjectEditor", "css"], true); + FileUtils.getDir("TmpD", ["ProjectEditor", "data"], true); + FileUtils.getDir("TmpD", ["ProjectEditor", "img", "icons"], true); + FileUtils.getDir("TmpD", ["ProjectEditor", "js"], true); + + let htmlFile = FileUtils.getFile("TmpD", ["ProjectEditor", "index.html"]); + htmlFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFile(htmlFile, [ + "<!DOCTYPE html>", + '<html lang="en">', + " <head>", + ' <meta charset="utf-8" />', + " <title>ProjectEditor Temp File</title>", + ' <link rel="stylesheet" href="style.css" />', + " </head>", + ' <body id="home">', + " <p>ProjectEditor Temp File</p>", + " </body>", + "</html>"].join("\n") + ); + + let readmeFile = FileUtils.getFile("TmpD", ["ProjectEditor", "README.md"]); + readmeFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFile(readmeFile, [ + "## Readme" + ].join("\n") + ); + + let licenseFile = FileUtils.getFile("TmpD", ["ProjectEditor", "LICENSE"]); + licenseFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFile(licenseFile, [ + "/* 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/. */" + ].join("\n") + ); + + let cssFile = FileUtils.getFile("TmpD", ["ProjectEditor", "css", "styles.css"]); + cssFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFile(cssFile, [ + "body {", + " background: red;", + "}" + ].join("\n") + ); + + FileUtils.getFile("TmpD", ["ProjectEditor", "js", "script.js"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + + FileUtils.getFile("TmpD", ["ProjectEditor", "img", "fake.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "16x16.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "32x32.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "128x128.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", ["ProjectEditor", "img", "icons", "vector.svg"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + + return TEMP_DIR.path; +} + + +// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#Writing_to_a_file +function writeToFile(file, data) { + + let defer = promise.defer(); + var ostream = FileUtils.openSafeFileOutputStream(file); + + var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Components.interfaces.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + var istream = converter.convertToInputStream(data); + + // The last argument (the callback) is optional. + NetUtil.asyncCopy(istream, ostream, function (status) { + if (!Components.isSuccessCode(status)) { + // Handle error! + console.log("ERROR WRITING TEMP FILE", status); + } + }); +} diff --git a/devtools/client/projecteditor/chrome/content/projecteditor-loader.xul b/devtools/client/projecteditor/chrome/content/projecteditor-loader.xul new file mode 100644 index 000000000..84db8ea48 --- /dev/null +++ b/devtools/client/projecteditor/chrome/content/projecteditor-loader.xul @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<!DOCTYPE window [ +<!ENTITY % toolboxDTD SYSTEM "chrome://devtools/locale/toolbox.dtd" > + %toolboxDTD; +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<script type="application/javascript;version=1.8" src="projecteditor-loader.js"></script> + + <commandset id="toolbox-commandset"> + <command id="projecteditor-cmd-close" oncommand="window.close();"/> + </commandset> + + <keyset id="projecteditor-keyset"> + <key id="projecteditor-key-close" + key="&closeCmd.key;" + command="projecteditor-cmd-close" + modifiers="accel"/> + </keyset> + + <iframe id="projecteditor-iframe" flex="1" forceOwnRefreshDriver=""></iframe> +</window> diff --git a/devtools/client/projecteditor/chrome/content/projecteditor-test.xul b/devtools/client/projecteditor/chrome/content/projecteditor-test.xul new file mode 100644 index 000000000..ee2be12f0 --- /dev/null +++ b/devtools/client/projecteditor/chrome/content/projecteditor-test.xul @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?> +<?xml-stylesheet href="chrome://global/skin/global.css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + + <script type="application/javascript" src="chrome://global/content/globalOverlay.js"></script> + + <commandset id="mainCommandSet"> + <commandset id="editMenuCommands"/> + </commandset> + <menubar></menubar> + <iframe id='projecteditor-iframe' flex="1"></iframe> +</window> diff --git a/devtools/client/projecteditor/chrome/content/projecteditor.xul b/devtools/client/projecteditor/chrome/content/projecteditor.xul new file mode 100644 index 000000000..795fe9fab --- /dev/null +++ b/devtools/client/projecteditor/chrome/content/projecteditor.xul @@ -0,0 +1,87 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://devtools/skin/light-theme.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/projecteditor/projecteditor.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/content/debugger/debugger.css" type="text/css"?> +<?xml-stylesheet href="resource://devtools/client/themes/common.css" type="text/css"?> +<?xml-stylesheet href="chrome://devtools/skin/markup.css" type="text/css"?> + +<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?> + +<!DOCTYPE window [ +<!ENTITY % scratchpadDTD SYSTEM "chrome://devtools/locale/scratchpad.dtd" > + %scratchpadDTD; +<!ENTITY % editMenuStrings SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuStrings; +<!ENTITY % sourceEditorStrings SYSTEM "chrome://devtools/locale/sourceeditor.dtd"> +%sourceEditorStrings; +]> + +<page xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" class="theme-body theme-light"> + + <script type="application/javascript" src="chrome://global/content/globalOverlay.js"/> + + <commandset id="projecteditor-commandset" /> + <commandset id="editMenuCommands"/> + <keyset id="projecteditor-keyset" /> + <keyset id="editMenuKeys"/> + + <!-- Eventually we want to let plugins declare their own menu items. + Wait unti app manager lands to deal with this integration point. + --> + <menubar id="projecteditor-menubar"> + <menu id="file-menu" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;"> + <menupopup id="file-menu-popup" /> + </menu> + + <menu id="edit-menu" label="&editMenu.label;" + accesskey="&editMenu.accesskey;"> + <menupopup id="edit-menu-popup"> + <menuitem id="menu_undo"/> + <menuitem id="menu_redo"/> + <menuseparator/> + <menuitem id="menu_cut"/> + <menuitem id="menu_copy"/> + <menuitem id="menu_paste"/> + </menupopup> + </menu> + </menubar> + + <popupset> + <menupopup id="context-menu-popup"> + </menupopup> + <menupopup id="texteditor-context-popup"> + <menuitem id="cMenu_cut"/> + <menuitem id="cMenu_copy"/> + <menuitem id="cMenu_paste"/> + <menuitem id="cMenu_delete"/> + <menuseparator/> + <menuitem id="cMenu_selectAll"/> + </menupopup> + </popupset> + + <deck id="main-deck" flex="1"> + <vbox flex="1" id="source-deckitem"> + <hbox id="sources-body" flex="1"> + <vbox width="250" id="sources"> + <vbox flex="1"> + </vbox> + <toolbar id="project-toolbar" class="devtools-toolbar" hidden="true"></toolbar> + </vbox> + <splitter id="source-editor-splitter" class="devtools-side-splitter"/> + <vbox id="shells" flex="4"> + <toolbar id="projecteditor-toolbar" class="devtools-toolbar"> + <hbox id="plugin-toolbar-left"/> + <spacer flex="1"/> + <hbox id="plugin-toolbar-right"/> + </toolbar> + <box id="shells-deck-container" flex="4"></box> + <toolbar id="projecteditor-toolbar-bottom" class="devtools-toolbar"> + </toolbar> + </vbox> + </hbox> + </vbox> + </deck> +</page> diff --git a/devtools/client/projecteditor/lib/editors.js b/devtools/client/projecteditor/lib/editors.js new file mode 100644 index 000000000..7d0150cf7 --- /dev/null +++ b/devtools/client/projecteditor/lib/editors.js @@ -0,0 +1,303 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { EventTarget } = require("sdk/event/target"); +const { emit } = require("sdk/event/core"); +const promise = require("promise"); +const Editor = require("devtools/client/sourceeditor/editor"); +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * ItchEditor is extended to implement an editor, which is the main view + * that shows up when a file is selected. This object should not be used + * directly - use TextEditor for a basic code editor. + */ +var ItchEditor = Class({ + extends: EventTarget, + + /** + * A boolean specifying if the toolbar above the editor should be hidden. + */ + hidesToolbar: false, + + /** + * A boolean specifying whether the editor can be edited / saved. + * For instance, a 'save' doesn't make sense on an image. + */ + isEditable: false, + + toString: function () { + return this.label || ""; + }, + + emit: function (name, ...args) { + emit(this, name, ...args); + }, + + /* Does the editor not have any unsaved changes? */ + isClean: function () { + return true; + }, + + /** + * Initialize the editor with a single host. This should be called + * by objects extending this object with: + * ItchEditor.prototype.initialize.apply(this, arguments) + */ + initialize: function (host) { + this.host = host; + this.doc = host.document; + this.label = ""; + this.elt = this.doc.createElement("vbox"); + this.elt.setAttribute("flex", "1"); + this.elt.editor = this; + this.toolbar = this.doc.querySelector("#projecteditor-toolbar"); + this.projectEditorKeyset = host.projectEditorKeyset; + this.projectEditorCommandset = host.projectEditorCommandset; + }, + + /** + * Sets the visibility of the element that shows up above the editor + * based on the this.hidesToolbar property. + */ + setToolbarVisibility: function () { + if (this.hidesToolbar) { + this.toolbar.setAttribute("hidden", "true"); + } else { + this.toolbar.removeAttribute("hidden"); + } + }, + + + /** + * Load a single resource into the editor. + * + * @param Resource resource + * The single file / item that is being dealt with (see stores/base) + * @returns Promise + * A promise that is resolved once the editor has loaded the contents + * of the resource. + */ + load: function (resource) { + return promise.resolve(); + }, + + /** + * Clean up the editor. This can have different meanings + * depending on the type of editor. + */ + destroy: function () { + + }, + + /** + * Give focus to the editor. This can have different meanings + * depending on the type of editor. + * + * @returns Promise + * A promise that is resolved once the editor has been focused. + */ + focus: function () { + return promise.resolve(); + } +}); +exports.ItchEditor = ItchEditor; + +/** + * The main implementation of the ItchEditor class. The TextEditor is used + * when editing any sort of plain text file, and can be created with different + * modes for syntax highlighting depending on the language. + */ +var TextEditor = Class({ + extends: ItchEditor, + + isEditable: true, + + /** + * Extra keyboard shortcuts to use with the editor. Shortcuts defined + * within projecteditor should be triggered when they happen in the editor, and + * they would usually be swallowed without registering them. + * See "devtools/sourceeditor/editor" for more information. + */ + get extraKeys() { + let extraKeys = {}; + + // Copy all of the registered keys into extraKeys object, to notify CodeMirror + // that it should be ignoring these keys + [...this.projectEditorKeyset.querySelectorAll("key")].forEach((key) => { + let keyUpper = key.getAttribute("key").toUpperCase(); + let toolModifiers = key.getAttribute("modifiers"); + let modifiers = { + alt: toolModifiers.includes("alt"), + shift: toolModifiers.includes("shift") + }; + + // On the key press, we will dispatch the event within projecteditor. + extraKeys[Editor.accel(keyUpper, modifiers)] = () => { + let doc = this.projectEditorCommandset.ownerDocument; + let event = doc.createEvent("Event"); + event.initEvent("command", true, true); + let command = this.projectEditorCommandset.querySelector("#" + key.getAttribute("command")); + command.dispatchEvent(event); + }; + }); + + return extraKeys; + }, + + isClean: function () { + if (!this.editor.isAppended()) { + return true; + } + return this.editor.getText() === this._savedResourceContents; + }, + + initialize: function (document, mode = Editor.modes.text) { + ItchEditor.prototype.initialize.apply(this, arguments); + this.label = mode.name; + this.editor = new Editor({ + mode: mode, + lineNumbers: true, + extraKeys: this.extraKeys, + themeSwitching: false, + autocomplete: true, + contextMenu: this.host.textEditorContextMenuPopup + }); + + // Trigger a few editor specific events on `this`. + this.editor.on("change", (...args) => { + this.emit("change", ...args); + }); + this.editor.on("cursorActivity", (...args) => { + this.emit("cursorActivity", ...args); + }); + this.editor.on("focus", (...args) => { + this.emit("focus", ...args); + }); + this.editor.on("saveRequested", (...args) => { + this.emit("saveRequested", ...args); + }); + + this.appended = this.editor.appendTo(this.elt); + }, + + /** + * Clean up the editor. This can have different meanings + * depending on the type of editor. + */ + destroy: function () { + this.editor.destroy(); + this.editor = null; + }, + + /** + * Load a single resource into the text editor. + * + * @param Resource resource + * The single file / item that is being dealt with (see stores/base) + * @returns Promise + * A promise that is resolved once the text editor has loaded the + * contents of the resource. + */ + load: function (resource) { + // Wait for the editor.appendTo and resource.load before proceeding. + // They can run in parallel. + return promise.all([ + resource.load(), + this.appended + ]).then(([resourceContents])=> { + if (!this.editor) { + return; + } + this._savedResourceContents = resourceContents; + this.editor.setText(resourceContents); + this.editor.clearHistory(); + this.editor.setClean(); + this.emit("load"); + }, console.error); + }, + + /** + * Save the resource based on the current state of the editor + * + * @param Resource resource + * The single file / item to be saved + * @returns Promise + * A promise that is resolved once the resource has been + * saved. + */ + save: function (resource) { + let newText = this.editor.getText(); + return resource.save(newText).then(() => { + this._savedResourceContents = newText; + this.emit("save", resource); + }); + }, + + /** + * Give focus to the code editor. + * + * @returns Promise + * A promise that is resolved once the editor has been focused. + */ + focus: function () { + return this.appended.then(() => { + if (this.editor) { + this.editor.focus(); + } + }); + } +}); + +/** + * Wrapper for TextEditor using JavaScript syntax highlighting. + */ +function JSEditor(host) { + return TextEditor(host, Editor.modes.js); +} + +/** + * Wrapper for TextEditor using CSS syntax highlighting. + */ +function CSSEditor(host) { + return TextEditor(host, Editor.modes.css); +} + +/** + * Wrapper for TextEditor using HTML syntax highlighting. + */ +function HTMLEditor(host) { + return TextEditor(host, Editor.modes.html); +} + +/** + * Get the type of editor that can handle a particular resource. + * @param Resource resource + * The single file that is going to be opened. + * @returns Type:Editor + * The type of editor that can handle this resource. The + * return value is a constructor function. + */ +function EditorTypeForResource(resource) { + const categoryMap = { + "txt": TextEditor, + "html": HTMLEditor, + "xml": HTMLEditor, + "css": CSSEditor, + "js": JSEditor, + "json": JSEditor + }; + return categoryMap[resource.contentCategory] || TextEditor; +} + +exports.TextEditor = TextEditor; +exports.JSEditor = JSEditor; +exports.CSSEditor = CSSEditor; +exports.HTMLEditor = HTMLEditor; +exports.EditorTypeForResource = EditorTypeForResource; diff --git a/devtools/client/projecteditor/lib/helpers/event.js b/devtools/client/projecteditor/lib/helpers/event.js new file mode 100644 index 000000000..74b4adb04 --- /dev/null +++ b/devtools/client/projecteditor/lib/helpers/event.js @@ -0,0 +1,86 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file wraps EventEmitter objects to provide functions to forget + * all events bound on a certain object. + */ + +const { Class } = require("sdk/core/heritage"); + +/** + * The Scope object is used to keep track of listeners. + * This object is not exported. + */ +var Scope = Class({ + on: function (target, event, handler) { + this.listeners = this.listeners || []; + this.listeners.push({ + target: target, + event: event, + handler: handler + }); + target.on(event, handler); + }, + + off: function (t, e, h) { + if (!this.listeners) return; + this.listeners = this.listeners.filter(({ target, event, handler }) => { + return !(target === t && event === e && handler === h); + }); + target.off(event, handler); + }, + + clear: function (clearTarget) { + if (!this.listeners) return; + this.listeners = this.listeners.filter(({ target, event, handler }) => { + if (target === clearTarget) { + target.off(event, handler); + return false; + } + return true; + }); + }, + + destroy: function () { + if (!this.listeners) return; + this.listeners.forEach(({ target, event, handler }) => { + target.off(event, handler); + }); + this.listeners = undefined; + } +}); + +var scopes = new WeakMap(); +function scope(owner) { + if (!scopes.has(owner)) { + let scope = new Scope(owner); + scopes.set(owner, scope); + return scope; + } + return scopes.get(owner); +} +exports.scope = scope; + +exports.on = function on(owner, target, event, handler) { + if (!target) return; + scope(owner).on(target, event, handler); +}; + +exports.off = function off(owner, target, event, handler) { + if (!target) return; + scope(owner).off(target, event, handler); +}; + +exports.forget = function forget(owner, target) { + scope(owner).clear(target); +}; + +exports.done = function done(owner) { + scope(owner).destroy(); + scopes.delete(owner); +}; + diff --git a/devtools/client/projecteditor/lib/helpers/file-picker.js b/devtools/client/projecteditor/lib/helpers/file-picker.js new file mode 100644 index 000000000..1dab0f001 --- /dev/null +++ b/devtools/client/projecteditor/lib/helpers/file-picker.js @@ -0,0 +1,116 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file contains helper functions for showing OS-specific + * file and folder pickers. + */ + +const { Cu, Cc, Ci } = require("chrome"); +const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {}); +const promise = require("promise"); +const { merge } = require("sdk/util/object"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); + +/** + * Show a file / folder picker. + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIFilePicker + * + * @param object options + * Additional options for setting the source. Supported options: + * - directory: string, The path to default opening + * - defaultName: string, The filename including extension that + * should be suggested to the user as a default + * - window: DOMWindow, The filename including extension that + * should be suggested to the user as a default + * - title: string, The filename including extension that + * should be suggested to the user as a default + * - mode: int, The type of picker to open. + * + * @return promise + * A promise that is resolved with the full path + * after the file has been picked. + */ +function showPicker(options) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + if (options.directory) { + try { + fp.displayDirectory = FileUtils.File(options.directory); + } catch (ex) { + console.warn(ex); + } + } + + if (options.defaultName) { + fp.defaultString = options.defaultName; + } + + fp.init(options.window, options.title, options.mode); + let deferred = promise.defer(); + fp.open({ + done: function (res) { + if (res === Ci.nsIFilePicker.returnOK || res === Ci.nsIFilePicker.returnReplace) { + deferred.resolve(fp.file.path); + } else { + deferred.reject(); + } + } + }); + return deferred.promise; +} +exports.showPicker = showPicker; + +/** + * Show a save dialog + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIFilePicker + * + * @param object options + * Additional options as specified in showPicker + * + * @return promise + * A promise that is resolved when the save dialog has closed + */ +function showSave(options) { + return showPicker(merge({ + title: getLocalizedString("projecteditor.selectFileLabel"), + mode: Ci.nsIFilePicker.modeSave + }, options)); +} +exports.showSave = showSave; + +/** + * Show a file open dialog + * + * @param object options + * Additional options as specified in showPicker + * + * @return promise + * A promise that is resolved when the file has been opened + */ +function showOpen(options) { + return showPicker(merge({ + title: getLocalizedString("projecteditor.openFileLabel"), + mode: Ci.nsIFilePicker.modeOpen + }, options)); +} +exports.showOpen = showOpen; + +/** + * Show a folder open dialog + * + * @param object options + * Additional options as specified in showPicker + * + * @return promise + * A promise that is resolved when the folder has been opened + */ +function showOpenFolder(options) { + return showPicker(merge({ + title: getLocalizedString("projecteditor.openFolderLabel"), + mode: Ci.nsIFilePicker.modeGetFolder + }, options)); +} +exports.showOpenFolder = showOpenFolder; diff --git a/devtools/client/projecteditor/lib/helpers/l10n.js b/devtools/client/projecteditor/lib/helpers/l10n.js new file mode 100644 index 000000000..b2b315ff8 --- /dev/null +++ b/devtools/client/projecteditor/lib/helpers/l10n.js @@ -0,0 +1,26 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * This file contains helper functions for internationalizing projecteditor strings + */ + +const { LocalizationHelper } = require("devtools/shared/l10n"); +const ITCHPAD_STRINGS_URI = "devtools/client/locales/projecteditor.properties"; +const L10N = new LocalizationHelper(ITCHPAD_STRINGS_URI); + +function getLocalizedString(name) { + try { + return L10N.getStr(name); + } catch (ex) { + console.log("Error reading '" + name + "'"); + throw new Error("l10n error with " + name); + } +} + +exports.getLocalizedString = getLocalizedString; diff --git a/devtools/client/projecteditor/lib/helpers/moz.build b/devtools/client/projecteditor/lib/helpers/moz.build new file mode 100644 index 000000000..c2e14fce6 --- /dev/null +++ b/devtools/client/projecteditor/lib/helpers/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( + 'event.js', + 'file-picker.js', + 'l10n.js', + 'prompts.js', +) diff --git a/devtools/client/projecteditor/lib/helpers/prompts.js b/devtools/client/projecteditor/lib/helpers/prompts.js new file mode 100644 index 000000000..0df6af304 --- /dev/null +++ b/devtools/client/projecteditor/lib/helpers/prompts.js @@ -0,0 +1,33 @@ +/* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file contains helper functions for showing user prompts. + * See https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPromptService + */ + +const { Cu, Cc, Ci } = require("chrome"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); +const prompts = Cc["@mozilla.org/embedcomp/prompt-service;1"] + .getService(Ci.nsIPromptService); + +/** + * Show a prompt. + * + * @param string title + * The title to the dialog + * @param string message + * The message to display + * + * @return bool + * Whether the user has confirmed the action + */ +function confirm(title, message) { + var result = prompts.confirm(null, title || "Title of this Dialog", message || "Are you sure?"); + return result; +} +exports.confirm = confirm; + diff --git a/devtools/client/projecteditor/lib/helpers/readdir.js b/devtools/client/projecteditor/lib/helpers/readdir.js new file mode 100644 index 000000000..054730faf --- /dev/null +++ b/devtools/client/projecteditor/lib/helpers/readdir.js @@ -0,0 +1,89 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +importScripts("resource://gre/modules/osfile.jsm"); + +/** + * This file is meant to be loaded in a worker using: + * new ChromeWorker("chrome://devtools/content/projecteditor/lib/helpers/readdir.js"); + * + * Read a local directory inside of a web woker + * + * @param {string} path + * window to inspect + * @param {RegExp|string} ignore + * A pattern to ignore certain files. This is + * called with file.name.match(ignore). + * @param {Number} maxDepth + * How many directories to recurse before stopping. + * Directories with depth > maxDepth will be ignored. + */ +function readDir(path, ignore, maxDepth = Infinity) { + let ret = {}; + + let set = new Set(); + + let info = OS.File.stat(path); + set.add({ + path: path, + name: info.name, + isDir: info.isDir, + isSymLink: info.isSymLink, + depth: 0 + }); + + for (let info of set) { + let children = []; + + if (info.isDir && !info.isSymLink) { + if (info.depth > maxDepth) { + continue; + } + + let iterator = new OS.File.DirectoryIterator(info.path); + try { + for (let child in iterator) { + if (ignore && child.name.match(ignore)) { + continue; + } + + children.push(child.path); + set.add({ + path: child.path, + name: child.name, + isDir: child.isDir, + isSymLink: child.isSymLink, + depth: info.depth + 1 + }); + } + } finally { + iterator.close(); + } + } + + ret[info.path] = { + name: info.name, + isDir: info.isDir, + isSymLink: info.isSymLink, + depth: info.depth, + children: children, + }; + } + + return ret; +} + +onmessage = function (event) { + try { + let {path, ignore, depth} = event.data; + let message = readDir(path, ignore, depth); + postMessage(message); + } catch (ex) { + console.log(ex); + } +}; + + diff --git a/devtools/client/projecteditor/lib/moz.build b/devtools/client/projecteditor/lib/moz.build new file mode 100644 index 000000000..91b88ed91 --- /dev/null +++ b/devtools/client/projecteditor/lib/moz.build @@ -0,0 +1,19 @@ +# -*- 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 += [ + 'helpers', + 'plugins', + 'stores', +] + +DevToolsModules( + 'editors.js', + 'project.js', + 'projecteditor.js', + 'shells.js', + 'tree.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/app-manager/app-project-editor.js b/devtools/client/projecteditor/lib/plugins/app-manager/app-project-editor.js new file mode 100644 index 000000000..9a66770b0 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/app-manager/app-project-editor.js @@ -0,0 +1,56 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const promise = require("promise"); +const { ItchEditor } = require("devtools/client/projecteditor/lib/editors"); + +var AppProjectEditor = Class({ + extends: ItchEditor, + + hidesToolbar: true, + + initialize: function (host) { + ItchEditor.prototype.initialize.apply(this, arguments); + this.appended = promise.resolve(); + this.host = host; + this.label = "app-manager"; + }, + + destroy: function () { + this.elt.remove(); + this.elt = null; + }, + + load: function (resource) { + let {appManagerOpts} = this.host.project; + + // Only load the frame the first time it is selected + if (!this.iframe || this.iframe.getAttribute("src") !== appManagerOpts.projectOverviewURL) { + + this.elt.textContent = ""; + let iframe = this.iframe = this.elt.ownerDocument.createElement("iframe"); + let iframeLoaded = this.iframeLoaded = promise.defer(); + + iframe.addEventListener("load", function onLoad() { + iframe.removeEventListener("load", onLoad); + iframeLoaded.resolve(); + }); + + iframe.setAttribute("flex", "1"); + iframe.setAttribute("src", appManagerOpts.projectOverviewURL); + this.elt.appendChild(iframe); + + } + + promise.all([this.iframeLoaded.promise, this.appended]).then(() => { + this.emit("load"); + }); + } +}); + +exports.AppProjectEditor = AppProjectEditor; diff --git a/devtools/client/projecteditor/lib/plugins/app-manager/moz.build b/devtools/client/projecteditor/lib/plugins/app-manager/moz.build new file mode 100644 index 000000000..8aae52725 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/app-manager/moz.build @@ -0,0 +1,10 @@ +# -*- 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( + 'app-project-editor.js', + 'plugin.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/app-manager/plugin.js b/devtools/client/projecteditor/lib/plugins/app-manager/plugin.js new file mode 100644 index 000000000..82bbab34b --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/app-manager/plugin.js @@ -0,0 +1,77 @@ +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { EventTarget } = require("sdk/event/target"); +const { emit } = require("sdk/event/core"); +const promise = require("promise"); +var { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); +const { AppProjectEditor } = require("./app-project-editor"); +const OPTION_URL = "chrome://devtools/skin/images/tool-options.svg"; +const Services = require("Services"); +const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties"); + +var AppManagerRenderer = Class({ + extends: Plugin, + + isAppManagerProject: function () { + return !!this.host.project.appManagerOpts; + }, + editorForResource: function (resource) { + if (!resource.parent && this.isAppManagerProject()) { + return AppProjectEditor; + } + }, + getUI: function (parent) { + let doc = parent.ownerDocument; + if (parent.childElementCount == 0) { + let image = doc.createElement("image"); + let optionImage = doc.createElement("image"); + let flexElement = doc.createElement("div"); + let nameLabel = doc.createElement("span"); + let statusElement = doc.createElement("div"); + + image.className = "project-image"; + optionImage.className = "project-options"; + optionImage.setAttribute("src", OPTION_URL); + nameLabel.className = "project-name-label"; + statusElement.className = "project-status"; + flexElement.className = "project-flex"; + + parent.appendChild(image); + parent.appendChild(nameLabel); + parent.appendChild(flexElement); + parent.appendChild(statusElement); + parent.appendChild(optionImage); + } + + return { + image: parent.querySelector(".project-image"), + nameLabel: parent.querySelector(".project-name-label"), + statusElement: parent.querySelector(".project-status") + }; + }, + onAnnotate: function (resource, editor, elt) { + if (resource.parent || !this.isAppManagerProject()) { + return; + } + + let {appManagerOpts} = this.host.project; + let doc = elt.ownerDocument; + + let {image, nameLabel, statusElement} = this.getUI(elt); + let name = appManagerOpts.name || resource.basename; + let url = appManagerOpts.iconUrl || "icon-sample.png"; + let status = appManagerOpts.validationStatus || "unknown"; + let tooltip = Strings.formatStringFromName("status_tooltip", + [Strings.GetStringFromName("status_" + status)], 1); + + nameLabel.textContent = name; + image.setAttribute("src", url); + statusElement.setAttribute("status", status); + statusElement.setAttribute("tooltiptext", tooltip); + + return true; + } +}); + +exports.AppManagerRenderer = AppManagerRenderer; +registerPlugin(AppManagerRenderer); diff --git a/devtools/client/projecteditor/lib/plugins/core.js b/devtools/client/projecteditor/lib/plugins/core.js new file mode 100644 index 000000000..933eda043 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/core.js @@ -0,0 +1,83 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This is the core plugin API. + +const { Class } = require("sdk/core/heritage"); + +var Plugin = Class({ + initialize: function (host) { + this.host = host; + this.init(host); + }, + + destroy: function (host) { }, + + init: function (host) {}, + + showForCategories: function (elt, categories) { + this._showFor = this._showFor || []; + let set = new Set(categories); + this._showFor.push({ + elt: elt, + categories: new Set(categories) + }); + if (this.host.currentEditor) { + this.onEditorActivated(this.host.currentEditor); + } else { + elt.classList.add("plugin-hidden"); + } + }, + + priv: function (item) { + if (!this._privData) { + this._privData = new WeakMap(); + } + if (!this._privData.has(item)) { + this._privData.set(item, {}); + } + return this._privData.get(item); + }, + onTreeSelected: function (resource) {}, + + + // Editor state lifetime... + onEditorCreated: function (editor) {}, + onEditorDestroyed: function (editor) {}, + + onEditorActivated: function (editor) { + if (this._showFor) { + let category = editor.category; + for (let item of this._showFor) { + if (item.categories.has(category)) { + item.elt.classList.remove("plugin-hidden"); + } else { + item.elt.classList.add("plugin-hidden"); + } + } + } + }, + onEditorDeactivated: function (editor) { + if (this._showFor) { + for (let item of this._showFor) { + item.elt.classList.add("plugin-hidden"); + } + } + }, + + onEditorLoad: function (editor) {}, + onEditorSave: function (editor) {}, + onEditorChange: function (editor) {}, + onEditorCursorActivity: function (editor) {}, +}); +exports.Plugin = Plugin; + +function registerPlugin(constr) { + exports.registeredPlugins.push(constr); +} +exports.registerPlugin = registerPlugin; + +exports.registeredPlugins = []; diff --git a/devtools/client/projecteditor/lib/plugins/delete/delete.js b/devtools/client/projecteditor/lib/plugins/delete/delete.js new file mode 100644 index 000000000..b28d6a0ef --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/delete/delete.js @@ -0,0 +1,67 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Class } = require("sdk/core/heritage"); +const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); +const { confirm } = require("devtools/client/projecteditor/lib/helpers/prompts"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); + +var DeletePlugin = Class({ + extends: Plugin, + shouldConfirm: true, + + init: function (host) { + this.host.addCommand(this, { + id: "cmd-delete" + }); + this.contextMenuItem = this.host.createMenuItem({ + parent: this.host.contextMenuPopup, + label: getLocalizedString("projecteditor.deleteLabel"), + command: "cmd-delete" + }); + }, + + confirmDelete: function (resource) { + let deletePromptMessage = resource.isDir ? + getLocalizedString("projecteditor.deleteFolderPromptMessage") : + getLocalizedString("projecteditor.deleteFilePromptMessage"); + return !this.shouldConfirm || confirm( + getLocalizedString("projecteditor.deletePromptTitle"), + deletePromptMessage + ); + }, + + onContextMenuOpen: function (resource) { + // Do not allow deletion of the top level items in the tree. In the + // case of the Web IDE in particular this can leave the UI in a weird + // state. If we'd like to add ability to delete the project folder from + // the tree in the future, then the UI could be cleaned up by listening + // to the ProjectTree's "resource-removed" event. + if (!resource.parent) { + this.contextMenuItem.setAttribute("hidden", "true"); + } else { + this.contextMenuItem.removeAttribute("hidden"); + } + }, + + onCommand: function (cmd) { + if (cmd === "cmd-delete") { + let tree = this.host.projectTree; + let resource = tree.getSelectedResource(); + + if (!this.confirmDelete(resource)) { + return; + } + + resource.delete().then(() => { + this.host.project.refresh(); + }); + } + } +}); + +exports.DeletePlugin = DeletePlugin; +registerPlugin(DeletePlugin); diff --git a/devtools/client/projecteditor/lib/plugins/delete/moz.build b/devtools/client/projecteditor/lib/plugins/delete/moz.build new file mode 100644 index 000000000..4b1d00466 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/delete/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( + 'delete.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/dirty/dirty.js b/devtools/client/projecteditor/lib/plugins/dirty/dirty.js new file mode 100644 index 000000000..f976c626f --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/dirty/dirty.js @@ -0,0 +1,47 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Class } = require("sdk/core/heritage"); +const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); +const { emit } = require("sdk/event/core"); + +var DirtyPlugin = Class({ + extends: Plugin, + + onEditorSave: function (editor) { this.onEditorChange(editor); }, + onEditorLoad: function (editor) { this.onEditorChange(editor); }, + + onEditorChange: function (editor) { + // Only run on a TextEditor + if (!editor || !editor.editor) { + return; + } + + // Dont' force a refresh unless the dirty state has changed... + let priv = this.priv(editor); + let clean = editor.isClean(); + if (priv.isClean !== clean) { + let resource = editor.shell.resource; + emit(resource, "label-change", resource); + priv.isClean = clean; + } + }, + + onAnnotate: function (resource, editor, elt) { + // Only run on a TextEditor + if (!editor || !editor.editor) { + return; + } + + if (!editor.isClean()) { + elt.textContent = "*" + resource.displayName; + return true; + } + } +}); +exports.DirtyPlugin = DirtyPlugin; + +registerPlugin(DirtyPlugin); diff --git a/devtools/client/projecteditor/lib/plugins/dirty/moz.build b/devtools/client/projecteditor/lib/plugins/dirty/moz.build new file mode 100644 index 000000000..b86c5a9af --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/dirty/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( + 'dirty.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/image-view/image-editor.js b/devtools/client/projecteditor/lib/plugins/image-view/image-editor.js new file mode 100644 index 000000000..668fcbeb2 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/image-view/image-editor.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const promise = require("promise"); +const { ItchEditor } = require("devtools/client/projecteditor/lib/editors"); + +var ImageEditor = Class({ + extends: ItchEditor, + + initialize: function () { + ItchEditor.prototype.initialize.apply(this, arguments); + this.label = "image"; + this.appended = promise.resolve(); + }, + + load: function (resource) { + this.elt.innerHTML = ""; + let image = this.image = this.doc.createElement("image"); + image.className = "editor-image"; + image.setAttribute("src", resource.uri); + + let box1 = this.doc.createElement("box"); + box1.appendChild(image); + + let box2 = this.doc.createElement("box"); + box2.setAttribute("flex", 1); + + this.elt.appendChild(box1); + this.elt.appendChild(box2); + + this.appended.then(() => { + this.emit("load"); + }); + }, + + destroy: function () { + if (this.image) { + this.image.remove(); + this.image = null; + } + } + +}); + +exports.ImageEditor = ImageEditor; diff --git a/devtools/client/projecteditor/lib/plugins/image-view/moz.build b/devtools/client/projecteditor/lib/plugins/image-view/moz.build new file mode 100644 index 000000000..d67370e5b --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/image-view/moz.build @@ -0,0 +1,10 @@ +# -*- 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( + 'image-editor.js', + 'plugin.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/image-view/plugin.js b/devtools/client/projecteditor/lib/plugins/image-view/plugin.js new file mode 100644 index 000000000..626ea3c9a --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/image-view/plugin.js @@ -0,0 +1,28 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const promise = require("promise"); +const { ImageEditor } = require("./image-editor"); +const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); + +var ImageEditorPlugin = Class({ + extends: Plugin, + + editorForResource: function (node) { + if (node.contentCategory === "image") { + return ImageEditor; + } + }, + + init: function (host) { + + } +}); + +exports.ImageEditorPlugin = ImageEditorPlugin; +registerPlugin(ImageEditorPlugin); diff --git a/devtools/client/projecteditor/lib/plugins/logging/logging.js b/devtools/client/projecteditor/lib/plugins/logging/logging.js new file mode 100644 index 000000000..cd5757b72 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/logging/logging.js @@ -0,0 +1,29 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { Class } = require("sdk/core/heritage"); +var { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); + +var LoggingPlugin = Class({ + extends: Plugin, + + // Editor state lifetime... + onEditorCreated: function (editor) { console.log("editor created: " + editor); }, + onEditorDestroyed: function (editor) { console.log("editor destroyed: " + editor);}, + + onEditorSave: function (editor) { console.log("editor saved: " + editor); }, + onEditorLoad: function (editor) { console.log("editor loaded: " + editor); }, + + onEditorActivated: function (editor) { console.log("editor activated: " + editor);}, + onEditorDeactivated: function (editor) { console.log("editor deactivated: " + editor);}, + + onEditorChange: function (editor) { console.log("editor changed: " + editor);}, + + onCommand: function (cmd) { console.log("Command: " + cmd); } +}); +exports.LoggingPlugin = LoggingPlugin; + +registerPlugin(LoggingPlugin); diff --git a/devtools/client/projecteditor/lib/plugins/logging/moz.build b/devtools/client/projecteditor/lib/plugins/logging/moz.build new file mode 100644 index 000000000..5d8d98fbe --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/logging/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( + 'logging.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/moz.build b/devtools/client/projecteditor/lib/plugins/moz.build new file mode 100644 index 000000000..17bff7ce0 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/moz.build @@ -0,0 +1,21 @@ +# -*- 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 += [ + 'app-manager', + 'delete', + 'dirty', + 'image-view', + 'logging', + 'new', + 'rename', + 'save', + 'status-bar', +] + +DevToolsModules( + 'core.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/new/moz.build b/devtools/client/projecteditor/lib/plugins/new/moz.build new file mode 100644 index 000000000..3caacefb1 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/new/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( + 'new.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/new/new.js b/devtools/client/projecteditor/lib/plugins/new/new.js new file mode 100644 index 000000000..220cb4977 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/new/new.js @@ -0,0 +1,80 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Class } = require("sdk/core/heritage"); +const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); + +// Handles the new command. +var NewFile = Class({ + extends: Plugin, + + init: function () { + this.command = this.host.addCommand(this, { + id: "cmd-new", + key: getLocalizedString("projecteditor.new.commandkey"), + modifiers: "accel" + }); + this.host.createMenuItem({ + parent: this.host.fileMenuPopup, + label: getLocalizedString("projecteditor.newLabel"), + command: "cmd-new", + key: "key_cmd-new" + }); + this.host.createMenuItem({ + parent: this.host.contextMenuPopup, + label: getLocalizedString("projecteditor.newLabel"), + command: "cmd-new" + }); + }, + + onCommand: function (cmd) { + if (cmd === "cmd-new") { + let tree = this.host.projectTree; + let resource = tree.getSelectedResource(); + parent = resource.isDir ? resource : resource.parent; + sibling = resource.isDir ? null : resource; + + if (!("createChild" in parent)) { + return; + } + + let extension = sibling ? sibling.contentCategory : parent.store.defaultCategory; + let template = "untitled{1}." + extension; + let name = this.suggestName(parent, template); + + tree.promptNew(name, parent, sibling).then(name => { + + // XXX: sanitize bad file names. + + // If the name is already taken, just add/increment a number. + if (parent.hasChild(name)) { + let matches = name.match(/([^\d.]*)(\d*)([^.]*)(.*)/); + template = matches[1] + "{1}" + matches[3] + matches[4]; + name = this.suggestName(parent, template, parseInt(matches[2]) || 2); + } + + return parent.createChild(name); + }).then(resource => { + tree.selectResource(resource); + this.host.currentEditor.focus(); + }).then(null, console.error); + } + }, + + suggestName: function (parent, template, start = 1) { + let i = start; + let name; + do { + name = template.replace("\{1\}", i === 1 ? "" : i); + i++; + } while (parent.hasChild(name)); + + return name; + } +}); +exports.NewFile = NewFile; +registerPlugin(NewFile); diff --git a/devtools/client/projecteditor/lib/plugins/rename/moz.build b/devtools/client/projecteditor/lib/plugins/rename/moz.build new file mode 100644 index 000000000..2b1612452 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/rename/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( + 'rename.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/rename/rename.js b/devtools/client/projecteditor/lib/plugins/rename/rename.js new file mode 100644 index 000000000..850401869 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/rename/rename.js @@ -0,0 +1,74 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Class } = require("sdk/core/heritage"); +const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); + +var RenamePlugin = Class({ + extends: Plugin, + + init: function (host) { + this.host.addCommand(this, { + id: "cmd-rename" + }); + this.contextMenuItem = this.host.createMenuItem({ + parent: this.host.contextMenuPopup, + label: getLocalizedString("projecteditor.renameLabel"), + command: "cmd-rename" + }); + }, + + onContextMenuOpen: function (resource) { + if (resource.isRoot) { + this.contextMenuItem.setAttribute("hidden", "true"); + } else { + this.contextMenuItem.removeAttribute("hidden"); + } + }, + + onCommand: function (cmd) { + if (cmd === "cmd-rename") { + let tree = this.host.projectTree; + let resource = tree.getSelectedResource(); + let parent = resource.parent; + let oldName = resource.basename; + + tree.promptEdit(oldName, resource).then(name => { + if (name === oldName) { + return resource; + } + if (parent.hasChild(name)) { + let matches = name.match(/([^\d.]*)(\d*)([^.]*)(.*)/); + let template = matches[1] + "{1}" + matches[3] + matches[4]; + name = this.suggestName(resource, template, parseInt(matches[2]) || 2); + } + return parent.rename(oldName, name); + }).then(resource => { + this.host.project.refresh(); + tree.selectResource(resource); + if (!resource.isDir) { + this.host.currentEditor.focus(); + } + }).then(null, console.error); + } + }, + + suggestName: function (resource, template, start = 1) { + let i = start; + let name; + let parent = resource.parent; + do { + name = template.replace("\{1\}", i === 1 ? "" : i); + i++; + } while (parent.hasChild(name)); + + return name; + } +}); + +exports.RenamePlugin = RenamePlugin; +registerPlugin(RenamePlugin); diff --git a/devtools/client/projecteditor/lib/plugins/save/moz.build b/devtools/client/projecteditor/lib/plugins/save/moz.build new file mode 100644 index 000000000..66df054eb --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/save/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( + 'save.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/save/save.js b/devtools/client/projecteditor/lib/plugins/save/save.js new file mode 100644 index 000000000..43b2185d2 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/save/save.js @@ -0,0 +1,93 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Class } = require("sdk/core/heritage"); +const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); +const picker = require("devtools/client/projecteditor/lib/helpers/file-picker"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); + +// Handles the save command. +var SavePlugin = Class({ + extends: Plugin, + + init: function (host) { + + this.host.addCommand(this, { + id: "cmd-save", + key: getLocalizedString("projecteditor.save.commandkey"), + modifiers: "accel" + }); + this.host.addCommand(this, { + id: "cmd-saveas", + key: getLocalizedString("projecteditor.save.commandkey"), + modifiers: "accel shift" + }); + this.host.createMenuItem({ + parent: this.host.fileMenuPopup, + label: getLocalizedString("projecteditor.saveLabel"), + command: "cmd-save", + key: "key_cmd-save" + }); + this.host.createMenuItem({ + parent: this.host.fileMenuPopup, + label: getLocalizedString("projecteditor.saveAsLabel"), + command: "cmd-saveas", + key: "key_cmd-saveas" + }); + }, + + isCommandEnabled: function (cmd) { + let currentEditor = this.host.currentEditor; + return currentEditor.isEditable; + }, + + onCommand: function (cmd) { + if (cmd === "cmd-save") { + this.onEditorSaveRequested(); + } else if (cmd === "cmd-saveas") { + this.saveAs(); + } + }, + + saveAs: function () { + let editor = this.host.currentEditor; + let project = this.host.resourceFor(editor); + + let resource; + picker.showSave({ + window: this.host.window, + directory: project && project.parent ? project.parent.path : null, + defaultName: project ? project.basename : null, + }).then(path => { + return this.createResource(path); + }).then(res => { + resource = res; + return this.saveResource(editor, resource); + }).then(() => { + this.host.openResource(resource); + }).then(null, console.error); + }, + + onEditorSaveRequested: function () { + let editor = this.host.currentEditor; + let resource = this.host.resourceFor(editor); + if (!resource) { + return this.saveAs(); + } + + return this.saveResource(editor, resource); + }, + + createResource: function (path) { + return this.host.project.resourceFor(path, { create: true }); + }, + + saveResource: function (editor, resource) { + return editor.save(resource); + } +}); +exports.SavePlugin = SavePlugin; +registerPlugin(SavePlugin); diff --git a/devtools/client/projecteditor/lib/plugins/status-bar/moz.build b/devtools/client/projecteditor/lib/plugins/status-bar/moz.build new file mode 100644 index 000000000..87ce21584 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/status-bar/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( + 'plugin.js', +) diff --git a/devtools/client/projecteditor/lib/plugins/status-bar/plugin.js b/devtools/client/projecteditor/lib/plugins/status-bar/plugin.js new file mode 100644 index 000000000..9450baef3 --- /dev/null +++ b/devtools/client/projecteditor/lib/plugins/status-bar/plugin.js @@ -0,0 +1,105 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const promise = require("promise"); +const { registerPlugin, Plugin } = require("devtools/client/projecteditor/lib/plugins/core"); + +/** + * Print information about the currently opened file + * and the state of the current editor + */ +var StatusBarPlugin = Class({ + extends: Plugin, + + init: function () { + this.box = this.host.createElement("hbox", { + parent: "#projecteditor-toolbar-bottom" + }); + + this.activeMode = this.host.createElement("label", { + parent: this.box, + class: "projecteditor-basic-display" + }); + + this.cursorPosition = this.host.createElement("label", { + parent: this.box, + class: "projecteditor-basic-display" + }); + + this.fileLabel = this.host.createElement("label", { + parent: "#plugin-toolbar-left", + class: "projecteditor-file-label" + }); + }, + + destroy: function () { + }, + + /** + * Print information about the current state of the editor + * + * @param Editor editor + */ + render: function (editor, resource) { + if (!resource || resource.isDir) { + this.fileLabel.textContent = ""; + this.cursorPosition.value = ""; + return; + } + + this.fileLabel.textContent = resource.basename; + this.activeMode.value = editor.toString(); + if (editor.editor) { + let cursorStart = editor.editor.getCursor("start"); + let cursorEnd = editor.editor.getCursor("end"); + if (cursorStart.line === cursorEnd.line && cursorStart.ch === cursorEnd.ch) { + this.cursorPosition.value = cursorStart.line + " " + cursorStart.ch; + } else { + this.cursorPosition.value = cursorStart.line + " " + cursorStart.ch + " | " + + cursorEnd.line + " " + cursorEnd.ch; + } + } else { + this.cursorPosition.value = ""; + } + }, + + + /** + * Print the current file name + * + * @param Resource resource + */ + onTreeSelected: function (resource) { + if (!resource || resource.isDir) { + this.fileLabel.textContent = ""; + return; + } + this.fileLabel.textContent = resource.basename; + }, + + onEditorDeactivated: function (editor) { + this.fileLabel.textContent = ""; + this.cursorPosition.value = ""; + }, + + onEditorChange: function (editor, resource) { + this.render(editor, resource); + }, + + onEditorCursorActivity: function (editor, resource) { + this.render(editor, resource); + }, + + onEditorActivated: function (editor, resource) { + this.render(editor, resource); + }, + +}); + +exports.StatusBarPlugin = StatusBarPlugin; +registerPlugin(StatusBarPlugin); diff --git a/devtools/client/projecteditor/lib/project.js b/devtools/client/projecteditor/lib/project.js new file mode 100644 index 000000000..8e0a8802d --- /dev/null +++ b/devtools/client/projecteditor/lib/project.js @@ -0,0 +1,246 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { EventTarget } = require("sdk/event/target"); +const { emit } = require("sdk/event/core"); +const { scope, on, forget } = require("devtools/client/projecteditor/lib/helpers/event"); +const prefs = require("sdk/preferences/service"); +const { LocalStore } = require("devtools/client/projecteditor/lib/stores/local"); +const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {}); +const { Task } = require("devtools/shared/task"); +const promise = require("promise"); +const { TextEncoder, TextDecoder } = require("sdk/io/buffer"); +const url = require("sdk/url"); + +const gDecoder = new TextDecoder(); +const gEncoder = new TextEncoder(); + +/** + * A Project keeps track of the opened folders using LocalStore + * objects. Resources are generally requested from the project, + * even though the Store is actually keeping track of them. + * + * + * This object emits the following events: + * - "refresh-complete": After all stores have been refreshed from disk. + * - "store-added": When a store has been added to the project. + * - "store-removed": When a store has been removed from the project. + * - "resource-added": When a resource has been added to one of the stores. + * - "resource-removed": When a resource has been removed from one of the stores. + */ +var Project = Class({ + extends: EventTarget, + + /** + * Intialize the Project. + * + * @param Object options + * Options to be passed into Project.load function + */ + initialize: function (options) { + this.localStores = new Map(); + + this.load(options); + }, + + destroy: function () { + // We are removing the store because the project never gets persisted. + // There may need to be separate destroy functionality that doesn't remove + // from project if this is saved to DB. + this.removeAllStores(); + }, + + toString: function () { + return "[Project] " + this.name; + }, + + /** + * Load a project given metadata about it. + * + * @param Object options + * Information about the project, containing: + * id: An ID (currently unused, but could be used for saving) + * name: The display name of the project + * directories: An array of path strings to load + */ + load: function (options) { + this.id = options.id; + this.name = options.name || "Untitled"; + + let paths = new Set(options.directories.map(name => OS.Path.normalize(name))); + + for (let [path, store] of this.localStores) { + if (!paths.has(path)) { + this.removePath(path); + } + } + + for (let path of paths) { + this.addPath(path); + } + }, + + /** + * Refresh all project stores from disk + * + * @returns Promise + * A promise that resolves when everything has been refreshed. + */ + refresh: function () { + return Task.spawn(function* () { + for (let [path, store] of this.localStores) { + yield store.refresh(); + } + emit(this, "refresh-complete"); + }.bind(this)); + }, + + + /** + * Fetch a resource from the backing storage system for the store. + * + * @param string path + * The path to fetch + * @param Object options + * "create": bool indicating whether to create a file if it does not exist. + * @returns Promise + * A promise that resolves with the Resource. + */ + resourceFor: function (path, options) { + let store = this.storeContaining(path); + return store.resourceFor(path, options); + }, + + /** + * Get every resource used inside of the project. + * + * @returns Array<Resource> + * A list of all Resources in all Stores. + */ + allResources: function () { + let resources = []; + for (let store of this.allStores()) { + resources = resources.concat(store.allResources()); + } + return resources; + }, + + /** + * Get every Path used inside of the project. + * + * @returns generator-iterator<Store> + * A list of all Stores + */ + allStores: function* () { + for (let [path, store] of this.localStores) { + yield store; + } + }, + + /** + * Get every file path used inside of the project. + * + * @returns Array<string> + * A list of all file paths + */ + allPaths: function () { + return [...this.localStores.keys()]; + }, + + /** + * Get the store that contains a path. + * + * @returns Store + * The store, if any. Will return null if no store + * contains the given path. + */ + storeContaining: function (path) { + let containingStore = null; + for (let store of this.allStores()) { + if (store.contains(path)) { + // With nested projects, the final containing store will be returned. + containingStore = store; + } + } + return containingStore; + }, + + /** + * Add a store at the current path. If a store already exists + * for this path, then return it. + * + * @param string path + * @returns LocalStore + */ + addPath: function (path) { + if (!this.localStores.has(path)) { + this.addLocalStore(new LocalStore(path)); + } + return this.localStores.get(path); + }, + + /** + * Remove a store for a given path. + * + * @param string path + */ + removePath: function (path) { + this.removeLocalStore(this.localStores.get(path)); + }, + + + /** + * Add the given Store to the project. + * Fires a 'store-added' event on the project. + * + * @param Store store + */ + addLocalStore: function (store) { + store.canPair = true; + this.localStores.set(store.path, store); + + // Originally StoreCollection.addStore + on(this, store, "resource-added", (resource) => { + emit(this, "resource-added", resource); + }); + on(this, store, "resource-removed", (resource) => { + emit(this, "resource-removed", resource); + }); + + emit(this, "store-added", store); + }, + + + /** + * Remove all of the Stores belonging to the project. + */ + removeAllStores: function () { + for (let store of this.allStores()) { + this.removeLocalStore(store); + } + }, + + /** + * Remove the given Store from the project. + * Fires a 'store-removed' event on the project. + * + * @param Store store + */ + removeLocalStore: function (store) { + // XXX: tree selection should be reset if active element is affected by + // the store being removed + if (store) { + this.localStores.delete(store.path); + forget(this, store); + emit(this, "store-removed", store); + store.destroy(); + } + } +}); + +exports.Project = Project; diff --git a/devtools/client/projecteditor/lib/projecteditor.js b/devtools/client/projecteditor/lib/projecteditor.js new file mode 100644 index 000000000..a3ef06249 --- /dev/null +++ b/devtools/client/projecteditor/lib/projecteditor.js @@ -0,0 +1,816 @@ +/* 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 { Class } = require("sdk/core/heritage"); +const { Project } = require("devtools/client/projecteditor/lib/project"); +const { ProjectTreeView } = require("devtools/client/projecteditor/lib/tree"); +const { ShellDeck } = require("devtools/client/projecteditor/lib/shells"); +const { Resource } = require("devtools/client/projecteditor/lib/stores/resource"); +const { registeredPlugins } = require("devtools/client/projecteditor/lib/plugins/core"); +const { EventTarget } = require("sdk/event/target"); +const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event"); +const { emit } = require("sdk/event/core"); +const { merge } = require("sdk/util/object"); +const promise = require("promise"); +const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers"); +const { DOMHelpers } = require("resource://devtools/client/shared/DOMHelpers.jsm"); +const Services = require("Services"); +const { Task } = require("devtools/shared/task"); +const ITCHPAD_URL = "chrome://devtools/content/projecteditor/chrome/content/projecteditor.xul"; +const { confirm } = require("devtools/client/projecteditor/lib/helpers/prompts"); +const { getLocalizedString } = require("devtools/client/projecteditor/lib/helpers/l10n"); + +// Enabled Plugins +require("devtools/client/projecteditor/lib/plugins/dirty/dirty"); +require("devtools/client/projecteditor/lib/plugins/delete/delete"); +require("devtools/client/projecteditor/lib/plugins/new/new"); +require("devtools/client/projecteditor/lib/plugins/rename/rename"); +require("devtools/client/projecteditor/lib/plugins/save/save"); +require("devtools/client/projecteditor/lib/plugins/image-view/plugin"); +require("devtools/client/projecteditor/lib/plugins/app-manager/plugin"); +require("devtools/client/projecteditor/lib/plugins/status-bar/plugin"); + +// Uncomment to enable logging. +// require("devtools/client/projecteditor/lib/plugins/logging/logging"); + +/** + * This is the main class tying together an instance of the ProjectEditor. + * The frontend is contained inside of this.iframe, which loads projecteditor.xul. + * + * Usage: + * let projecteditor = new ProjectEditor(frame); + * projecteditor.loaded.then((projecteditor) => { + * // Ready to use. + * }); + * + * Responsible for maintaining: + * - The list of Plugins for this instance. + * - The ShellDeck, which includes all Shells for opened Resources + * -- Shells take in a Resource, and construct the appropriate Editor + * - The Project, which includes all Stores for this instance + * -- Stores manage all Resources starting from a root directory + * --- Resources are a representation of a file on disk + * - The ProjectTreeView that builds the UI for interacting with the + * project. + * + * This object emits the following events: + * - "onEditorDestroyed": When editor is destroyed + * - "onEditorSave": When editor is saved + * - "onEditorLoad": When editor is loaded + * - "onEditorActivated": When editor is activated + * - "onEditorChange": When editor is changed + * - "onEditorCursorActivity": When there is cursor activity in a text editor + * - "onCommand": When a command happens + * - "onEditorDestroyed": When editor is destroyed + * - "onContextMenuOpen": When the context menu is opened on the project tree + * + * The events can be bound like so: + * projecteditor.on("onEditorCreated", (editor) => { }); + */ +var ProjectEditor = Class({ + extends: EventTarget, + + /** + * Initialize ProjectEditor, and load into an iframe if specified. + * + * @param Iframe iframe + * The iframe to inject the DOM into. If this is not + * specified, then this.load(frame) will need to be called + * before accessing ProjectEditor. + * @param Object options + * - menubar: a <menubar> element to inject menus into + * - menuindex: Integer child index to insert menus + */ + initialize: function (iframe, options = {}) { + this._onTreeSelected = this._onTreeSelected.bind(this); + this._onTreeResourceRemoved = this._onTreeResourceRemoved.bind(this); + this._onEditorCreated = this._onEditorCreated.bind(this); + this._onEditorActivated = this._onEditorActivated.bind(this); + this._onEditorDeactivated = this._onEditorDeactivated.bind(this); + this._updateMenuItems = this._updateMenuItems.bind(this); + this._updateContextMenuItems = this._updateContextMenuItems.bind(this); + this.destroy = this.destroy.bind(this); + this.menubar = options.menubar || null; + this.menuindex = options.menuindex || null; + this._menuEnabled = true; + this._destroyed = false; + this._loaded = false; + this._pluginCommands = new Map(); + if (iframe) { + this.load(iframe); + } + }, + + /** + * Load the instance inside of a specified iframe. + * This can be called more than once, and it will return the promise + * from the first call. + * + * @param Iframe iframe + * The iframe to inject the projecteditor DOM into + * @returns Promise + * A promise that is resolved once the iframe has been + * loaded. + */ + load: function (iframe) { + if (this.loaded) { + return this.loaded; + } + + let deferred = promise.defer(); + this.loaded = deferred.promise; + this.iframe = iframe; + + let domReady = () => { + if (this._destroyed) { + deferred.reject("Error: ProjectEditor has been destroyed before loading"); + return; + } + this._onLoad(); + this._loaded = true; + deferred.resolve(this); + }; + + let domHelper = new DOMHelpers(this.iframe.contentWindow); + domHelper.onceDOMReady(domReady); + + this.iframe.setAttribute("src", ITCHPAD_URL); + + return this.loaded; + }, + + /** + * Build the projecteditor DOM inside of this.iframe. + */ + _onLoad: function () { + this.document = this.iframe.contentDocument; + this.window = this.iframe.contentWindow; + + this._initCommands(); + this._buildMenubar(); + this._buildSidebar(); + + this.window.addEventListener("unload", this.destroy, false); + + // Editor management + this.shells = new ShellDeck(this, this.document); + this.shells.on("editor-created", this._onEditorCreated); + this.shells.on("editor-activated", this._onEditorActivated); + this.shells.on("editor-deactivated", this._onEditorDeactivated); + + let shellContainer = this.document.querySelector("#shells-deck-container"); + shellContainer.appendChild(this.shells.elt); + + // We are not allowing preset projects for now - rebuild a fresh one + // each time. + this.setProject(new Project({ + id: "", + name: "", + directories: [], + openFiles: [] + })); + + this._initPlugins(); + }, + + _buildMenubar: function () { + + this.contextMenuPopup = this.document.getElementById("context-menu-popup"); + this.contextMenuPopup.addEventListener("popupshowing", this._updateContextMenuItems); + + this.textEditorContextMenuPopup = this.document.getElementById("texteditor-context-popup"); + this.textEditorContextMenuPopup.addEventListener("popupshowing", this._updateMenuItems); + + this.editMenu = this.document.getElementById("edit-menu"); + this.fileMenu = this.document.getElementById("file-menu"); + + this.editMenuPopup = this.document.getElementById("edit-menu-popup"); + this.fileMenuPopup = this.document.getElementById("file-menu-popup"); + this.editMenu.addEventListener("popupshowing", this._updateMenuItems); + this.fileMenu.addEventListener("popupshowing", this._updateMenuItems); + + if (this.menubar) { + let body = this.menubar.ownerDocument.body || + this.menubar.ownerDocument.querySelector("window"); + body.appendChild(this.projectEditorCommandset); + body.appendChild(this.projectEditorKeyset); + body.appendChild(this.editorCommandset); + body.appendChild(this.editorKeyset); + body.appendChild(this.contextMenuPopup); + body.appendChild(this.textEditorContextMenuPopup); + + let index = this.menuindex || 0; + this.menubar.insertBefore(this.editMenu, this.menubar.children[index]); + this.menubar.insertBefore(this.fileMenu, this.menubar.children[index]); + } else { + this.document.getElementById("projecteditor-menubar").style.display = "block"; + } + + // Insert a controller to allow enabling and disabling of menu items. + this._commandWindow = this.editorCommandset.ownerDocument.defaultView; + this._commandController = getCommandController(this); + this._commandWindow.controllers.insertControllerAt(0, this._commandController); + }, + + /** + * Create the project tree sidebar that lists files. + */ + _buildSidebar: function () { + this.projectTree = new ProjectTreeView(this.document, { + resourceVisible: this.resourceVisible.bind(this), + resourceFormatter: this.resourceFormatter.bind(this), + contextMenuPopup: this.contextMenuPopup + }); + on(this, this.projectTree, "selection", this._onTreeSelected); + on(this, this.projectTree, "resource-removed", this._onTreeResourceRemoved); + + let sourcesBox = this.document.querySelector("#sources > vbox"); + sourcesBox.appendChild(this.projectTree.elt); + }, + + /** + * Set up listeners for commands to dispatch to all of the plugins + */ + _initCommands: function () { + + this.projectEditorCommandset = this.document.getElementById("projecteditor-commandset"); + this.projectEditorKeyset = this.document.getElementById("projecteditor-keyset"); + + this.editorCommandset = this.document.getElementById("editMenuCommands"); + this.editorKeyset = this.document.getElementById("editMenuKeys"); + + this.projectEditorCommandset.addEventListener("command", (evt) => { + evt.stopPropagation(); + evt.preventDefault(); + this.pluginDispatch("onCommand", evt.target.id, evt.target); + }); + }, + + /** + * Initialize each plugin in registeredPlugins + */ + _initPlugins: function () { + this._plugins = []; + + for (let plugin of registeredPlugins) { + try { + this._plugins.push(plugin(this)); + } catch (ex) { + console.exception(ex); + } + } + + this.pluginDispatch("lateInit"); + }, + + /** + * Enable / disable necessary menu items using globalOverlay.js. + */ + _updateMenuItems: function () { + let window = this.editMenu.ownerDocument.defaultView; + let commands = ["cmd_undo", "cmd_redo", "cmd_delete", "cmd_cut", "cmd_copy", "cmd_paste"]; + commands.forEach(window.goUpdateCommand); + + for (let c of this._pluginCommands.keys()) { + window.goUpdateCommand(c); + } + }, + + /** + * Enable / disable necessary context menu items by passing an event + * onto plugins. + */ + _updateContextMenuItems: function () { + let resource = this.projectTree.getSelectedResource(); + this.pluginDispatch("onContextMenuOpen", resource); + }, + + /** + * Destroy all objects on the iframe unload event. + */ + destroy: function () { + this._destroyed = true; + + + // If been destroyed before the iframe finished loading, then + // the properties below will not exist. + if (!this._loaded) { + this.iframe.setAttribute("src", "about:blank"); + return; + } + + // Reset the src for the iframe so if it reused for a new ProjectEditor + // instance, the load will fire properly. + this.window.removeEventListener("unload", this.destroy, false); + this.iframe.setAttribute("src", "about:blank"); + + this._plugins.forEach(plugin => { plugin.destroy(); }); + + forget(this, this.projectTree); + this.projectTree.destroy(); + this.projectTree = null; + + this.shells.destroy(); + + this.projectEditorCommandset.remove(); + this.projectEditorKeyset.remove(); + this.editorCommandset.remove(); + this.editorKeyset.remove(); + this.contextMenuPopup.remove(); + this.textEditorContextMenuPopup.remove(); + this.editMenu.remove(); + this.fileMenu.remove(); + + this._commandWindow.controllers.removeController(this._commandController); + this._commandController = null; + + forget(this, this.project); + this.project.destroy(); + this.project = null; + }, + + /** + * Set the current project viewed by the projecteditor. + * + * @param Project project + * The project to set. + */ + setProject: function (project) { + if (this.project) { + forget(this, this.project); + } + this.project = project; + this.projectTree.setProject(project); + + // Whenever a store gets removed, clean up any editors that + // exist for resources within it. + on(this, project, "store-removed", (store) => { + store.allResources().forEach((resource) => { + this.shells.removeResource(resource); + }); + }); + }, + + /** + * Set the current project viewed by the projecteditor to a single path, + * used by the app manager. + * + * @param string path + * The file path to set + * @param Object opts + * Custom options used by the project. + * - name: display name for project + * - iconUrl: path to icon for project + * - validationStatus: one of 'unknown|error|warning|valid' + * - projectOverviewURL: path to load for iframe when project + * is selected in the tree. + * @param Promise + * Promise that is resolved once the project is ready to be used. + */ + setProjectToAppPath: function (path, opts = {}) { + this.project.appManagerOpts = opts; + + let existingPaths = this.project.allPaths(); + if (existingPaths.length !== 1 || existingPaths[0] !== path) { + // Only fully reset if this is a new path. + this.project.removeAllStores(); + this.project.addPath(path); + } else { + // Otherwise, just ask for the root to be redrawn + let rootResource = this.project.localStores.get(path).root; + emit(rootResource, "label-change", rootResource); + } + + return this.project.refresh(); + }, + + /** + * Open a resource in a particular shell. + * + * @param Resource resource + * The file to be opened. + */ + openResource: function (resource) { + let shell = this.shells.open(resource); + this.projectTree.selectResource(resource); + shell.editor.focus(); + }, + + /** + * When a node is selected in the tree, open its associated editor. + * + * @param Resource resource + * The file that has been selected + */ + _onTreeSelected: function (resource) { + // Don't attempt to open a directory that is not the root element. + if (resource.isDir && resource.parent) { + return; + } + this.pluginDispatch("onTreeSelected", resource); + this.openResource(resource); + }, + + /** + * When a node is removed, destroy it and its associated editor. + * + * @param Resource resource + * The resource being removed + */ + _onTreeResourceRemoved: function (resource) { + this.shells.removeResource(resource); + }, + + /** + * Create an xul element with options + * + * @param string type + * The tag name of the element to create. + * @param Object options + * "command": DOMNode or string ID of a command element. + * "parent": DOMNode or selector of parent to append child to. + * anything other keys are set as an attribute as the element. + * @returns DOMElement + * The element that has been created. + */ + createElement: function (type, options) { + let elt = this.document.createElement(type); + + let parent; + + for (let opt in options) { + if (opt === "command") { + let command = typeof (options.command) === "string" ? options.command : options.command.id; + elt.setAttribute("command", command); + } else if (opt === "parent") { + continue; + } else { + elt.setAttribute(opt, options[opt]); + } + } + + if (options.parent) { + let parent = options.parent; + if (typeof (parent) === "string") { + parent = this.document.querySelector(parent); + } + parent.appendChild(elt); + } + + return elt; + }, + + /** + * Create a "menuitem" xul element with options + * + * @param Object options + * See createElement for available options. + * @returns DOMElement + * The menuitem that has been created. + */ + createMenuItem: function (options) { + return this.createElement("menuitem", options); + }, + + /** + * Add a command to the projecteditor document. + * This method is meant to be used with plugins. + * + * @param Object definition + * key: a key/keycode string. Example: "f". + * id: Unique ID. Example: "find". + * modifiers: Key modifiers. Example: "accel". + * @returns DOMElement + * The command element that has been created. + */ + addCommand: function (plugin, definition) { + this._pluginCommands.set(definition.id, plugin); + let document = this.projectEditorKeyset.ownerDocument; + let command = document.createElement("command"); + command.setAttribute("id", definition.id); + if (definition.key) { + let key = document.createElement("key"); + key.id = "key_" + definition.id; + + let keyName = definition.key; + if (keyName.startsWith("VK_")) { + key.setAttribute("keycode", keyName); + } else { + key.setAttribute("key", keyName); + } + key.setAttribute("modifiers", definition.modifiers); + key.setAttribute("command", definition.id); + this.projectEditorKeyset.appendChild(key); + } + command.setAttribute("oncommand", "void(0);"); // needed. See bug 371900 + this.projectEditorCommandset.appendChild(command); + return command; + }, + + /** + * Get the instance of a plugin registered with a certain type. + * + * @param Type pluginType + * The type, such as SavePlugin + * @returns Plugin + * The plugin instance matching the specified type. + */ + getPlugin: function (pluginType) { + for (let plugin of this.plugins) { + if (plugin.constructor === pluginType) { + return plugin; + } + } + return null; + }, + + /** + * Get all plugin instances active for the current project + * + * @returns [Plugin] + */ + get plugins() { + if (!this._plugins) { + console.log("plugins requested before _plugins was set"); + return []; + } + // Could filter further based on the type of project selected, + // but no need right now. + return this._plugins; + }, + + /** + * Dispatch an onEditorCreated event, and listen for other events specific + * to this editor instance. + * + * @param Editor editor + * The new editor instance. + */ + _onEditorCreated: function (editor) { + this.pluginDispatch("onEditorCreated", editor); + this._editorListenAndDispatch(editor, "change", "onEditorChange"); + this._editorListenAndDispatch(editor, "cursorActivity", "onEditorCursorActivity"); + this._editorListenAndDispatch(editor, "load", "onEditorLoad"); + this._editorListenAndDispatch(editor, "saveRequested", "onEditorSaveRequested"); + this._editorListenAndDispatch(editor, "save", "onEditorSave"); + + editor.on("focus", () => { + this.projectTree.selectResource(this.resourceFor(editor)); + }); + }, + + /** + * Dispatch an onEditorActivated event and finish setting up once the + * editor is ready to use. + * + * @param Editor editor + * The editor instance, which is now appended in the document. + * @param Resource resource + * The resource used by the editor + */ + _onEditorActivated: function (editor, resource) { + editor.setToolbarVisibility(); + this.pluginDispatch("onEditorActivated", editor, resource); + }, + + /** + * Dispatch an onEditorDactivated event once an editor loses focus + * + * @param Editor editor + * The editor instance, which is no longer active. + * @param Resource resource + * The resource used by the editor + */ + _onEditorDeactivated: function (editor, resource) { + this.pluginDispatch("onEditorDeactivated", editor, resource); + }, + + /** + * Call a method on all plugins that implement the method. + * Also emits the same handler name on `this`. + * + * @param string handler + * Which function name to call on plugins. + * @param ...args args + * All remaining parameters are passed into the handler. + */ + pluginDispatch: function (handler, ...args) { + emit(this, handler, ...args); + this.plugins.forEach(plugin => { + try { + if (handler in plugin) plugin[handler](...args); + } catch (ex) { + console.error(ex); + } + }); + }, + + /** + * Listen to an event on the editor object and dispatch it + * to all plugins that implement the associated method + * + * @param Editor editor + * Which editor to listen to + * @param string event + * Which editor event to listen for + * @param string handler + * Which plugin method to call + */ + _editorListenAndDispatch: function (editor, event, handler) { + editor.on(event, (...args) => { + this.pluginDispatch(handler, editor, this.resourceFor(editor), ...args); + }); + }, + + /** + * Find a shell for a resource. + * + * @param Resource resource + * The file to be opened. + * @returns Shell + */ + shellFor: function (resource) { + return this.shells.shellFor(resource); + }, + + /** + * Returns the Editor for a given resource. + * + * @param Resource resource + * The file to check. + * @returns Editor + * Instance of the editor for this file. + */ + editorFor: function (resource) { + let shell = this.shellFor(resource); + return shell ? shell.editor : shell; + }, + + /** + * Returns a resource for the given editor + * + * @param Editor editor + * The editor to check + * @returns Resource + * The resource associated with this editor + */ + resourceFor: function (editor) { + if (editor && editor.shell && editor.shell.resource) { + return editor.shell.resource; + } + return null; + }, + + /** + * Decide whether a given resource should be hidden in the tree. + * + * @param Resource resource + * The resource in the tree + * @returns Boolean + * True if the node should be visible, false if hidden. + */ + resourceVisible: function (resource) { + return true; + }, + + /** + * Format the given node for display in the resource tree view. + * + * @param Resource resource + * The file to be opened. + * @param DOMNode elt + * The element in the tree to render into. + */ + resourceFormatter: function (resource, elt) { + let editor = this.editorFor(resource); + let renderedByPlugin = false; + + // Allow plugins to override default templating of resource in tree. + this.plugins.forEach(plugin => { + if (!plugin.onAnnotate) { + return; + } + if (plugin.onAnnotate(resource, editor, elt)) { + renderedByPlugin = true; + } + }); + + // If no plugin wants to handle it, just use a string from the resource. + if (!renderedByPlugin) { + elt.textContent = resource.displayName; + } + }, + + get sourcesVisible() { + return this.sourceToggle.classList.contains("pane-collapsed"); + }, + + get currentShell() { + return this.shells.currentShell; + }, + + get currentEditor() { + return this.shells.currentEditor; + }, + + /** + * Whether or not menu items should be able to be enabled. + * Note that even if this is true, certain menu items will not be + * enabled until the correct state is achieved (for instance, the + * 'copy' menu item is only enabled when there is a selection). + * But if this is false, then nothing will be enabled. + */ + set menuEnabled(val) { + this._menuEnabled = val; + if (this._loaded) { + this._updateMenuItems(); + } + }, + + get menuEnabled() { + return this._menuEnabled; + }, + + /** + * Are there any unsaved resources in the Project? + */ + get hasUnsavedResources() { + return this.project.allResources().some(resource=> { + let editor = this.editorFor(resource); + return editor && !editor.isClean(); + }); + }, + + /** + * Check with the user about navigating away with unsaved changes. + * + * @returns Boolean + * True if there are no unsaved changes + * Otherwise, ask the user to confirm and return the outcome. + */ + confirmUnsaved: function () { + if (this.hasUnsavedResources) { + return confirm( + getLocalizedString("projecteditor.confirmUnsavedTitle"), + getLocalizedString("projecteditor.confirmUnsavedLabel2") + ); + } + + return true; + }, + + /** + * Save all the changes in source files. + * + * @returns Boolean + * True if there were resources to save. + */ + saveAllFiles: Task.async(function* () { + if (this.hasUnsavedResources) { + for (let resource of this.project.allResources()) { + let editor = this.editorFor(resource); + if (editor && !editor.isClean()) { + yield editor.save(resource); + } + } + + return true; + } + + return false; + }) + +}); + + +/** + * Returns a controller object that can be used for + * editor-specific commands such as find, jump to line, + * copy/paste, etc. + */ +function getCommandController(host) { + return { + supportsCommand: function (cmd) { + return host._pluginCommands.get(cmd); + }, + + isCommandEnabled: function (cmd) { + if (!host.menuEnabled) { + return false; + } + let plugin = host._pluginCommands.get(cmd); + if (plugin && plugin.isCommandEnabled) { + return plugin.isCommandEnabled(cmd); + } + return true; + }, + doCommand: function (cmd) { + } + }; +} + +exports.ProjectEditor = ProjectEditor; diff --git a/devtools/client/projecteditor/lib/shells.js b/devtools/client/projecteditor/lib/shells.js new file mode 100644 index 000000000..8004f24a2 --- /dev/null +++ b/devtools/client/projecteditor/lib/shells.js @@ -0,0 +1,243 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { EventTarget } = require("sdk/event/target"); +const { emit } = require("sdk/event/core"); +const { EditorTypeForResource } = require("devtools/client/projecteditor/lib/editors"); +const NetworkHelper = require("devtools/shared/webconsole/network-helper"); +const promise = require("promise"); + +/** + * The Shell is the object that manages the editor for a single resource. + * It is in charge of selecting the proper Editor (text/image/plugin-defined) + * and instantiating / appending the editor. + * This object is not exported, it is just used internally by the ShellDeck. + * + * This object has a promise `editorAppended`, that will resolve once the editor + * is ready to be used. + */ +var Shell = Class({ + extends: EventTarget, + + /** + * @param ProjectEditor host + * @param Resource resource + */ + initialize: function (host, resource) { + this.host = host; + this.doc = host.document; + this.resource = resource; + this.elt = this.doc.createElement("vbox"); + this.elt.classList.add("view-project-detail"); + this.elt.shell = this; + + let constructor = this._editorTypeForResource(); + + this.editor = constructor(this.host); + this.editor.shell = this; + this.editorAppended = this.editor.appended; + + this.editor.on("load", () => { + this.editorDeferred.resolve(); + }); + this.elt.appendChild(this.editor.elt); + }, + + /** + * Start loading the resource. The 'load' event happens as + * a result of this function, so any listeners to 'editorAppended' + * need to be added before calling this. + */ + load: function () { + this.editorDeferred = promise.defer(); + this.editorLoaded = this.editorDeferred.promise; + this.editor.load(this.resource); + }, + + /** + * Destroy the shell and its associated editor + */ + destroy: function () { + this.editor.destroy(); + this.resource.destroy(); + }, + + /** + * Make sure the correct editor is selected for the resource. + * @returns Type:Editor + */ + _editorTypeForResource: function () { + let resource = this.resource; + let constructor = EditorTypeForResource(resource); + + if (this.host.plugins) { + this.host.plugins.forEach(plugin => { + if (plugin.editorForResource) { + let pluginEditor = plugin.editorForResource(resource); + if (pluginEditor) { + constructor = pluginEditor; + } + } + }); + } + + return constructor; + } +}); + +/** + * The ShellDeck is in charge of managing the list of active Shells for + * the current ProjectEditor instance (aka host). + * + * This object emits the following events: + * - "editor-created": When an editor is initially created + * - "editor-activated": When an editor is ready to use + * - "editor-deactivated": When an editor is ready to use + */ +var ShellDeck = Class({ + extends: EventTarget, + + /** + * @param ProjectEditor host + * @param Document document + */ + initialize: function (host, document) { + this.doc = document; + this.host = host; + this.deck = this.doc.createElement("deck"); + this.deck.setAttribute("flex", "1"); + this.elt = this.deck; + + this.shells = new Map(); + + this._activeShell = null; + }, + + /** + * Open a resource in a Shell. Will create the Shell + * if it doesn't exist yet. + * + * @param Resource resource + * The file to be opened + * @returns Shell + */ + open: function (defaultResource) { + let shell = this.shellFor(defaultResource); + if (!shell) { + shell = this._createShell(defaultResource); + this.shells.set(defaultResource, shell); + } + this.selectShell(shell); + return shell; + }, + + /** + * Create a new Shell for a resource. Called by `open`. + * + * @returns Shell + */ + _createShell: function (defaultResource) { + let shell = Shell(this.host, defaultResource); + + shell.editorAppended.then(() => { + this.shells.set(shell.resource, shell); + emit(this, "editor-created", shell.editor); + if (this.currentShell === shell) { + this.selectShell(shell); + } + + }); + + shell.load(); + this.deck.appendChild(shell.elt); + return shell; + }, + + /** + * Remove the shell for a given resource. + * + * @param Resource resource + */ + removeResource: function (resource) { + let shell = this.shellFor(resource); + if (shell) { + this.shells.delete(resource); + shell.destroy(); + } + }, + + destroy: function () { + for (let [resource, shell] of this.shells.entries()) { + this.shells.delete(resource); + shell.destroy(); + } + }, + + /** + * Select a given shell and open its editor. + * Will fire editor-deactivated on the old selected Shell (if any), + * and editor-activated on the new one once it is ready + * + * @param Shell shell + */ + selectShell: function (shell) { + // Don't fire another activate if this is already the active shell + if (this._activeShell != shell) { + if (this._activeShell) { + emit(this, "editor-deactivated", this._activeShell.editor, this._activeShell.resource); + } + this.deck.selectedPanel = shell.elt; + this._activeShell = shell; + + // Only reload the shell if the editor doesn't have local changes. + if (shell.editor.isClean()) { + shell.load(); + } + shell.editorLoaded.then(() => { + // Handle case where another shell has been requested before this + // one is finished loading. + if (this._activeShell === shell) { + emit(this, "editor-activated", shell.editor, shell.resource); + } + }); + } + }, + + /** + * Find a Shell for a Resource. + * + * @param Resource resource + * @returns Shell + */ + shellFor: function (resource) { + return this.shells.get(resource); + }, + + /** + * The currently active Shell. Note: the editor may not yet be available + * on the current shell. Best to wait for the 'editor-activated' event + * instead. + * + * @returns Shell + */ + get currentShell() { + return this._activeShell; + }, + + /** + * The currently active Editor, or null if it is not ready. + * + * @returns Editor + */ + get currentEditor() { + let shell = this.currentShell; + return shell ? shell.editor : null; + }, + +}); +exports.ShellDeck = ShellDeck; diff --git a/devtools/client/projecteditor/lib/stores/base.js b/devtools/client/projecteditor/lib/stores/base.js new file mode 100644 index 000000000..ef9495c77 --- /dev/null +++ b/devtools/client/projecteditor/lib/stores/base.js @@ -0,0 +1,58 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cc, Ci, Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { EventTarget } = require("sdk/event/target"); +const { emit } = require("sdk/event/core"); +const promise = require("promise"); + +/** + * A Store object maintains a collection of Resource objects stored in a tree. + * + * The Store class should not be instantiated directly. Instead, you should + * use a class extending it - right now this is only a LocalStore. + * + * Events: + * This object emits the 'resource-added' and 'resource-removed' events. + */ +var Store = Class({ + extends: EventTarget, + + /** + * Should be called during initialize() of a subclass. + */ + initStore: function () { + this.resources = new Map(); + }, + + refresh: function () { + return promise.resolve(); + }, + + /** + * Return a sorted Array of all Resources in the Store + */ + allResources: function () { + var resources = []; + function addResource(resource) { + resources.push(resource); + resource.childrenSorted.forEach(addResource); + } + addResource(this.root); + return resources; + }, + + notifyAdd: function (resource) { + emit(this, "resource-added", resource); + }, + + notifyRemove: function (resource) { + emit(this, "resource-removed", resource); + } +}); + +exports.Store = Store; diff --git a/devtools/client/projecteditor/lib/stores/local.js b/devtools/client/projecteditor/lib/stores/local.js new file mode 100644 index 000000000..1f782dadf --- /dev/null +++ b/devtools/client/projecteditor/lib/stores/local.js @@ -0,0 +1,215 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cc, Ci, Cu, ChromeWorker } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { OS } = require("resource://gre/modules/osfile.jsm"); +const { emit } = require("sdk/event/core"); +const { Store } = require("devtools/client/projecteditor/lib/stores/base"); +const { Task } = require("devtools/shared/task"); +const promise = require("promise"); +const Services = require("Services"); +const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event"); +const { FileResource } = require("devtools/client/projecteditor/lib/stores/resource"); + +const CHECK_LINKED_DIRECTORY_DELAY = 5000; +const SHOULD_LIVE_REFRESH = true; +// XXX: Ignores should be customizable +const IGNORE_REGEX = /(^\.)|(\~$)|(^node_modules$)/; + +/** + * A LocalStore object maintains a collection of Resource objects + * from the file system. + * + * This object emits the following events: + * - "resource-added": When a resource is added + * - "resource-removed": When a resource is removed + */ +var LocalStore = Class({ + extends: Store, + + defaultCategory: "js", + + initialize: function(path) { + this.initStore(); + this.path = OS.Path.normalize(path); + this.rootPath = this.path; + this.displayName = this.path; + this.root = this._forPath(this.path); + this.notifyAdd(this.root); + this.refreshLoop = this.refreshLoop.bind(this); + this.refreshLoop(); + }, + + destroy: function() { + clearTimeout(this._refreshTimeout); + + if (this._refreshDeferred) { + this._refreshDeferred.reject("destroy"); + } + if (this.worker) { + this.worker.terminate(); + } + + this._refreshTimeout = null; + this._refreshDeferred = null; + this.worker = null; + + if (this.root) { + forget(this, this.root); + this.root.destroy(); + } + }, + + toString: function() { return "[LocalStore:" + this.path + "]" }, + + /** + * Return a FileResource object for the given path. If a FileInfo + * is provided the resource will use it, otherwise the FileResource + * might not have full information until the next refresh. + * + * The following parameters are passed into the FileResource constructor + * See resource.js for information about them + * + * @param String path + * @param FileInfo info + * @returns Resource + */ + _forPath: function(path, info=null) { + if (this.resources.has(path)) { + return this.resources.get(path); + } + + let resource = FileResource(this, path, info); + this.resources.set(path, resource); + return resource; + }, + + /** + * Return a promise that resolves to a fully-functional FileResource + * within this project. This will hit the disk for stat info. + * options: + * + * create: If true, a resource will be created even if the underlying + * file doesn't exist. + */ + resourceFor: function(path, options) { + path = OS.Path.normalize(path); + + if (this.resources.has(path)) { + return promise.resolve(this.resources.get(path)); + } + + if (!this.contains(path)) { + return promise.reject(new Error(path + " does not belong to " + this.path)); + } + + return Task.spawn(function*() { + let parent = yield this.resourceFor(OS.Path.dirname(path)); + + let info; + try { + info = yield OS.File.stat(path); + } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) { + if (!options.create) { + throw ex; + } + } + + let resource = this._forPath(path, info); + parent.addChild(resource); + return resource; + }.bind(this)); + }, + + refreshLoop: function() { + // XXX: Once Bug 958280 adds a watch function, will not need to forever loop here. + this.refresh().then(() => { + if (SHOULD_LIVE_REFRESH) { + this._refreshTimeout = setTimeout(this.refreshLoop, + CHECK_LINKED_DIRECTORY_DELAY); + } + }); + }, + + _refreshTimeout: null, + _refreshDeferred: null, + + /** + * Refresh the directory structure. + */ + refresh: function(path=this.rootPath) { + if (this._refreshDeferred) { + return this._refreshDeferred.promise; + } + this._refreshDeferred = promise.defer(); + + let worker = this.worker = new ChromeWorker("chrome://devtools/content/projecteditor/lib/helpers/readdir.js"); + let start = Date.now(); + + worker.onmessage = evt => { + // console.log("Directory read finished in " + ( Date.now() - start ) +"ms", evt); + for (path in evt.data) { + let info = evt.data[path]; + info.path = path; + + let resource = this._forPath(path, info); + resource.info = info; + if (info.isDir) { + let newChildren = new Set(); + for (let childPath of info.children) { + childInfo = evt.data[childPath]; + newChildren.add(this._forPath(childPath, childInfo)); + } + resource.setChildren(newChildren); + } + resource.info.children = null; + } + + worker = null; + this._refreshDeferred.resolve(); + this._refreshDeferred = null; + }; + worker.onerror = ex => { + console.error(ex); + worker = null; + this._refreshDeferred.reject(ex); + this._refreshDeferred = null; + } + worker.postMessage({ path: this.rootPath, ignore: IGNORE_REGEX }); + return this._refreshDeferred.promise; + }, + + /** + * Returns true if the given path would be a child of the store's + * root directory. + */ + contains: function(path) { + path = OS.Path.normalize(path); + let thisPath = OS.Path.split(this.rootPath); + let thatPath = OS.Path.split(path) + + if (!(thisPath.absolute && thatPath.absolute)) { + throw new Error("Contains only works with absolute paths."); + } + + if (thisPath.winDrive && (thisPath.winDrive != thatPath.winDrive)) { + return false; + } + + if (thatPath.components.length <= thisPath.components.length) { + return false; + } + + for (let i = 0; i < thisPath.components.length; i++) { + if (thisPath.components[i] != thatPath.components[i]) { + return false; + } + } + return true; + } +}); +exports.LocalStore = LocalStore; diff --git a/devtools/client/projecteditor/lib/stores/moz.build b/devtools/client/projecteditor/lib/stores/moz.build new file mode 100644 index 000000000..5a6becd92 --- /dev/null +++ b/devtools/client/projecteditor/lib/stores/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( + 'base.js', + 'local.js', + 'resource.js', +) diff --git a/devtools/client/projecteditor/lib/stores/resource.js b/devtools/client/projecteditor/lib/stores/resource.js new file mode 100644 index 000000000..53e3e7348 --- /dev/null +++ b/devtools/client/projecteditor/lib/stores/resource.js @@ -0,0 +1,398 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Cc, Ci, Cu } = require("chrome"); +const { TextEncoder, TextDecoder } = require("sdk/io/buffer"); +const { Class } = require("sdk/core/heritage"); +const { EventTarget } = require("sdk/event/target"); +const { emit } = require("sdk/event/core"); +const URL = require("sdk/url"); +const promise = require("promise"); +const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {}); +const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {}); +const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); +const { Task } = require("devtools/shared/task"); + +const gDecoder = new TextDecoder(); +const gEncoder = new TextEncoder(); + +/** + * A Resource is a single file-like object that can be respresented + * as a file for ProjectEditor. + * + * The Resource class is not exported, and should not be instantiated + * Instead, you should use the FileResource class that extends it. + * + * This object emits the following events: + * - "children-changed": When a child has been added or removed. + * See setChildren. + * - "deleted": When the resource has been deleted. + */ +var Resource = Class({ + extends: EventTarget, + + refresh: function () { return promise.resolve(this); }, + destroy: function () { }, + delete: function () { }, + + setURI: function (uri) { + if (typeof (uri) === "string") { + uri = URL.URL(uri); + } + this.uri = uri; + }, + + /** + * Is there more than 1 child Resource? + */ + get hasChildren() { return this.children && this.children.size > 0; }, + + /** + * Is this Resource the root (top level for the store)? + */ + get isRoot() { + return !this.parent; + }, + + /** + * Sorted array of children for display + */ + get childrenSorted() { + if (!this.hasChildren) { + return []; + } + + return [...this.children].sort((a, b)=> { + // Put directories above files. + if (a.isDir !== b.isDir) { + return b.isDir; + } + return a.basename.toLowerCase() > b.basename.toLowerCase(); + }); + }, + + /** + * Set the children set of this Resource, and notify of any + * additions / removals that happened in the change. + */ + setChildren: function (newChildren) { + let oldChildren = this.children || new Set(); + let change = false; + + for (let child of oldChildren) { + if (!newChildren.has(child)) { + change = true; + child.parent = null; + this.store.notifyRemove(child); + } + } + + for (let child of newChildren) { + if (!oldChildren.has(child)) { + change = true; + child.parent = this; + this.store.notifyAdd(child); + } + } + + this.children = newChildren; + if (change) { + emit(this, "children-changed", this); + } + }, + + /** + * Add a resource to children set and notify of the change. + * + * @param Resource resource + */ + addChild: function (resource) { + this.children = this.children || new Set(); + + resource.parent = this; + this.children.add(resource); + this.store.notifyAdd(resource); + emit(this, "children-changed", this); + return resource; + }, + + /** + * Checks if current object has child with specific name. + * + * @param string name + */ + hasChild: function (name) { + for (let child of this.children) { + if (child.basename === name) { + return true; + } + } + return false; + }, + + /** + * Remove a resource to children set and notify of the change. + * + * @param Resource resource + */ + removeChild: function (resource) { + resource.parent = null; + this.children.remove(resource); + this.store.notifyRemove(resource); + emit(this, "children-changed", this); + return resource; + }, + + /** + * Return a set with children, children of children, etc - + * gathered recursively. + * + * @returns Set<Resource> + */ + allDescendants: function () { + let set = new Set(); + + function addChildren(item) { + if (!item.children) { + return; + } + + for (let child of item.children) { + set.add(child); + } + } + + addChildren(this); + for (let item of set) { + addChildren(item); + } + + return set; + }, +}); + +/** + * A FileResource is an implementation of Resource for a File System + * backing. This is exported, and should be used instead of Resource. + */ +var FileResource = Class({ + extends: Resource, + + /** + * @param Store store + * @param String path + * @param FileInfo info + * https://developer.mozilla.org/en-US/docs/JavaScript_OS.File/OS.File.Info + */ + initialize: function (store, path, info) { + this.store = store; + this.path = path; + + this.setURI(URL.URL(URL.fromFilename(path))); + this._lastReadModification = undefined; + + this.info = info; + this.parent = null; + }, + + toString: function () { + return "[FileResource:" + this.path + "]"; + }, + + destroy: function () { + if (this._refreshDeferred) { + this._refreshDeferred.reject(); + } + this._refreshDeferred = null; + }, + + /** + * Fetch and cache information about this particular file. + * https://developer.mozilla.org/en-US/docs/JavaScript_OS.File/OS.File_for_the_main_thread#OS.File.stat + * + * @returns Promise + * Resolves once the File.stat has finished. + */ + refresh: function () { + if (this._refreshDeferred) { + return this._refreshDeferred.promise; + } + this._refreshDeferred = promise.defer(); + OS.File.stat(this.path).then(info => { + this.info = info; + if (this._refreshDeferred) { + this._refreshDeferred.resolve(this); + this._refreshDeferred = null; + } + }); + return this._refreshDeferred.promise; + }, + + /** + * Return the trailing name component of this Resource + */ + get basename() { + return this.path.replace(/\/+$/, "").replace(/\\/g, "/").replace(/.*\//, ""); + }, + + /** + * A string to be used when displaying this Resource in views + */ + get displayName() { + return this.basename + (this.isDir ? "/" : ""); + }, + + /** + * Is this FileResource a directory? Rather than checking children + * here, we use this.info. So this could return a false negative + * if there was no info passed in on constructor and the first + * refresh hasn't yet finished. + */ + get isDir() { + if (!this.info) { return false; } + return this.info.isDir && !this.info.isSymLink; + }, + + /** + * Read the file as a string asynchronously. + * + * @returns Promise + * Resolves with the text of the file. + */ + load: function () { + return OS.File.read(this.path).then(bytes => { + return gDecoder.decode(bytes); + }); + }, + + /** + * Delete the file from the filesystem + * + * @returns Promise + * Resolves when the file is deleted + */ + delete: function () { + emit(this, "deleted", this); + if (this.isDir) { + return OS.File.removeDir(this.path); + } else { + return OS.File.remove(this.path); + } + }, + + /** + * Add a text file as a child of this FileResource. + * This instance must be a directory. + * + * @param string name + * The filename (path will be generated based on this.path). + * string initial + * The content to write to the new file. + * @returns Promise + * Resolves with the new FileResource once it has + * been written to disk. + * Rejected if this is not a directory. + */ + createChild: function (name, initial = "") { + if (!this.isDir) { + return promise.reject(new Error("Cannot add child to a regular file")); + } + + let newPath = OS.Path.join(this.path, name); + + let buffer = initial ? gEncoder.encode(initial) : ""; + return OS.File.writeAtomic(newPath, buffer, { + noOverwrite: true + }).then(() => { + return this.store.refresh(); + }).then(() => { + let resource = this.store.resources.get(newPath); + if (!resource) { + throw new Error("Error creating " + newPath); + } + return resource; + }); + }, + + /** + * Rename the file from the filesystem + * + * @returns Promise + * Resolves with the renamed FileResource. + */ + rename: function (oldName, newName) { + let oldPath = OS.Path.join(this.path, oldName); + let newPath = OS.Path.join(this.path, newName); + + return OS.File.move(oldPath, newPath).then(() => { + return this.store.refresh(); + }).then(() => { + let resource = this.store.resources.get(newPath); + if (!resource) { + throw new Error("Error creating " + newPath); + } + return resource; + }); + }, + + /** + * Write a string to this file. + * + * @param string content + * @returns Promise + * Resolves once it has been written to disk. + * Rejected if there is an error + */ + save: Task.async(function* (content) { + // XXX: writeAtomic was losing permissions after saving on OSX + // return OS.File.writeAtomic(this.path, buffer, { tmpPath: this.path + ".tmp" }); + let buffer = gEncoder.encode(content); + let path = this.path; + let file = yield OS.File.open(path, {truncate: true}); + yield file.write(buffer); + yield file.close(); + }), + + /** + * Attempts to get the content type from the file. + */ + get contentType() { + if (this._contentType) { + return this._contentType; + } + if (this.isDir) { + return "x-directory/normal"; + } + try { + this._contentType = mimeService.getTypeFromFile(new FileUtils.File(this.path)); + } catch (ex) { + if (ex.name !== "NS_ERROR_NOT_AVAILABLE" && + ex.name !== "NS_ERROR_FAILURE") { + console.error(ex, this.path); + } + this._contentType = null; + } + return this._contentType; + }, + + /** + * A string used when determining the type of Editor to open for this. + * See editors.js -> EditorTypeForResource. + */ + get contentCategory() { + const NetworkHelper = require("devtools/shared/webconsole/network-helper"); + let category = NetworkHelper.mimeCategoryMap[this.contentType]; + // Special treatment for manifest.webapp. + if (!category && this.basename === "manifest.webapp") { + return "json"; + } + return category || "txt"; + } +}); + +exports.FileResource = FileResource; diff --git a/devtools/client/projecteditor/lib/tree.js b/devtools/client/projecteditor/lib/tree.js new file mode 100644 index 000000000..50597804d --- /dev/null +++ b/devtools/client/projecteditor/lib/tree.js @@ -0,0 +1,593 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { Cu } = require("chrome"); +const { Class } = require("sdk/core/heritage"); +const { emit } = require("sdk/event/core"); +const { EventTarget } = require("sdk/event/target"); +const { merge } = require("sdk/util/object"); +const promise = require("promise"); +const { InplaceEditor } = require("devtools/client/shared/inplace-editor"); +const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event"); +const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {}); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * ResourceContainer is used as the view of a single Resource in + * the tree. It is not exported. + */ +var ResourceContainer = Class({ + /** + * @param ProjectTreeView tree + * @param Resource resource + */ + initialize: function (tree, resource) { + this.tree = tree; + this.resource = resource; + this.elt = null; + this.expander = null; + this.children = null; + + let doc = tree.doc; + + this.elt = doc.createElementNS(HTML_NS, "li"); + this.elt.classList.add("child"); + + this.line = doc.createElementNS(HTML_NS, "div"); + this.line.classList.add("child"); + this.line.classList.add("entry"); + this.line.setAttribute("theme", "dark"); + this.line.setAttribute("tabindex", "0"); + + this.elt.appendChild(this.line); + + this.highlighter = doc.createElementNS(HTML_NS, "span"); + this.highlighter.classList.add("highlighter"); + this.line.appendChild(this.highlighter); + + this.expander = doc.createElementNS(HTML_NS, "span"); + this.expander.className = "arrow expander"; + this.expander.setAttribute("open", ""); + this.line.appendChild(this.expander); + + this.label = doc.createElementNS(HTML_NS, "span"); + this.label.className = "file-label"; + this.line.appendChild(this.label); + + this.line.addEventListener("contextmenu", (ev) => { + this.select(); + this.openContextMenu(ev); + }, false); + + this.children = doc.createElementNS(HTML_NS, "ul"); + this.children.classList.add("children"); + + this.elt.appendChild(this.children); + + this.line.addEventListener("click", (evt) => { + this.select(); + this.toggleExpansion(); + evt.stopPropagation(); + }, false); + this.expander.addEventListener("click", (evt) => { + this.toggleExpansion(); + this.select(); + evt.stopPropagation(); + }, true); + + if (!this.resource.isRoot) { + this.expanded = false; + } + this.update(); + }, + + toggleExpansion: function () { + if (!this.resource.isRoot) { + this.expanded = !this.expanded; + } else { + this.expanded = true; + } + }, + + destroy: function () { + this.elt.remove(); + this.expander.remove(); + this.highlighter.remove(); + this.children.remove(); + this.label.remove(); + this.elt = this.expander = this.highlighter = this.children = this.label = null; + }, + + /** + * Open the context menu when right clicking on the view. + * XXX: We could pass this to plugins to allow themselves + * to be register/remove items from the context menu if needed. + * + * @param Event e + */ + openContextMenu: function (ev) { + ev.preventDefault(); + let popup = this.tree.options.contextMenuPopup; + popup.openPopupAtScreen(ev.screenX, ev.screenY, true); + }, + + /** + * Update the view based on the current state of the Resource. + */ + update: function () { + let visible = this.tree.options.resourceVisible ? + this.tree.options.resourceVisible(this.resource) : + true; + + this.elt.hidden = !visible; + + this.tree.options.resourceFormatter(this.resource, this.label); + + this.expander.style.visibility = this.resource.hasChildren ? "visible" : "hidden"; + + }, + + /** + * Select this view in the ProjectTreeView. + */ + select: function () { + this.tree.selectContainer(this); + }, + + /** + * @returns Boolean + * Is this view currently selected + */ + get selected() { + return this.line.classList.contains("selected"); + }, + + /** + * Set the selected state in the UI. + */ + set selected(v) { + if (v) { + this.line.classList.add("selected"); + } else { + this.line.classList.remove("selected"); + } + }, + + /** + * @returns Boolean + * Are any children visible. + */ + get expanded() { + return !this.elt.classList.contains("tree-collapsed"); + }, + + /** + * Set the visiblity state of children. + */ + set expanded(v) { + if (v) { + this.elt.classList.remove("tree-collapsed"); + this.expander.setAttribute("open", ""); + } else { + this.expander.removeAttribute("open"); + this.elt.classList.add("tree-collapsed"); + } + } +}); + +/** + * TreeView is a view managing a list of children. + * It is not to be instantiated directly - only extended. + * Use ProjectTreeView instead. + */ +var TreeView = Class({ + extends: EventTarget, + + /** + * @param Document document + * @param Object options + * - contextMenuPopup: a <menupopup> element + * - resourceFormatter: a function(Resource, DOMNode) + * that renders the resource into the view + * - resourceVisible: a function(Resource) -> Boolean + * that determines if the resource should show up. + */ + initialize: function (doc, options) { + this.doc = doc; + this.options = merge({ + resourceFormatter: function (resource, elt) { + elt.textContent = resource.toString(); + } + }, options); + this.models = new Set(); + this.roots = new Set(); + this._containers = new Map(); + this.elt = this.doc.createElementNS(HTML_NS, "div"); + this.elt.tree = this; + this.elt.className = "sources-tree"; + this.elt.setAttribute("with-arrows", "true"); + this.elt.setAttribute("theme", "dark"); + this.elt.setAttribute("flex", "1"); + + this.children = this.doc.createElementNS(HTML_NS, "ul"); + this.elt.appendChild(this.children); + + this.resourceChildrenChanged = this.resourceChildrenChanged.bind(this); + this.removeResource = this.removeResource.bind(this); + this.updateResource = this.updateResource.bind(this); + }, + + destroy: function () { + this._destroyed = true; + this.elt.remove(); + }, + + /** + * Helper function to create DOM elements for promptNew and promptEdit + */ + createInputContainer: function () { + let inputholder = this.doc.createElementNS(HTML_NS, "div"); + inputholder.className = "child entry"; + + let expander = this.doc.createElementNS(HTML_NS, "span"); + expander.className = "arrow expander"; + expander.setAttribute("invisible", ""); + inputholder.appendChild(expander); + + let placeholder = this.doc.createElementNS(HTML_NS, "div"); + placeholder.className = "child"; + inputholder.appendChild(placeholder); + + return {inputholder, placeholder}; + }, + + /** + * Prompt the user to create a new file in the tree. + * + * @param string initial + * The suggested starting file name + * @param Resource parent + * @param Resource sibling + * Which resource to put this next to. If not set, + * it will be put in front of all other children. + * + * @returns Promise + * Resolves once the prompt has been successful, + * Rejected if it is cancelled + */ + promptNew: function (initial, parent, sibling = null) { + let deferred = promise.defer(); + + let parentContainer = this._containers.get(parent); + let item = this.doc.createElement("li"); + item.className = "child"; + + let {inputholder, placeholder} = this.createInputContainer(); + item.appendChild(inputholder); + + let children = parentContainer.children; + sibling = sibling ? this._containers.get(sibling).elt : null; + parentContainer.children.insertBefore(item, sibling ? sibling.nextSibling : children.firstChild); + + new InplaceEditor({ + element: placeholder, + initial: initial, + preserveTextStyles: true, + start: editor => { + editor.input.select(); + }, + done: function (val, commit) { + if (commit) { + deferred.resolve(val); + } else { + deferred.reject(val); + } + parentContainer.line.focus(); + }, + destroy: () => { + item.parentNode.removeChild(item); + }, + }); + + return deferred.promise; + }, + + /** + * Prompt the user to rename file in the tree. + * + * @param string initial + * The suggested starting file name + * @param resource + * + * @returns Promise + * Resolves once the prompt has been successful, + * Rejected if it is cancelled + */ + promptEdit: function (initial, resource) { + let deferred = promise.defer(); + let item = this._containers.get(resource).elt; + let originalText = item.childNodes[0]; + + let {inputholder, placeholder} = this.createInputContainer(); + item.insertBefore(inputholder, originalText); + + item.removeChild(originalText); + + new InplaceEditor({ + element: placeholder, + initial: initial, + preserveTextStyles: true, + start: editor => { + editor.input.select(); + }, + done: function (val, commit) { + if (val === initial) { + item.insertBefore(originalText, inputholder); + } + + item.removeChild(inputholder); + + if (commit) { + deferred.resolve(val); + } else { + deferred.reject(val); + } + }, + }); + + return deferred.promise; + }, + + /** + * Add a new Store into the TreeView + * + * @param Store model + */ + addModel: function (model) { + if (this.models.has(model)) { + // Requesting to add a model that already exists + return; + } + this.models.add(model); + let placeholder = this.doc.createElementNS(HTML_NS, "li"); + placeholder.style.display = "none"; + this.children.appendChild(placeholder); + this.roots.add(model.root); + model.root.refresh().then(root => { + if (this._destroyed || !this.models.has(model)) { + // model may have been removed during the initial refresh. + // In this case, do not import the resource or add to DOM, just leave it be. + return; + } + let container = this.importResource(root); + container.line.classList.add("entry-group-title"); + container.line.setAttribute("theme", "dark"); + this.selectContainer(container); + + this.children.insertBefore(container.elt, placeholder); + this.children.removeChild(placeholder); + }); + }, + + /** + * Remove a Store from the TreeView + * + * @param Store model + */ + removeModel: function (model) { + this.models.delete(model); + this.removeResource(model.root); + }, + + + /** + * Get the ResourceContainer. Used for testing the view. + * + * @param Resource resource + * @returns ResourceContainer + */ + getViewContainer: function (resource) { + return this._containers.get(resource); + }, + + /** + * Select a ResourceContainer in the tree. + * + * @param ResourceContainer container + */ + selectContainer: function (container) { + if (this.selectedContainer === container) { + return; + } + if (this.selectedContainer) { + this.selectedContainer.selected = false; + } + this.selectedContainer = container; + container.selected = true; + emit(this, "selection", container.resource); + }, + + /** + * Select a Resource in the tree. + * + * @param Resource resource + */ + selectResource: function (resource) { + this.selectContainer(this._containers.get(resource)); + }, + + /** + * Get the currently selected Resource + * + * @param Resource resource + */ + getSelectedResource: function () { + return this.selectedContainer.resource; + }, + + /** + * Insert a Resource into the view. + * Makes a new ResourceContainer if needed + * + * @param Resource resource + */ + importResource: function (resource) { + if (!resource) { + return null; + } + + if (this._containers.has(resource)) { + return this._containers.get(resource); + } + var container = ResourceContainer(this, resource); + this._containers.set(resource, container); + this._updateChildren(container); + + on(this, resource, "children-changed", this.resourceChildrenChanged); + on(this, resource, "label-change", this.updateResource); + on(this, resource, "deleted", this.removeResource); + + return container; + }, + + /** + * Remove a Resource (including children) from the view. + * + * @param Resource resource + */ + removeResource: function (resource) { + let toRemove = resource.allDescendants(); + toRemove.add(resource); + for (let remove of toRemove) { + this._removeResource(remove); + } + }, + + /** + * Remove an individual Resource (but not children) from the view. + * + * @param Resource resource + */ + _removeResource: function (resource) { + forget(this, resource); + if (this._containers.get(resource)) { + this._containers.get(resource).destroy(); + this._containers.delete(resource); + } + emit(this, "resource-removed", resource); + }, + + /** + * Listener for when a resource has new children. + * This can happen as files are being loaded in from FileSystem, for example. + * + * @param Resource resource + */ + resourceChildrenChanged: function (resource) { + this.updateResource(resource); + this._updateChildren(this._containers.get(resource)); + }, + + /** + * Listener for when a label in the view has been updated. + * For example, the 'dirty' plugin marks changed files with an '*' + * next to the filename, and notifies with this event. + * + * @param Resource resource + */ + updateResource: function (resource) { + let container = this._containers.get(resource); + container.update(); + }, + + /** + * Build necessary ResourceContainers for a Resource and its + * children, then append them into the view. + * + * @param ResourceContainer container + */ + _updateChildren: function (container) { + let resource = container.resource; + let fragment = this.doc.createDocumentFragment(); + if (resource.children) { + for (let child of resource.childrenSorted) { + let childContainer = this.importResource(child); + fragment.appendChild(childContainer.elt); + } + } + + while (container.children.firstChild) { + container.children.firstChild.remove(); + } + + container.children.appendChild(fragment); + }, +}); + +/** + * ProjectTreeView is the implementation of TreeView + * that is exported. This is the class that is to be used + * directly. + */ +var ProjectTreeView = Class({ + extends: TreeView, + + /** + * See TreeView.initialize + * + * @param Document document + * @param Object options + */ + initialize: function (document, options) { + TreeView.prototype.initialize.apply(this, arguments); + }, + + destroy: function () { + this.forgetProject(); + TreeView.prototype.destroy.apply(this, arguments); + }, + + /** + * Remove current project and empty the tree + */ + forgetProject: function () { + if (this.project) { + forget(this, this.project); + for (let store of this.project.allStores()) { + this.removeModel(store); + } + } + }, + + /** + * Show a project in the tree + * + * @param Project project + * The project to render into a tree + */ + setProject: function (project) { + this.forgetProject(); + this.project = project; + if (this.project) { + on(this, project, "store-added", this.addModel.bind(this)); + on(this, project, "store-removed", this.removeModel.bind(this)); + on(this, project, "project-saved", this.refresh.bind(this)); + this.refresh(); + } + }, + + /** + * Refresh the tree with all of the current project stores + */ + refresh: function () { + for (let store of this.project.allStores()) { + this.addModel(store); + } + } +}); + +exports.ProjectTreeView = ProjectTreeView; diff --git a/devtools/client/projecteditor/moz.build b/devtools/client/projecteditor/moz.build new file mode 100644 index 000000000..049493833 --- /dev/null +++ b/devtools/client/projecteditor/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/. + +DIRS += ['lib'] + +BROWSER_CHROME_MANIFESTS += ['test/browser.ini'] diff --git a/devtools/client/projecteditor/test/.eslintrc.js b/devtools/client/projecteditor/test/.eslintrc.js new file mode 100644 index 000000000..8d15a76d9 --- /dev/null +++ b/devtools/client/projecteditor/test/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + "extends": "../../../.eslintrc.mochitests.js" +}; diff --git a/devtools/client/projecteditor/test/browser.ini b/devtools/client/projecteditor/test/browser.ini new file mode 100644 index 000000000..e7fdc7ae5 --- /dev/null +++ b/devtools/client/projecteditor/test/browser.ini @@ -0,0 +1,31 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + head.js + helper_homepage.html + helper_edits.js + +[browser_projecteditor_app_options.js] +[browser_projecteditor_confirm_unsaved.js] +[browser_projecteditor_contextmenu_01.js] +skip-if = asan # Bug 1083140 +[browser_projecteditor_contextmenu_02.js] +skip-if = true # Bug 1173950 +[browser_projecteditor_delete_file.js] +skip-if = e10s # Frequent failures in e10s - Bug 1020027 +[browser_projecteditor_rename_file_01.js] +[browser_projecteditor_rename_file_02.js] +[browser_projecteditor_editing_01.js] +[browser_projecteditor_editors_image.js] +[browser_projecteditor_external_change.js] +[browser_projecteditor_immediate_destroy.js] +[browser_projecteditor_init.js] +[browser_projecteditor_menubar_01.js] +[browser_projecteditor_menubar_02.js] +skip-if = true # Bug 1173950 +[browser_projecteditor_new_file.js] +[browser_projecteditor_saveall.js] +[browser_projecteditor_stores.js] +[browser_projecteditor_tree_selection_01.js] +[browser_projecteditor_tree_selection_02.js] diff --git a/devtools/client/projecteditor/test/browser_projecteditor_app_options.js b/devtools/client/projecteditor/test/browser_projecteditor_app_options.js new file mode 100644 index 000000000..aa608e205 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_app_options.js @@ -0,0 +1,87 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that options can be changed without resetting the whole +// editor. +add_task(function* () { + + let TEMP_PATH = buildTempDirectoryStructure(); + let projecteditor = yield addProjectEditorTab(); + + let resourceBeenAdded = promise.defer(); + projecteditor.project.once("resource-added", () => { + info("A resource has been added"); + resourceBeenAdded.resolve(); + }); + + info("About to set project to: " + TEMP_PATH); + yield projecteditor.setProjectToAppPath(TEMP_PATH, { + name: "Test", + iconUrl: "chrome://devtools/skin/images/tool-options.svg", + projectOverviewURL: SAMPLE_WEBAPP_URL + }); + + info("Making sure a resource has been added before continuing"); + yield resourceBeenAdded.promise; + + info("From now on, if a resource is added it should fail"); + projecteditor.project.on("resource-added", failIfResourceAdded); + + info("Getting ahold and validating the project header DOM"); + let header = projecteditor.document.querySelector(".entry-group-title"); + let image = header.querySelector(".project-image"); + let nameLabel = header.querySelector(".project-name-label"); + let statusElement = header.querySelector(".project-status"); + is(statusElement.getAttribute("status"), "unknown", "The status starts out as unknown."); + is(nameLabel.textContent, "Test", "The name label has been set correctly"); + is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-options.svg", "The icon has been set correctly"); + + info("About to set project with new options."); + yield projecteditor.setProjectToAppPath(TEMP_PATH, { + name: "Test2", + iconUrl: "chrome://devtools/skin/images/tool-inspector.svg", + projectOverviewURL: SAMPLE_WEBAPP_URL, + validationStatus: "error" + }); + + info("Getting ahold of and validating the project header DOM"); + is(statusElement.getAttribute("status"), "error", "The status has been set correctly."); + is(nameLabel.textContent, "Test2", "The name label has been set correctly"); + is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-inspector.svg", "The icon has been set correctly"); + + info("About to set project with new options."); + yield projecteditor.setProjectToAppPath(TEMP_PATH, { + name: "Test3", + iconUrl: "chrome://devtools/skin/images/tool-webconsole.svg", + projectOverviewURL: SAMPLE_WEBAPP_URL, + validationStatus: "warning" + }); + + info("Getting ahold of and validating the project header DOM"); + is(statusElement.getAttribute("status"), "warning", "The status has been set correctly."); + is(nameLabel.textContent, "Test3", "The name label has been set correctly"); + is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-webconsole.svg", "The icon has been set correctly"); + + info("About to set project with new options."); + yield projecteditor.setProjectToAppPath(TEMP_PATH, { + name: "Test4", + iconUrl: "chrome://devtools/skin/images/tool-debugger.svg", + projectOverviewURL: SAMPLE_WEBAPP_URL, + validationStatus: "valid" + }); + + info("Getting ahold of and validating the project header DOM"); + is(statusElement.getAttribute("status"), "valid", "The status has been set correctly."); + is(nameLabel.textContent, "Test4", "The name label has been set correctly"); + is(image.getAttribute("src"), "chrome://devtools/skin/images/tool-debugger.svg", "The icon has been set correctly"); + + info("Test finished, cleaning up"); + projecteditor.project.off("resource-added", failIfResourceAdded); +}); + +function failIfResourceAdded() { + ok(false, "A resource has been added, but it shouldn't have been"); +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_confirm_unsaved.js b/devtools/client/projecteditor/test/browser_projecteditor_confirm_unsaved.js new file mode 100644 index 000000000..72640d243 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_confirm_unsaved.js @@ -0,0 +1,60 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadHelperScript("helper_edits.js"); + +// Test that a prompt shows up when requested if a file is unsaved. +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + ok(true, "ProjectEditor has loaded"); + + let resources = projecteditor.project.allResources(); + yield selectFile(projecteditor, resources[2]); + let editor = projecteditor.currentEditor; + let originalText = editor.editor.getText(); + + ok(!projecteditor.hasUnsavedResources, "There are no unsaved resources"); + ok(projecteditor.confirmUnsaved(), "When there are no unsaved changes, confirmUnsaved() is true"); + editor.editor.setText("bar"); + editor.editor.setText(originalText); + ok(!projecteditor.hasUnsavedResources, "There are no unsaved resources"); + ok(projecteditor.confirmUnsaved(), "When an editor has changed but is still the original text, confirmUnsaved() is true"); + + editor.editor.setText("bar"); + + checkConfirmYes(projecteditor); + checkConfirmNo(projecteditor); +}); + +function checkConfirmYes(projecteditor, container) { + function confirmYes(aSubject) { + info("confirm dialog observed as expected, going to click OK"); + Services.obs.removeObserver(confirmYes, "common-dialog-loaded"); + Services.obs.removeObserver(confirmYes, "tabmodal-dialog-loaded"); + aSubject.Dialog.ui.button0.click(); + } + + Services.obs.addObserver(confirmYes, "common-dialog-loaded", false); + Services.obs.addObserver(confirmYes, "tabmodal-dialog-loaded", false); + + ok(projecteditor.hasUnsavedResources, "There are unsaved resources"); + ok(projecteditor.confirmUnsaved(), "When there are unsaved changes, clicking OK makes confirmUnsaved() true"); +} + +function checkConfirmNo(projecteditor, container) { + function confirmNo(aSubject) { + info("confirm dialog observed as expected, going to click cancel"); + Services.obs.removeObserver(confirmNo, "common-dialog-loaded"); + Services.obs.removeObserver(confirmNo, "tabmodal-dialog-loaded"); + aSubject.Dialog.ui.button1.click(); + } + + Services.obs.addObserver(confirmNo, "common-dialog-loaded", false); + Services.obs.addObserver(confirmNo, "tabmodal-dialog-loaded", false); + + ok(projecteditor.hasUnsavedResources, "There are unsaved resources"); + ok(!projecteditor.confirmUnsaved(), "When there are unsaved changes, clicking cancel makes confirmUnsaved() false"); +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_01.js b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_01.js new file mode 100644 index 000000000..44ffe1722 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_01.js @@ -0,0 +1,27 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that context menus append to the correct document. + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory({ + menubar: false + }); + ok(projecteditor, "ProjectEditor has loaded"); + + let contextMenuPopup = projecteditor.document.querySelector("#context-menu-popup"); + let textEditorContextMenuPopup = projecteditor.document.querySelector("#texteditor-context-popup"); + ok(contextMenuPopup, "The menu has loaded in the projecteditor document"); + ok(textEditorContextMenuPopup, "The menu has loaded in the projecteditor document"); + + let projecteditor2 = yield addProjectEditorTabForTempDirectory(); + contextMenuPopup = projecteditor2.document.getElementById("context-menu-popup"); + textEditorContextMenuPopup = projecteditor2.document.getElementById("texteditor-context-popup"); + ok(!contextMenuPopup, "The menu has NOT loaded in the projecteditor document"); + ok(!textEditorContextMenuPopup, "The menu has NOT loaded in the projecteditor document"); + ok(content.document.querySelector("#context-menu-popup"), "The menu has loaded in the specified element"); + ok(content.document.querySelector("#texteditor-context-popup"), "The menu has loaded in the specified element"); +}); diff --git a/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_02.js b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_02.js new file mode 100644 index 000000000..cf43b3e21 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_contextmenu_02.js @@ -0,0 +1,66 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadHelperScript("helper_edits.js"); + +// Test context menu enabled / disabled state in editor + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + ok(projecteditor, "ProjectEditor has loaded"); + + let {textEditorContextMenuPopup} = projecteditor; + + // Update menu items for a clean slate, so previous tests cannot + // affect paste, and possibly other side effects + projecteditor._updateMenuItems(); + + let cmdDelete = textEditorContextMenuPopup.querySelector("[command=cmd_delete]"); + let cmdSelectAll = textEditorContextMenuPopup.querySelector("[command=cmd_selectAll]"); + let cmdCut = textEditorContextMenuPopup.querySelector("[command=cmd_cut]"); + let cmdCopy = textEditorContextMenuPopup.querySelector("[command=cmd_copy]"); + let cmdPaste = textEditorContextMenuPopup.querySelector("[command=cmd_paste]"); + + info("Opening resource"); + let resource = projecteditor.project.allResources()[2]; + yield selectFile(projecteditor, resource); + let editor = projecteditor.currentEditor; + editor.editor.focus(); + + info("Opening context menu on resource"); + yield openContextMenuForEditor(editor, textEditorContextMenuPopup); + + is(cmdDelete.getAttribute("disabled"), "true", "cmdDelete is disabled"); + is(cmdSelectAll.getAttribute("disabled"), "", "cmdSelectAll is enabled"); + is(cmdCut.getAttribute("disabled"), "true", "cmdCut is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "cmdCopy is disabled"); + is(cmdPaste.getAttribute("disabled"), "", "cmdPaste is enabled"); + + info("Setting a selection and repening context menu on resource"); + yield closeContextMenuForEditor(editor, textEditorContextMenuPopup); + editor.editor.setSelection({line: 0, ch: 0}, {line: 0, ch: 2}); + yield openContextMenuForEditor(editor, textEditorContextMenuPopup); + + is(cmdDelete.getAttribute("disabled"), "", "cmdDelete is enabled"); + is(cmdSelectAll.getAttribute("disabled"), "", "cmdSelectAll is enabled"); + is(cmdCut.getAttribute("disabled"), "", "cmdCut is enabled"); + is(cmdCopy.getAttribute("disabled"), "", "cmdCopy is enabled"); + is(cmdPaste.getAttribute("disabled"), "", "cmdPaste is enabled"); +}); + +function* openContextMenuForEditor(editor, contextMenu) { + let editorDoc = editor.editor.container.contentDocument; + let shown = onPopupShow(contextMenu); + EventUtils.synthesizeMouse(editorDoc.body, 2, 2, + {type: "contextmenu", button: 2}, editorDoc.defaultView); + yield shown; +} +function* closeContextMenuForEditor(editor, contextMenu) { + let editorDoc = editor.editor.container.contentDocument; + let hidden = onPopupHidden(contextMenu); + contextMenu.hidePopup(); + yield hidden; +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_delete_file.js b/devtools/client/projecteditor/test/browser_projecteditor_delete_file.js new file mode 100644 index 000000000..446c1dbcb --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_delete_file.js @@ -0,0 +1,85 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test tree selection functionality + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + ok(true, "ProjectEditor has loaded"); + + let root = [...projecteditor.project.allStores()][0].root; + is(root.path, TEMP_PATH, "The root store is set to the correct temp path."); + for (let child of root.children) { + yield deleteWithContextMenu(projecteditor, projecteditor.projectTree.getViewContainer(child)); + } + + yield testDeleteOnRoot(projecteditor, projecteditor.projectTree.getViewContainer(root)); +}); + + +function openContextMenuOn(node) { + EventUtils.synthesizeMouseAtCenter( + node, + {button: 2, type: "contextmenu"}, + node.ownerDocument.defaultView + ); +} + +function* testDeleteOnRoot(projecteditor, container) { + let popup = projecteditor.contextMenuPopup; + let oncePopupShown = onPopupShow(popup); + openContextMenuOn(container.label); + yield oncePopupShown; + + let deleteCommand = popup.querySelector("[command=cmd-delete]"); + ok(deleteCommand, "Delete command exists in popup"); + is(deleteCommand.getAttribute("hidden"), "true", "Delete command is hidden"); +} + +function deleteWithContextMenu(projecteditor, container) { + let defer = promise.defer(); + + let popup = projecteditor.contextMenuPopup; + let resource = container.resource; + info("Going to attempt deletion for: " + resource.path); + + onPopupShow(popup).then(function () { + let deleteCommand = popup.querySelector("[command=cmd-delete]"); + ok(deleteCommand, "Delete command exists in popup"); + is(deleteCommand.getAttribute("hidden"), "", "Delete command is visible"); + is(deleteCommand.getAttribute("disabled"), "", "Delete command is enabled"); + + function onConfirmShown(aSubject) { + info("confirm dialog observed as expected"); + Services.obs.removeObserver(onConfirmShown, "common-dialog-loaded"); + Services.obs.removeObserver(onConfirmShown, "tabmodal-dialog-loaded"); + + projecteditor.project.on("refresh-complete", function refreshComplete() { + projecteditor.project.off("refresh-complete", refreshComplete); + OS.File.stat(resource.path).then(() => { + ok(false, "The file was not deleted"); + defer.resolve(); + }, (ex) => { + ok(ex instanceof OS.File.Error && ex.becauseNoSuchFile, "OS.File.stat promise was rejected because the file is gone"); + defer.resolve(); + }); + }); + + // Click the 'OK' button + aSubject.Dialog.ui.button0.click(); + } + + Services.obs.addObserver(onConfirmShown, "common-dialog-loaded", false); + Services.obs.addObserver(onConfirmShown, "tabmodal-dialog-loaded", false); + + deleteCommand.click(); + popup.hidePopup(); + }); + + openContextMenuOn(container.label); + + return defer.promise; +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_editing_01.js b/devtools/client/projecteditor/test/browser_projecteditor_editing_01.js new file mode 100644 index 000000000..c7ff1c0be --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_editing_01.js @@ -0,0 +1,70 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy"); + +loadHelperScript("helper_edits.js"); + +// Test ProjectEditor basic functionality +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let TEMP_PATH = projecteditor.project.allPaths()[0]; + + is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly."); + + ok(projecteditor.currentEditor, "There is an editor for projecteditor"); + let resources = projecteditor.project.allResources(); + + for (let data of helperEditData) { + info("Processing " + data.path); + let resource = resources.filter(r=>r.basename === data.basename)[0]; + yield selectFile(projecteditor, resource); + yield testEditFile(projecteditor, getTempFile(data.path).path, data.newContent); + } +}); + +function* testEditFile(projecteditor, filePath, newData) { + info("Testing file editing for: " + filePath); + + let initialData = yield getFileData(filePath); + let editor = projecteditor.currentEditor; + let resource = projecteditor.resourceFor(editor); + let viewContainer = projecteditor.projectTree.getViewContainer(resource); + let originalTreeLabel = viewContainer.label.textContent; + + is(resource.path, filePath, "Resource path is set correctly"); + is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents"); + + info("Setting text in the editor and doing checks before saving"); + + editor.editor.undo(); + editor.editor.undo(); + is(editor.editor.getText(), initialData, "Editor is still loaded with correct contents after undo"); + + editor.editor.setText(newData); + is(editor.editor.getText(), newData, "Editor has been filled with new data"); + is(viewContainer.label.textContent, "*" + originalTreeLabel, "Label is marked as changed"); + + info("Saving the editor and checking to make sure the file gets saved on disk"); + + editor.save(resource); + + let savedResource = yield onceEditorSave(projecteditor); + + is(viewContainer.label.textContent, originalTreeLabel, "Label is unmarked as changed"); + is(savedResource.path, filePath, "The saved resouce path matches the original file path"); + is(savedResource, resource, "The saved resource is the same as the original resource"); + + let savedData = yield getFileData(filePath); + is(savedData, newData, "Data has been correctly saved to disk"); + + info("Finished checking saving for " + filePath); + +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_editors_image.js b/devtools/client/projecteditor/test/browser_projecteditor_editors_image.js new file mode 100644 index 000000000..0b19cb5d1 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_editors_image.js @@ -0,0 +1,74 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy"); + +loadHelperScript("helper_edits.js"); + +// Test ProjectEditor image editor functionality +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let TEMP_PATH = projecteditor.project.allPaths()[0]; + + is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly."); + + ok(projecteditor.currentEditor, "There is an editor for projecteditor"); + let resources = projecteditor.project.allResources(); + + let helperImageData = [ + { + basename: "16x16.png", + path: "img/icons/16x16.png" + }, + { + basename: "32x32.png", + path: "img/icons/32x32.png" + }, + { + basename: "128x128.png", + path: "img/icons/128x128.png" + }, + ]; + + for (let data of helperImageData) { + info("Processing " + data.path); + let resource = resources.filter(r=>r.basename === data.basename)[0]; + yield selectFile(projecteditor, resource); + yield testEditor(projecteditor, getTempFile(data.path).path); + } +}); + +function* testEditor(projecteditor, filePath) { + info("Testing file editing for: " + filePath); + + let editor = projecteditor.currentEditor; + let resource = projecteditor.resourceFor(editor); + + is(resource.path, filePath, "Resource path is set correctly"); + + let images = editor.elt.querySelectorAll("image"); + is(images.length, 1, "There is one image inside the editor"); + is(images[0], editor.image, "The image property is set correctly with the DOM"); + is(editor.image.getAttribute("src"), resource.uri, "The image has the resource URL"); + + info("Selecting another resource, then reselecting this one"); + projecteditor.projectTree.selectResource(resource.store.root); + yield onceEditorActivated(projecteditor); + projecteditor.projectTree.selectResource(resource); + yield onceEditorActivated(projecteditor); + + editor = projecteditor.currentEditor; + images = editor.elt.querySelectorAll("image"); + ok(images.length, 1, "There is one image inside the editor"); + is(images[0], editor.image, "The image property is set correctly with the DOM"); + is(editor.image.getAttribute("src"), resource.uri, "The image has the resource URL"); + + info("Finished checking saving for " + filePath); +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_external_change.js b/devtools/client/projecteditor/test/browser_projecteditor_external_change.js new file mode 100644 index 000000000..12d90a869 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_external_change.js @@ -0,0 +1,84 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadHelperScript("helper_edits.js"); + +// Test ProjectEditor reaction to external changes (made outside of the) +// editor. +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let TEMP_PATH = projecteditor.project.allPaths()[0]; + + is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly."); + + ok(projecteditor.currentEditor, "There is an editor for projecteditor"); + let resources = projecteditor.project.allResources(); + + for (let data of helperEditData) { + info("Processing " + data.path); + let resource = resources.filter(r=>r.basename === data.basename)[0]; + yield selectFile(projecteditor, resource); + yield testChangeFileExternally(projecteditor, getTempFile(data.path).path, data.newContent); + yield testChangeUnsavedFileExternally(projecteditor, getTempFile(data.path).path, data.newContent + "[changed]"); + } +}); + +function* testChangeUnsavedFileExternally(projecteditor, filePath, newData) { + info("Testing file external changes for: " + filePath); + + let editor = projecteditor.currentEditor; + let resource = projecteditor.resourceFor(editor); + let initialData = yield getFileData(filePath); + + is(resource.path, filePath, "Resource path is set correctly"); + is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents"); + + info("Editing but not saving file in project editor"); + ok(editor.isClean(), "Editor is clean"); + editor.editor.setText("foobar"); + ok(!editor.isClean(), "Editor is dirty"); + + info("Editor has been selected, writing to file externally"); + yield writeToFile(resource.path, newData); + + info("Selecting another resource, then reselecting this one"); + projecteditor.projectTree.selectResource(resource.store.root); + yield onceEditorActivated(projecteditor); + projecteditor.projectTree.selectResource(resource); + yield onceEditorActivated(projecteditor); + + editor = projecteditor.currentEditor; + info("Checking to make sure the editor is now populated correctly"); + is(editor.editor.getText(), "foobar", "Editor has not been updated with new file contents"); + + info("Finished checking saving for " + filePath); +} + +function* testChangeFileExternally(projecteditor, filePath, newData) { + info("Testing file external changes for: " + filePath); + + let editor = projecteditor.currentEditor; + let resource = projecteditor.resourceFor(editor); + let initialData = yield getFileData(filePath); + + is(resource.path, filePath, "Resource path is set correctly"); + is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents"); + + info("Editor has been selected, writing to file externally"); + yield writeToFile(resource.path, newData); + + info("Selecting another resource, then reselecting this one"); + projecteditor.projectTree.selectResource(resource.store.root); + yield onceEditorActivated(projecteditor); + projecteditor.projectTree.selectResource(resource); + yield onceEditorActivated(projecteditor); + + editor = projecteditor.currentEditor; + info("Checking to make sure the editor is now populated correctly"); + is(editor.editor.getText(), newData, "Editor has been updated with correct file contents"); + + info("Finished checking saving for " + filePath); +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_immediate_destroy.js b/devtools/client/projecteditor/test/browser_projecteditor_immediate_destroy.js new file mode 100644 index 000000000..0773be55c --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_immediate_destroy.js @@ -0,0 +1,93 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy"); +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("TypeError: this.window is null"); + +// Test that projecteditor can be destroyed in various states of loading +// without causing any leaks or exceptions. + +add_task(function* () { + + info("Testing tab closure when projecteditor is in various states"); + let loaderUrl = "chrome://devtools/content/projecteditor/chrome/content/projecteditor-test.xul"; + + yield addTab(loaderUrl).then(() => { + let iframe = content.document.getElementById("projecteditor-iframe"); + ok(iframe, "Tab has placeholder iframe for projecteditor"); + + info("Closing the tab without doing anything"); + gBrowser.removeCurrentTab(); + }); + + yield addTab(loaderUrl).then(() => { + let iframe = content.document.getElementById("projecteditor-iframe"); + ok(iframe, "Tab has placeholder iframe for projecteditor"); + + let projecteditor = ProjectEditor.ProjectEditor(); + ok(projecteditor, "ProjectEditor has been initialized"); + + info("Closing the tab before attempting to load"); + gBrowser.removeCurrentTab(); + }); + + yield addTab(loaderUrl).then(() => { + let iframe = content.document.getElementById("projecteditor-iframe"); + ok(iframe, "Tab has placeholder iframe for projecteditor"); + + let projecteditor = ProjectEditor.ProjectEditor(); + ok(projecteditor, "ProjectEditor has been initialized"); + + projecteditor.load(iframe); + + info("Closing the tab after a load is requested, but before load is finished"); + gBrowser.removeCurrentTab(); + }); + + yield addTab(loaderUrl).then(() => { + let iframe = content.document.getElementById("projecteditor-iframe"); + ok(iframe, "Tab has placeholder iframe for projecteditor"); + + let projecteditor = ProjectEditor.ProjectEditor(); + ok(projecteditor, "ProjectEditor has been initialized"); + + return projecteditor.load(iframe).then(() => { + info("Closing the tab after a load has been requested and finished"); + gBrowser.removeCurrentTab(); + }); + }); + + yield addTab(loaderUrl).then(() => { + let iframe = content.document.getElementById("projecteditor-iframe"); + ok(iframe, "Tab has placeholder iframe for projecteditor"); + + let projecteditor = ProjectEditor.ProjectEditor(iframe); + ok(projecteditor, "ProjectEditor has been initialized"); + + let loadedDone = promise.defer(); + projecteditor.loaded.then(() => { + ok(false, "Loaded has finished after destroy() has been called"); + loadedDone.resolve(); + }, () => { + ok(true, "Loaded has been rejected after destroy() has been called"); + loadedDone.resolve(); + }); + + projecteditor.destroy(); + + return loadedDone.promise.then(() => { + gBrowser.removeCurrentTab(); + }); + }); + + finish(); +}); + + diff --git a/devtools/client/projecteditor/test/browser_projecteditor_init.js b/devtools/client/projecteditor/test/browser_projecteditor_init.js new file mode 100644 index 000000000..3ee947e0d --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_init.js @@ -0,0 +1,18 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that projecteditor can be initialized. + +function test() { + info("Initializing projecteditor"); + addProjectEditorTab().then((projecteditor) => { + ok(projecteditor, "Load callback has been called"); + ok(projecteditor.shells, "ProjectEditor has shells"); + ok(projecteditor.project, "ProjectEditor has a project"); + finish(); + }); +} + diff --git a/devtools/client/projecteditor/test/browser_projecteditor_menubar_01.js b/devtools/client/projecteditor/test/browser_projecteditor_menubar_01.js new file mode 100644 index 000000000..1641169e7 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_menubar_01.js @@ -0,0 +1,28 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that menu bar appends to the correct document. + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory({ + menubar: false + }); + ok(projecteditor, "ProjectEditor has loaded"); + + let fileMenu = projecteditor.document.getElementById("file-menu"); + let editMenu = projecteditor.document.getElementById("edit-menu"); + ok(fileMenu, "The menu has loaded in the projecteditor document"); + ok(editMenu, "The menu has loaded in the projecteditor document"); + + let projecteditor2 = yield addProjectEditorTabForTempDirectory(); + let menubar = projecteditor2.menubar; + fileMenu = projecteditor2.document.getElementById("file-menu"); + editMenu = projecteditor2.document.getElementById("edit-menu"); + ok(!fileMenu, "The menu has NOT loaded in the projecteditor document"); + ok(!editMenu, "The menu has NOT loaded in the projecteditor document"); + ok(content.document.querySelector("#file-menu"), "The menu has loaded in the specified element"); + ok(content.document.querySelector("#edit-menu"), "The menu has loaded in the specified element"); +}); diff --git a/devtools/client/projecteditor/test/browser_projecteditor_menubar_02.js b/devtools/client/projecteditor/test/browser_projecteditor_menubar_02.js new file mode 100644 index 000000000..d0d41f743 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_menubar_02.js @@ -0,0 +1,123 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadHelperScript("helper_edits.js"); + +// Test menu bar enabled / disabled state. + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let menubar = projecteditor.menubar; + + // Update menu items for a clean slate, so previous tests cannot + // affect paste, and possibly other side effects + projecteditor._updateMenuItems(); + + // let projecteditor = yield addProjectEditorTabForTempDirectory(); + ok(projecteditor, "ProjectEditor has loaded"); + + let fileMenu = menubar.querySelector("#file-menu"); + let editMenu = menubar.querySelector("#edit-menu"); + ok(fileMenu, "The menu has loaded in the projecteditor document"); + ok(editMenu, "The menu has loaded in the projecteditor document"); + + let cmdNew = fileMenu.querySelector("[command=cmd-new]"); + let cmdSave = fileMenu.querySelector("[command=cmd-save]"); + let cmdSaveas = fileMenu.querySelector("[command=cmd-saveas]"); + + let cmdUndo = editMenu.querySelector("[command=cmd_undo]"); + let cmdRedo = editMenu.querySelector("[command=cmd_redo]"); + let cmdCut = editMenu.querySelector("[command=cmd_cut]"); + let cmdCopy = editMenu.querySelector("[command=cmd_copy]"); + let cmdPaste = editMenu.querySelector("[command=cmd_paste]"); + + info("Checking initial state of menus"); + yield openAndCloseMenu(fileMenu); + yield openAndCloseMenu(editMenu); + + is(cmdNew.getAttribute("disabled"), "", "File menu item is enabled"); + is(cmdSave.getAttribute("disabled"), "true", "File menu item is disabled"); + is(cmdSaveas.getAttribute("disabled"), "true", "File menu item is disabled"); + + is(cmdUndo.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdPaste.getAttribute("disabled"), "true", "Edit menu item is disabled"); + + projecteditor.menuEnabled = false; + + info("Checking with menuEnabled = false"); + yield openAndCloseMenu(fileMenu); + yield openAndCloseMenu(editMenu); + + is(cmdNew.getAttribute("disabled"), "true", "File menu item is disabled"); + is(cmdSave.getAttribute("disabled"), "true", "File menu item is disabled"); + is(cmdSaveas.getAttribute("disabled"), "true", "File menu item is disabled"); + + is(cmdUndo.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdPaste.getAttribute("disabled"), "true", "Edit menu item is disabled"); + + info("Checking with menuEnabled=true"); + projecteditor.menuEnabled = true; + + yield openAndCloseMenu(fileMenu); + yield openAndCloseMenu(editMenu); + + is(cmdNew.getAttribute("disabled"), "", "File menu item is enabled"); + is(cmdSave.getAttribute("disabled"), "true", "File menu item is disabled"); + is(cmdSaveas.getAttribute("disabled"), "true", "File menu item is disabled"); + + is(cmdUndo.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdPaste.getAttribute("disabled"), "true", "Edit menu item is disabled"); + + info("Checking with resource selected"); + let resource = projecteditor.project.allResources()[2]; + yield selectFile(projecteditor, resource); + let editor = projecteditor.currentEditor; + + let onChange = promise.defer(); + + projecteditor.on("onEditorChange", () => { + info("onEditorChange has been detected"); + onChange.resolve(); + }); + editor.editor.focus(); + EventUtils.synthesizeKey("f", { }, projecteditor.window); + + yield onChange; + yield openAndCloseMenu(fileMenu); + yield openAndCloseMenu(editMenu); + + is(cmdNew.getAttribute("disabled"), "", "File menu item is enabled"); + is(cmdSave.getAttribute("disabled"), "", "File menu item is enabled"); + is(cmdSaveas.getAttribute("disabled"), "", "File menu item is enabled"); + + // Use editor.canUndo() to see if this is failing - the menu disabled property + // should be in sync with this because of isCommandEnabled in editor.js. + info('cmdUndo.getAttribute("disabled") is: "' + cmdUndo.getAttribute("disabled") + '"'); + ok(editor.editor.canUndo(), "Edit menu item is enabled"); + + is(cmdRedo.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCut.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdCopy.getAttribute("disabled"), "true", "Edit menu item is disabled"); + is(cmdPaste.getAttribute("disabled"), "", "Edit menu item is enabled"); +}); + +function* openAndCloseMenu(menu) { + let shown = onPopupShow(menu); + EventUtils.synthesizeMouseAtCenter(menu, {}, menu.ownerDocument.defaultView); + yield shown; + let hidden = onPopupHidden(menu); + EventUtils.synthesizeMouseAtCenter(menu, {}, menu.ownerDocument.defaultView); + yield hidden; +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_new_file.js b/devtools/client/projecteditor/test/browser_projecteditor_new_file.js new file mode 100644 index 000000000..aaaee0369 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_new_file.js @@ -0,0 +1,13 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test tree selection functionality + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + ok(projecteditor, "ProjectEditor has loaded"); + +}); diff --git a/devtools/client/projecteditor/test/browser_projecteditor_rename_file_01.js b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_01.js new file mode 100644 index 000000000..914fa73cc --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_01.js @@ -0,0 +1,19 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test file rename functionality + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + ok(true, "ProjectEditor has loaded"); + + let root = [...projecteditor.project.allStores()][0].root; + is(root.path, TEMP_PATH, "The root store is set to the correct temp path."); + for (let child of root.children) { + yield renameWithContextMenu(projecteditor, + projecteditor.projectTree.getViewContainer(child), ".renamed"); + } +}); diff --git a/devtools/client/projecteditor/test/browser_projecteditor_rename_file_02.js b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_02.js new file mode 100644 index 000000000..a2964da2a --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_rename_file_02.js @@ -0,0 +1,26 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test file rename functionality with non ascii characters + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + ok(true, "ProjectEditor has loaded"); + + let root = [...projecteditor.project.allStores()][0].root; + is(root.path, TEMP_PATH, "The root store is set to the correct temp path."); + + let childrenList = []; + for (let child of root.children) { + yield renameWithContextMenu(projecteditor, + projecteditor.projectTree.getViewContainer(child), ".ren\u0061\u0308med"); + childrenList.push(child.basename + ".ren\u0061\u0308med"); + } + for (let child of root.children) { + is(childrenList.indexOf(child.basename) == -1, false, + "Failed to update tree with non-ascii character"); + } +}); diff --git a/devtools/client/projecteditor/test/browser_projecteditor_saveall.js b/devtools/client/projecteditor/test/browser_projecteditor_saveall.js new file mode 100644 index 000000000..2468ea4fc --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_saveall.js @@ -0,0 +1,64 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy"); + +loadHelperScript("helper_edits.js"); + +// Test ProjectEditor basic functionality +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let TEMP_PATH = projecteditor.project.allPaths()[0]; + + is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly."); + + ok(projecteditor.currentEditor, "There is an editor for projecteditor"); + let resources = projecteditor.project.allResources(); + + for (let data of helperEditData) { + info("Processing " + data.path); + let resource = resources.filter(r=>r.basename === data.basename)[0]; + yield selectFile(projecteditor, resource); + yield editFile(projecteditor, getTempFile(data.path).path, data.newContent); + } + + info("Saving all resources"); + ok(projecteditor.hasUnsavedResources, "hasUnsavedResources"); + yield projecteditor.saveAllFiles(); + ok(!projecteditor.hasUnsavedResources, "!hasUnsavedResources"); + for (let data of helperEditData) { + let filePath = getTempFile(data.path).path; + info("Asserting that data at " + filePath + " has been saved"); + let resource = resources.filter(r=>r.basename === data.basename)[0]; + yield selectFile(projecteditor, resource); + let editor = projecteditor.currentEditor; + let savedData = yield getFileData(filePath); + is(savedData, data.newContent, "Data has been correctly saved to disk"); + } +}); + +function* editFile(projecteditor, filePath, newData) { + info("Testing file editing for: " + filePath); + + let initialData = yield getFileData(filePath); + let editor = projecteditor.currentEditor; + let resource = projecteditor.resourceFor(editor); + let viewContainer = projecteditor.projectTree.getViewContainer(resource); + let originalTreeLabel = viewContainer.label.textContent; + + is(resource.path, filePath, "Resource path is set correctly"); + is(editor.editor.getText(), initialData, "Editor is loaded with correct file contents"); + + info("Setting text in the editor"); + + editor.editor.setText(newData); + is(editor.editor.getText(), newData, "Editor has been filled with new data"); + is(viewContainer.label.textContent, "*" + originalTreeLabel, "Label is marked as changed"); +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_stores.js b/devtools/client/projecteditor/test/browser_projecteditor_stores.js new file mode 100644 index 000000000..c85a7526b --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_stores.js @@ -0,0 +1,16 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test ProjectEditor basic functionality +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let TEMP_PATH = projecteditor.project.allPaths()[0]; + is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly."); + + is(projecteditor.project.allPaths().length, 1, "1 path is set"); + projecteditor.project.removeAllStores(); + is(projecteditor.project.allPaths().length, 0, "No paths are remaining"); +}); diff --git a/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_01.js b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_01.js new file mode 100644 index 000000000..0a98f7122 --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_01.js @@ -0,0 +1,98 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test tree selection functionality + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let TEMP_PATH = projecteditor.project.allPaths()[0]; + + is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly."); + + ok(projecteditor.currentEditor, "There is an editor for projecteditor"); + let resources = projecteditor.project.allResources(); + + is( + resources.map(r=>r.basename).join("|"), + TEMP_FOLDER_NAME + "|css|styles.css|data|img|icons|128x128.png|16x16.png|32x32.png|vector.svg|fake.png|js|script.js|index.html|LICENSE|README.md", + "Resources came through in proper order" + ); + + for (let i = 0; i < resources.length; i++) { + yield selectFileFirstLoad(projecteditor, resources[i]); + } + for (let i = 0; i < resources.length; i++) { + yield selectFileSubsequentLoad(projecteditor, resources[i]); + } + for (let i = 0; i < resources.length; i++) { + yield selectFileSubsequentLoad(projecteditor, resources[i]); + } +}); + +function* selectFileFirstLoad(projecteditor, resource) { + ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path)); + projecteditor.projectTree.selectResource(resource); + let container = projecteditor.projectTree.getViewContainer(resource); + + if (resource.isRoot) { + ok(container.expanded, "The root directory is expanded by default."); + container.line.click(); + ok(container.expanded, "Clicking on the line does not toggles expansion."); + return; + } + if (resource.isDir) { + ok(!container.expanded, "A directory is not expanded by default."); + container.line.click(); + ok(container.expanded, "Clicking on the line toggles expansion."); + container.line.click(); + ok(!container.expanded, "Clicking on the line toggles expansion."); + return; + } + + let [editorCreated, editorLoaded, editorActivated] = yield promise.all([ + onceEditorCreated(projecteditor), + onceEditorLoad(projecteditor), + onceEditorActivated(projecteditor) + ]); + + is(editorCreated, projecteditor.currentEditor, "Editor has been created for " + resource.path); + is(editorActivated, projecteditor.currentEditor, "Editor has been activated for " + resource.path); + is(editorLoaded, projecteditor.currentEditor, "Editor has been loaded for " + resource.path); +} + +function* selectFileSubsequentLoad(projecteditor, resource) { + ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path)); + projecteditor.projectTree.selectResource(resource); + + if (resource.isDir) { + return; + } + + // Make sure text editors are focused immediately when selected. + let focusPromise = promise.resolve(); + if (projecteditor.currentEditor.editor) { + focusPromise = onEditorFocus(projecteditor.currentEditor); + } + + // Only activated should fire the next time + // (may add load() if we begin checking for changes from disk) + let [editorActivated] = yield promise.all([ + onceEditorActivated(projecteditor) + ]); + + is(editorActivated, projecteditor.currentEditor, "Editor has been activated for " + resource.path); + + yield focusPromise; +} + +function onEditorFocus(editor) { + let def = promise.defer(); + editor.on("focus", function focus() { + editor.off("focus", focus); + def.resolve(); + }); + return def.promise; +} diff --git a/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_02.js b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_02.js new file mode 100644 index 000000000..51826e4dc --- /dev/null +++ b/devtools/client/projecteditor/test/browser_projecteditor_tree_selection_02.js @@ -0,0 +1,76 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("destroy"); + +// Test that files get reselected in the tree when their editor +// is focused. https://bugzilla.mozilla.org/show_bug.cgi?id=1011116. + +add_task(function* () { + let projecteditor = yield addProjectEditorTabForTempDirectory(); + let TEMP_PATH = projecteditor.project.allPaths()[0]; + + is(getTempFile("").path, TEMP_PATH, "Temp path is set correctly."); + + ok(projecteditor.currentEditor, "There is an editor for projecteditor"); + let resources = projecteditor.project.allResources(); + + is( + resources.map(r=>r.basename).join("|"), + TEMP_FOLDER_NAME + "|css|styles.css|data|img|icons|128x128.png|16x16.png|32x32.png|vector.svg|fake.png|js|script.js|index.html|LICENSE|README.md", + "Resources came through in proper order" + ); + + for (let i = 0; i < resources.length; i++) { + yield selectAndRefocusFile(projecteditor, resources[i]); + } +}); + +function* selectAndRefocusFile(projecteditor, resource) { + ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path)); + projecteditor.projectTree.selectResource(resource); + + if (resource.isDir) { + return; + } + + let [editorCreated, editorLoaded, editorActivated] = yield promise.all([ + onceEditorCreated(projecteditor), + onceEditorLoad(projecteditor), + onceEditorActivated(projecteditor) + ]); + + if (projecteditor.currentEditor.editor) { + // This is a text editor. Go ahead and select a directory then refocus + // the editor to make sure it is reselected in tree. + let treeContainer = projecteditor.projectTree.getViewContainer(getDirectoryInStore(resource)); + treeContainer.line.click(); + EventUtils.synthesizeMouseAtCenter(treeContainer.elt, {}, treeContainer.elt.ownerDocument.defaultView); + let waitForTreeSelect = onTreeSelection(projecteditor); + projecteditor.currentEditor.focus(); + yield waitForTreeSelect; + + is(projecteditor.projectTree.getSelectedResource(), resource, "The resource gets reselected in the tree"); + } +} + +// Return a directory to select in the tree. +function getDirectoryInStore(resource) { + return resource.store.root.childrenSorted.filter(r=>r.isDir)[0]; +} + +function onTreeSelection(projecteditor) { + let def = promise.defer(); + projecteditor.projectTree.on("selection", function selection() { + projecteditor.projectTree.off("focus", selection); + def.resolve(); + }); + return def.promise; +} diff --git a/devtools/client/projecteditor/test/head.js b/devtools/client/projecteditor/test/head.js new file mode 100644 index 000000000..d5d9ce849 --- /dev/null +++ b/devtools/client/projecteditor/test/head.js @@ -0,0 +1,391 @@ +/* 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/. */ + +var Cu = Components.utils; +const {require} = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const {TargetFactory} = require("devtools/client/framework/target"); +const {console} = Cu.import("resource://gre/modules/Console.jsm", {}); +const promise = require("promise"); +const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {}); +const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {}); +const ProjectEditor = require("devtools/client/projecteditor/lib/projecteditor"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const flags = require("devtools/shared/flags"); + +const TEST_URL_ROOT = "http://mochi.test:8888/browser/devtools/client/projecteditor/test/"; +const SAMPLE_WEBAPP_URL = TEST_URL_ROOT + "/helper_homepage.html"; +var TEMP_PATH; +var TEMP_FOLDER_NAME = "ProjectEditor" + (new Date().getTime()); + +// All test are asynchronous +waitForExplicitFinish(); + +// Uncomment this pref to dump all devtools emitted events to the console. +// Services.prefs.setBoolPref("devtools.dump.emit", true); + +// Set the testing flag and reset it when the test ends +flags.testing = true; +registerCleanupFunction(() => flags.testing = false); + +// Clear preferences that may be set during the course of tests. +registerCleanupFunction(() => { + // Services.prefs.clearUserPref("devtools.dump.emit"); + TEMP_PATH = null; + TEMP_FOLDER_NAME = null; +}); + +// Auto close the toolbox and close the test tabs when the test ends +registerCleanupFunction(() => { + try { + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.closeToolbox(target); + } catch (ex) { + dump(ex); + } + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +}); + +/** + * Add a new test tab in the browser and load the given url. + * @param {String} url The url to be loaded in the new tab + * @return a promise that resolves to the tab object when the url is loaded + */ +function addTab(url) { + info("Adding a new tab with URL: '" + url + "'"); + let def = promise.defer(); + + let tab = gBrowser.selectedTab = gBrowser.addTab(url); + BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(function () { + info("URL '" + url + "' loading complete"); + waitForFocus(() => { + def.resolve(tab); + }, content); + }); + + return def.promise; +} + +/** + * Some tests may need to import one or more of the test helper scripts. + * A test helper script is simply a js file that contains common test code that + * is either not common-enough to be in head.js, or that is located in a separate + * directory. + * The script will be loaded synchronously and in the test's scope. + * @param {String} filePath The file path, relative to the current directory. + * Examples: + * - "helper_attributes_test_runner.js" + * - "../../../commandline/test/helpers.js" + */ +function loadHelperScript(filePath) { + let testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/")); + Services.scriptloader.loadSubScript(testDir + "/" + filePath, this); +} + +function addProjectEditorTabForTempDirectory(opts = {}) { + try { + TEMP_PATH = buildTempDirectoryStructure(); + } catch (e) { + // Bug 1037292 - The test servers sometimes are unable to + // write to the temporary directory due to locked files + // or access denied errors. Try again if this failed. + info("Project Editor temp directory creation failed. Trying again."); + TEMP_PATH = buildTempDirectoryStructure(); + } + let customOpts = { + name: "Test", + iconUrl: "chrome://devtools/skin/images/tool-options.svg", + projectOverviewURL: SAMPLE_WEBAPP_URL + }; + + info("Adding a project editor tab for editing at: " + TEMP_PATH); + return addProjectEditorTab(opts).then((projecteditor) => { + return projecteditor.setProjectToAppPath(TEMP_PATH, customOpts).then(() => { + return projecteditor; + }); + }); +} + +function addProjectEditorTab(opts = {}) { + return addTab("chrome://devtools/content/projecteditor/chrome/content/projecteditor-test.xul").then(() => { + let iframe = content.document.getElementById("projecteditor-iframe"); + if (opts.menubar !== false) { + opts.menubar = content.document.querySelector("menubar"); + } + let projecteditor = ProjectEditor.ProjectEditor(iframe, opts); + + + ok(iframe, "Tab has placeholder iframe for projecteditor"); + ok(projecteditor, "ProjectEditor has been initialized"); + + return projecteditor.loaded.then((projecteditor) => { + return projecteditor; + }); + }); +} + +/** + * Build a temporary directory as a workspace for this loader + * https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O + */ +function buildTempDirectoryStructure() { + + let dirName = TEMP_FOLDER_NAME; + info("Building a temporary directory at " + dirName); + + // First create (and remove) the temp dir to discard any changes + let TEMP_DIR = FileUtils.getDir("TmpD", [dirName], true); + TEMP_DIR.remove(true); + + // Now rebuild our fake project. + TEMP_DIR = FileUtils.getDir("TmpD", [dirName], true); + + FileUtils.getDir("TmpD", [dirName, "css"], true); + FileUtils.getDir("TmpD", [dirName, "data"], true); + FileUtils.getDir("TmpD", [dirName, "img", "icons"], true); + FileUtils.getDir("TmpD", [dirName, "js"], true); + + let htmlFile = FileUtils.getFile("TmpD", [dirName, "index.html"]); + htmlFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFileSync(htmlFile, [ + "<!DOCTYPE html>", + '<html lang="en">', + " <head>", + ' <meta charset="utf-8" />', + " <title>ProjectEditor Temp File</title>", + ' <link rel="stylesheet" href="style.css" />', + " </head>", + ' <body id="home">', + " <p>ProjectEditor Temp File</p>", + " </body>", + "</html>"].join("\n") + ); + + let readmeFile = FileUtils.getFile("TmpD", [dirName, "README.md"]); + readmeFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFileSync(readmeFile, [ + "## Readme" + ].join("\n") + ); + + let licenseFile = FileUtils.getFile("TmpD", [dirName, "LICENSE"]); + licenseFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFileSync(licenseFile, [ + "/* 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/. */" + ].join("\n") + ); + + let cssFile = FileUtils.getFile("TmpD", [dirName, "css", "styles.css"]); + cssFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + writeToFileSync(cssFile, [ + "body {", + " background: red;", + "}" + ].join("\n") + ); + + FileUtils.getFile("TmpD", [dirName, "js", "script.js"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + + FileUtils.getFile("TmpD", [dirName, "img", "fake.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", [dirName, "img", "icons", "16x16.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", [dirName, "img", "icons", "32x32.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", [dirName, "img", "icons", "128x128.png"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + FileUtils.getFile("TmpD", [dirName, "img", "icons", "vector.svg"]).createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + + return TEMP_DIR.path; +} + +// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#Writing_to_a_file +function writeToFile(file, data) { + if (typeof file === "string") { + file = new FileUtils.File(file); + } + info("Writing to file: " + file.path + " (exists? " + file.exists() + ")"); + let defer = promise.defer(); + var ostream = FileUtils.openSafeFileOutputStream(file); + + var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Components.interfaces.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + var istream = converter.convertToInputStream(data); + + // The last argument (the callback) is optional. + NetUtil.asyncCopy(istream, ostream, function (status) { + if (!Components.isSuccessCode(status)) { + // Handle error! + info("ERROR WRITING TEMP FILE", status); + } + defer.resolve(); + }); + return defer.promise; +} + +// This is used when setting up the test. +// You should typically use the async version of this, writeToFile. +// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#More +function writeToFileSync(file, data) { + // file is nsIFile, data is a string + var foStream = Components.classes["@mozilla.org/network/file-output-stream;1"]. + createInstance(Components.interfaces.nsIFileOutputStream); + + // use 0x02 | 0x10 to open file for appending. + foStream.init(file, 0x02 | 0x08 | 0x20, 0o666, 0); + // write, create, truncate + // In a c file operation, we have no need to set file mode with or operation, + // directly using "r" or "w" usually. + + // if you are sure there will never ever be any non-ascii text in data you can + // also call foStream.write(data, data.length) directly + var converter = Components.classes["@mozilla.org/intl/converter-output-stream;1"]. + createInstance(Components.interfaces.nsIConverterOutputStream); + converter.init(foStream, "UTF-8", 0, 0); + converter.writeString(data); + converter.close(); // this closes foStream +} + +function getTempFile(path) { + let parts = [TEMP_FOLDER_NAME]; + parts = parts.concat(path.split("/")); + return FileUtils.getFile("TmpD", parts); +} + +// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/File_I_O#Writing_to_a_file +function* getFileData(file) { + if (typeof file === "string") { + file = new FileUtils.File(file); + } + let def = promise.defer(); + + NetUtil.asyncFetch({ + uri: NetUtil.newURI(file), + loadUsingSystemPrincipal: true + }, function (inputStream, status) { + if (!Components.isSuccessCode(status)) { + info("ERROR READING TEMP FILE", status); + } + + // Detect if an empty file is loaded + try { + inputStream.available(); + } catch (e) { + def.resolve(""); + return; + } + + var data = NetUtil.readInputStreamToString(inputStream, inputStream.available()); + def.resolve(data); + }); + + return def.promise; +} + +/** + * Rename the resource of the provided container using the context menu. + * + * @param {ProjectEditor} projecteditor the current project editor instance + * @param {Shell} container for the resource to rename + * @param {String} newName the name to use for renaming the resource + * @return {Promise} a promise that resolves when the resource has been renamed + */ +var renameWithContextMenu = Task.async(function* (projecteditor, + container, newName) { + let popup = projecteditor.contextMenuPopup; + let resource = container.resource; + info("Going to attempt renaming for: " + resource.path); + + let waitForPopupShow = onPopupShow(popup); + openContextMenu(container.label); + yield waitForPopupShow; + + let renameCommand = popup.querySelector("[command=cmd-rename]"); + ok(renameCommand, "Rename command exists in popup"); + is(renameCommand.getAttribute("hidden"), "", "Rename command is visible"); + is(renameCommand.getAttribute("disabled"), "", "Rename command is enabled"); + + renameCommand.click(); + popup.hidePopup(); + let input = container.elt.childNodes[0].childNodes[1]; + input.value = resource.basename + newName; + + let waitForProjectRefresh = onceProjectRefreshed(projecteditor); + EventUtils.synthesizeKey("VK_RETURN", {}, projecteditor.window); + yield waitForProjectRefresh; + + try { + yield OS.File.stat(resource.path + newName); + ok(true, "File is renamed"); + } catch (e) { + ok(false, "Failed to rename file"); + } +}); + +function onceEditorCreated(projecteditor) { + let def = promise.defer(); + projecteditor.once("onEditorCreated", (editor) => { + def.resolve(editor); + }); + return def.promise; +} + +function onceEditorLoad(projecteditor) { + let def = promise.defer(); + projecteditor.once("onEditorLoad", (editor) => { + def.resolve(editor); + }); + return def.promise; +} + +function onceEditorActivated(projecteditor) { + let def = promise.defer(); + projecteditor.once("onEditorActivated", (editor) => { + def.resolve(editor); + }); + return def.promise; +} + +function onceEditorSave(projecteditor) { + let def = promise.defer(); + projecteditor.once("onEditorSave", (editor, resource) => { + def.resolve(resource); + }); + return def.promise; +} + +function onceProjectRefreshed(projecteditor) { + return new Promise(resolve => { + projecteditor.project.on("refresh-complete", function refreshComplete() { + projecteditor.project.off("refresh-complete", refreshComplete); + resolve(); + }); + }); +} + +function onPopupShow(menu) { + let defer = promise.defer(); + menu.addEventListener("popupshown", function onpopupshown() { + menu.removeEventListener("popupshown", onpopupshown); + defer.resolve(); + }); + return defer.promise; +} + +function onPopupHidden(menu) { + let defer = promise.defer(); + menu.addEventListener("popuphidden", function onpopuphidden() { + menu.removeEventListener("popuphidden", onpopuphidden); + defer.resolve(); + }); + return defer.promise; +} + +function openContextMenu(node) { + EventUtils.synthesizeMouseAtCenter( + node, + {button: 2, type: "contextmenu"}, + node.ownerDocument.defaultView + ); +} diff --git a/devtools/client/projecteditor/test/helper_edits.js b/devtools/client/projecteditor/test/helper_edits.js new file mode 100644 index 000000000..d8e83672b --- /dev/null +++ b/devtools/client/projecteditor/test/helper_edits.js @@ -0,0 +1,53 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var helperEditData = [ + { + basename: "styles.css", + path: "css/styles.css", + newContent: "body,html { color: orange; }" + }, + { + basename: "index.html", + path: "index.html", + newContent: "<h1>Changed Content Again</h1>" + }, + { + basename: "LICENSE", + path: "LICENSE", + newContent: "My new license" + }, + { + basename: "README.md", + path: "README.md", + newContent: "My awesome readme" + }, + { + basename: "script.js", + path: "js/script.js", + newContent: "alert('hi')" + }, + { + basename: "vector.svg", + path: "img/icons/vector.svg", + newContent: "<svg></svg>" + }, +]; + +function* selectFile(projecteditor, resource) { + ok(resource && resource.path, "A valid resource has been passed in for selection " + (resource && resource.path)); + projecteditor.projectTree.selectResource(resource); + + if (resource.isDir) { + return; + } + + let [editorActivated] = yield promise.all([ + onceEditorActivated(projecteditor) + ]); + + is(editorActivated, projecteditor.currentEditor, "Editor has been activated for " + resource.path); +} diff --git a/devtools/client/projecteditor/test/helper_homepage.html b/devtools/client/projecteditor/test/helper_homepage.html new file mode 100644 index 000000000..a4402a9bd --- /dev/null +++ b/devtools/client/projecteditor/test/helper_homepage.html @@ -0,0 +1 @@ +<h1>ProjectEditor tests</h1>
\ No newline at end of file |