diff --git a/src/LiveDevelopment/Agents/RemoteFunctions.js b/src/LiveDevelopment/Agents/RemoteFunctions.js index d09d7a0a736..e89d4591feb 100644 --- a/src/LiveDevelopment/Agents/RemoteFunctions.js +++ b/src/LiveDevelopment/Agents/RemoteFunctions.js @@ -660,7 +660,11 @@ function RemoteFunctions(experimental) { } else { lastRemovedWasText = isText; - current.remove(); + if (current.remove) { + current.remove(); + } else if (current.parentNode && current.parentNode.removeChild) { + current.parentNode.removeChild(current); + } current = next; } } @@ -698,7 +702,11 @@ function RemoteFunctions(experimental) { edit.tagIDs.forEach(function (tagID) { var node = self._queryBracketsID(tagID); self.rememberedNodes[tagID] = node; - node.remove(); + if (node.remove) { + node.remove(); + } else if (node.parentNode && node.parentNode.removeChild) { + node.parentNode.removeChild(node); + } }); return; } @@ -720,7 +728,11 @@ function RemoteFunctions(experimental) { targetElement.removeAttribute(edit.attribute); break; case "elementDelete": - targetElement.remove(); + if (targetElement.remove) { + targetElement.remove(); + } else if (targetElement.parentNode && targetElement.parentNode.removeChild) { + targetElement.parentNode.removeChild(targetElement); + } break; case "elementInsert": childElement = null; diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js new file mode 100644 index 00000000000..21f5f8dd57b --- /dev/null +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -0,0 +1,863 @@ +/* + * Copyright (c) 2012 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, forin: true, maxerr: 50, regexp: true */ +/*global define, $, brackets, window, open */ + +/** + * LiveDevelopment allows Brackets to launch a browser with a "live preview" that's + * connected to the current editor. + * + * # STARTING + * + * To start a session call `open`. This will read the currentDocument from brackets, + * launch it in the default browser, and connect to it for live editing. + * + * # STOPPING + * + * To stop a session call `close`. This will close the connection to the browser + * (but will not close the browser tab). + * + * # STATUS + * + * Status updates are dispatched as `statusChange` jQuery events. The status + * is passed as the first parameter and the reason for the change as the second + * parameter. Currently only the "Inactive" status supports the reason parameter. + * The status codes are: + * + * -1: Error + * 0: Inactive + * 1: Connecting (waiting for a browser connection) + * 2: Active + * 3: Out of sync + * 4: Sync error + * 5: Reloading (after saving JS changes) + * 6: Restarting (switching context to a new HTML live doc) + * + * The reason codes are: + * - null (Unknown reason) + * - "explicit_close" (LiveDevelopment.close() was called) + * - "navigated_away" (The browser changed to a location outside of the project) + * - "detached_target_closed" (The tab or window was closed) + */ +define(function (require, exports, module) { + "use strict"; + + // Status Codes + var STATUS_ERROR = exports.STATUS_ERROR = -1; + var STATUS_INACTIVE = exports.STATUS_INACTIVE = 0; + var STATUS_CONNECTING = exports.STATUS_CONNECTING = 1; + var STATUS_ACTIVE = exports.STATUS_ACTIVE = 2; + var STATUS_OUT_OF_SYNC = exports.STATUS_OUT_OF_SYNC = 3; + var STATUS_SYNC_ERROR = exports.STATUS_SYNC_ERROR = 4; + var STATUS_RELOADING = exports.STATUS_RELOADING = 5; + var STATUS_RESTARTING = exports.STATUS_RESTARTING = 6; + + var Async = require("utils/Async"), + Dialogs = require("widgets/Dialogs"), + DefaultDialogs = require("widgets/DefaultDialogs"), + DocumentManager = require("document/DocumentManager"), + EditorManager = require("editor/EditorManager"), + ExtensionUtils = require("utils/ExtensionUtils"), + FileSystemError = require("filesystem/FileSystemError"), + FileUtils = require("file/FileUtils"), + NativeApp = require("utils/NativeApp"), + PreferencesDialogs = require("preferences/PreferencesDialogs"), + ProjectManager = require("project/ProjectManager"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"), + _ = require("thirdparty/lodash"), + LiveDevServerManager = require("LiveDevelopment/LiveDevServerManager"), + NodeSocketTransport = require("LiveDevelopment/MultiBrowserImpl/transports/NodeSocketTransport"), + LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"), + Launcher = require("LiveDevelopment/MultiBrowserImpl/launchers/Launcher"); + + // Documents + var LiveCSSDocument = require("LiveDevelopment/MultiBrowserImpl/documents/LiveCSSDocument"), + LiveHTMLDocument = require("LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument"); + + /** + * @private + * The live HTML document for the currently active preview. + * @type {LiveHTMLDocument} + */ + var _liveDocument; + + /** + * @private + * Live documents related to the active HTML document - for example, CSS files + * that are used by the document. + * @type {Object.} + */ + var _relatedDocuments = {}; + + /** + * @private + * Current transport for communicating with browser instances. See setTransport(). + * @type {{start: function(string), send: function(number|Array., string), close: function(number)}} + */ + var _transport; + + /** + * @private + * Protocol handler that provides the actual live development API on top of the current transport. + */ + var _protocol = LiveDevProtocol; + + /** + * @private + * Current live preview server + * @type {BaseServer} + */ + var _server; + + /** + * @private + * Returns true if we think the given extension is for an HTML file. + * @param {string} ext The extension to check. + * @return {boolean} true if this is an HTML extension + */ + function _isHtmlFileExt(ext) { + return (FileUtils.isStaticHtmlFileExt(ext) || + (ProjectManager.getBaseUrl() && FileUtils.isServerHtmlFileExt(ext))); + } + + /** + * @private + * Determine which live document class should be used for a given document + * @param {Document} document The document we want to create a live document for. + * @return {function} The constructor for the live document class; will be a subclass of LiveDocument. + */ + function _classForDocument(doc) { + if (doc.getLanguage().getId() === "css") { + return LiveCSSDocument; + } + + if (_isHtmlFileExt(doc.file.fullPath)) { + return LiveHTMLDocument; + } + + return null; + } + + /** + * Returns true if the global Live Development mode is on (might be in the middle of connecting). + * @return {boolean} + */ + function isActive() { + return exports.status > STATUS_INACTIVE; + } + + /** + * Returns the live document for a given path, or null if there is no live document for it. + * @param {string} path + * @return {?LiveDocument} + */ + function getLiveDocForPath(path) { + if (!_server) { + return null; + } + + return _server.get(path); + } + + /** + * Returns the live document for a given editor, or null if there is no live document for it. + * @param {Editor} editor + * @return {?LiveDocument} + */ + function getLiveDocForEditor(editor) { + if (!editor) { + return null; + } + return getLiveDocForPath(editor.document.file.fullPath); + } + + /** + * @private + * Close a live document. + * @param {LiveDocument} + */ + function _closeDocument(liveDocument) { + $(liveDocument).off(".livedev"); + liveDocument.close(); + } + + /** + * Removes the given CSS/JSDocument from _relatedDocuments. Signals that the + * given file is no longer associated with the HTML document that is live (e.g. + * if the related file has been deleted on disk). + * @param {string} url Absolute URL of the related document + */ + function _handleRelatedDocumentDeleted(url) { + var liveDoc = _relatedDocuments[url]; + if (liveDoc) { + delete _relatedDocuments[url]; + } + + if (_server) { + _server.remove(liveDoc); + } + _closeDocument(liveDoc); + } + + /** + * Update the status. Triggers a statusChange event. + * @param {number} status new status + * @param {?string} closeReason Optional string key suffix to display to + * user when closing the live development connection (see LIVE_DEV_* keys) + */ + function _setStatus(status, closeReason) { + // Don't send a notification when the status didn't actually change + if (status === exports.status) { + return; + } + + exports.status = status; + + var reason = status === STATUS_INACTIVE ? closeReason : null; + $(exports).triggerHandler("statusChange", [status, reason]); + } + + /** + * @private + * Close all live documents. + */ + function _closeDocuments() { + if (_liveDocument) { + _closeDocument(_liveDocument); + _liveDocument = undefined; + } + + Object.keys(_relatedDocuments).forEach(function (url) { + _closeDocument(_relatedDocuments[url]); + delete _relatedDocuments[url]; + }); + + // Clear all documents from request filtering + if (_server) { + _server.clear(); + } + } + + /** + * @private + * Returns the URL that we would serve the given path at. + * @param {string} path + * @return {string} + */ + function _resolveUrl(path) { + return _server && _server.pathToUrl(path); + } + + /** + * @private + * Create a LiveDocument for a Brackets editor/document to manage communication between the + * editor and the browser. + * @param {Document} doc + * @param {Editor} editor + * @param {roots} roots + * @return {?LiveDocument} The live document, or null if this type of file doesn't support live editing. + */ + function _createLiveDocument(doc, editor, roots) { + var DocClass = _classForDocument(doc), + liveDocument; + + if (!DocClass) { + return null; + } + + liveDocument = new DocClass(_protocol, _resolveUrl, doc, editor, roots); + + $(liveDocument).on("errorStatusChanged.livedev", function (event, hasErrors) { + if (isActive()) { + _setStatus(hasErrors ? STATUS_SYNC_ERROR : STATUS_ACTIVE); + } + }); + + return liveDocument; + } + + /** + * Documents are considered to be out-of-sync if they are dirty and + * do not have "update while editing" support + * @param {Document} doc + * @return {boolean} + */ + function _docIsOutOfSync(doc) { + var liveDoc = _server && _server.get(doc.file.fullPath), + isLiveEditingEnabled = liveDoc && liveDoc.isLiveEditingEnabled(); + + return doc.isDirty && !isLiveEditingEnabled; + } + + /** + * Handles a notification from the browser that a stylesheet was loaded into + * the live HTML document. If the stylesheet maps to a file in the project, then + * creates a live document for the stylesheet and adds it to _relatedDocuments. + * @param {$.Event} event + * @param {string} url The URL of the stylesheet that was added. + * @param {array} roots The URLs of the roots of the stylesheet (the css files loaded through ) + */ + function _styleSheetAdded(event, url, roots) { + var path = _server && _server.urlToPath(url), + alreadyAdded = !!_relatedDocuments[url]; + + // path may be null if loading an external stylesheet. + // Also, the stylesheet may already exist and be reported as added twice + // due to Chrome reporting added/removed events after incremental changes + // are pushed to the browser + if (!path || alreadyAdded) { + return; + } + + var docPromise = DocumentManager.getDocumentForPath(path); + + docPromise.done(function (doc) { + if ((_classForDocument(doc) === LiveCSSDocument) && + (!_liveDocument || (doc !== _liveDocument.doc))) { + var liveDoc = _createLiveDocument(doc, null, roots); + if (liveDoc) { + _server.add(liveDoc); + _relatedDocuments[doc.url] = liveDoc; + $(liveDoc).on("updateDoc", function (event, url) { + var path = _server.urlToPath(url), + doc = getLiveDocForPath(path); + doc._updateBrowser(); + }); + } + } + }); + } + + /** + * @private + * Determine an index file that can be used to start Live Development. + * This function will inspect all files in a project to find the closest index file + * available for currently opened document. We are searching for these files: + * - index.html + * - index.htm + * + * If the project is configured with a custom base url for live developmment, then + * the list of possible index files is extended to contain these index files too: + * - index.php + * - index.php3 + * - index.php4 + * - index.php5 + * - index.phtm + * - index.phtml + * - index.cfm + * - index.cfml + * - index.asp + * - index.aspx + * - index.jsp + * - index.jspx + * - index.shm + * - index.shml + * + * If a file was found, the promise will be resolved with the full path to this file. If no file + * was found in the whole project tree, the promise will be resolved with null. + * + * @return {jQuery.Promise} A promise that is resolved with a full path + * to a file if one could been determined, or null if there was no suitable index + * file. + */ + function _getInitialDocFromCurrent() { + var doc = DocumentManager.getCurrentDocument(), + refPath, + i; + + // Is the currently opened document already a file we can use for Live Development? + if (doc) { + refPath = doc.file.fullPath; + if (FileUtils.isStaticHtmlFileExt(refPath) || FileUtils.isServerHtmlFileExt(refPath)) { + return new $.Deferred().resolve(doc); + } + } + + var result = new $.Deferred(); + + var baseUrl = ProjectManager.getBaseUrl(), + hasOwnServerForLiveDevelopment = (baseUrl && baseUrl.length); + + ProjectManager.getAllFiles().done(function (allFiles) { + var projectRoot = ProjectManager.getProjectRoot().fullPath, + containingFolder, + indexFileFound = false, + stillInProjectTree = true; + + if (refPath) { + containingFolder = FileUtils.getDirectoryPath(refPath); + } else { + containingFolder = projectRoot; + } + + var filteredFiltered = allFiles.filter(function (item) { + var parent = FileUtils.getDirectoryPath(item.fullPath); + + return (containingFolder.indexOf(parent) === 0); + }); + + var filterIndexFile = function (fileInfo) { + if (fileInfo.fullPath.indexOf(containingFolder) === 0) { + if (FileUtils.getFilenameWithoutExtension(fileInfo.name) === "index") { + if (hasOwnServerForLiveDevelopment) { + if ((FileUtils.isServerHtmlFileExt(fileInfo.name)) || + (FileUtils.isStaticHtmlFileExt(fileInfo.name))) { + return true; + } + } else if (FileUtils.isStaticHtmlFileExt(fileInfo.name)) { + return true; + } + } else { + return false; + } + } + }; + + while (!indexFileFound && stillInProjectTree) { + i = _.findIndex(filteredFiltered, filterIndexFile); + + // We found no good match + if (i === -1) { + // traverse the directory tree up one level + containingFolder = FileUtils.getDirectoryPath(containingFolder); + // Are we still inside the project? + if (containingFolder.indexOf(projectRoot) === -1) { + stillInProjectTree = false; + } + } else { + indexFileFound = true; + } + } + + if (i !== -1) { + DocumentManager.getDocumentForPath(filteredFiltered[i].fullPath).then(result.resolve, result.resolve); + return; + } + + result.resolve(null); + }); + + return result.promise(); + } + + /** + * @private + * Close the connection and the associated window + * @param {boolean} doCloseWindow Use true to close the window/tab in the browser + * @param {?string} reason Optional string key suffix to display to user (see LIVE_DEV_* keys) + */ + function _close(doCloseWindow, reason) { + if (exports.status !== STATUS_INACTIVE) { + // Close live documents + _closeDocuments(); + // Close all active connections + _protocol.closeAllConnections(); + + if (_server) { + // Stop listening for requests when disconnected + _server.stop(); + + // Dispose server + _server = null; + } + } + //TODO: implement closeWindow together with launchers. +// if (doCloseWindow) { +// +// } + _setStatus(STATUS_INACTIVE, reason || "explicit_close"); + } + + /** + * Close all active connections + */ + function close() { + return _close(true); + } + + /** + * @private + * Displays an error when no HTML file can be found to preview. + */ + function _showWrongDocError() { + Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_ERROR, + Strings.LIVE_DEVELOPMENT_ERROR_TITLE, + Strings.LIVE_DEV_NEED_HTML_MESSAGE + ); + } + + /** + * @private + * Displays an error when the server for live development files can't be started. + */ + function _showLiveDevServerNotReadyError() { + Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_ERROR, + Strings.LIVE_DEVELOPMENT_ERROR_TITLE, + Strings.LIVE_DEV_SERVER_NOT_READY_MESSAGE + ); + } + + /** + * @private + * Creates the main live document for a given HTML document and notifies the server it exists. + * TODO: we should really maintain the list of live documents, not the server. + * @param {Document} doc + */ + function _createLiveDocumentForFrame(doc) { + // create live document + doc._ensureMasterEditor(); + _liveDocument = _createLiveDocument(doc, doc._masterEditor); + _server.add(_liveDocument); + } + + + /** + * Launches the given URL in the default browser. + * @param {string} url + * TODO: launchers for multiple browsers + */ + function _launch(url) { + // open default browser + // TODO: fail? + // + Launcher.launch(url); + } + + /** + * @private + * Launches the given document in the browser, given that a live document has already + * been created for it. + * @param {Document} doc + */ + function _open(doc) { + if (doc && _liveDocument && doc === _liveDocument.doc) { + if (_server) { + // Launch the URL in the browser. If it's the first one to connect back to us, + // our status will transition to ACTIVE once it does so. + if (exports.status < STATUS_ACTIVE) { + _launch(_server.pathToUrl(doc.file.fullPath)); + } + if (exports.status === STATUS_RESTARTING) { + // change page in browser + _protocol.navigate(_server.pathToUrl(doc.file.fullPath)); + } + + $(_protocol) + // TODO: timeout if we don't get a connection within a certain time + .on("Connection.connect.livedev", function (event, msg) { + // check for the first connection + if (_protocol.getConnectionIds().length === 1) { + var doc = DocumentManager.getCurrentDocument(); + // check the page that connection comes from matches the current live document session + if (_liveDocument && (msg.url === _resolveUrl(_liveDocument.doc.file.fullPath))) { + _setStatus(STATUS_ACTIVE); + } + } + }) + .on("Connection.close.livedev", function (event, msg) { + // close session when the last connection was closed + if (_protocol.getConnectionIds().length === 0) { + if (exports.status <= STATUS_ACTIVE) { + _close(false, "detached_target_closed"); + } + } + }) + // extract stylesheets and create related LiveCSSDocument instances + .on("Document.Related.livedev", function (event, msg) { + var relatedDocs = msg.related; + var docs = Object.keys(relatedDocs.stylesheets); + docs.forEach(function (url) { + _styleSheetAdded(null, url, relatedDocs.stylesheets[url]); + }); + }) + // create new LiveCSSDocument if a new stylesheet is added + .on("Stylesheet.Added.livedev", function (event, msg) { + _styleSheetAdded(null, msg.href, msg.roots); + }) + // remove LiveCSSDocument instance when stylesheet is removed + .on("Stylesheet.Removed.livedev", function (event, msg) { + _handleRelatedDocumentDeleted(msg.href); + }); + } else { + console.error("LiveDevelopment._open(): No server active"); + } + } else { + // Unlikely that we would get to this state where + // a connection is in process but there is no current + // document + close(); + } + } + + /** + * @private + * Creates the live document in preparation for launching the + * preview of the given document, then launches it. (The live document + * must already exist before we launch it so that the server can + * ask it for the instrumented version of the document when the browser + * requests it.) + * TODO: could probably just consolidate this with _open() + * @param {Document} doc + */ + function _doLaunchAfterServerReady(initialDoc) { + + _createLiveDocumentForFrame(initialDoc); + + // start listening for requests + _server.start(); + + // open browser to the url + _open(initialDoc); + } + + /** + * @private + * Create the server in preparation for opening a live preview. + * @param {Document} doc The document we want the server for. Different servers handle + * different types of project (a static server for when no app server is configured, + * vs. a user server when there is an app server set in File > Project Settings). + */ + function _prepareServer(doc) { + var deferred = new $.Deferred(), + showBaseUrlPrompt = false; + + _server = LiveDevServerManager.getServer(doc.file.fullPath); + + // Optionally prompt for a base URL if no server was found but the + // file is a known server file extension + showBaseUrlPrompt = !_server && FileUtils.isServerHtmlFileExt(doc.file.fullPath); + + if (showBaseUrlPrompt) { + // Prompt for a base URL + PreferencesDialogs.showProjectPreferencesDialog("", Strings.LIVE_DEV_NEED_BASEURL_MESSAGE) + .done(function (id) { + if (id === Dialogs.DIALOG_BTN_OK && ProjectManager.getBaseUrl()) { + // If base url is specifed, then re-invoke _prepareServer() to continue + _prepareServer(doc).then(deferred.resolve, deferred.reject); + } else { + deferred.reject(); + } + }); + } else if (_server) { + // Startup the server + var readyPromise = _server.readyToServe(); + if (!readyPromise) { + _showLiveDevServerNotReadyError(); + deferred.reject(); + } else { + readyPromise.then(deferred.resolve, function () { + _showLiveDevServerNotReadyError(); + deferred.reject(); + }); + } + } else { + // No server found + deferred.reject(); + } + + return deferred.promise(); + } + + /** + * @private + * When switching documents, close the current preview and open a new one. + */ + function _onDocumentChange() { + var doc = DocumentManager.getCurrentDocument(); + if (!isActive() || !doc) { + return; + } + + // close the current session and begin a new session + var docUrl = _server && _server.pathToUrl(doc.file.fullPath), + isViewable = _server && _server.canServe(doc.file.fullPath); + + if (_liveDocument.doc.url !== docUrl && isViewable) { + // clear live doc and related docs + _closeDocuments(); + // create new live doc + _createLiveDocumentForFrame(doc); + _setStatus(STATUS_RESTARTING); + _open(doc); + + } + } + + + /** + * Open a live preview on the current docuemnt. + */ + function open() { + // TODO: need to run _onDocumentChange() after load if doc != currentDocument here? Maybe not, since activeEditorChange + // doesn't trigger it, while inline editors can still cause edits in doc other than currentDoc... + _getInitialDocFromCurrent().done(function (doc) { + var prepareServerPromise = (doc && _prepareServer(doc)) || new $.Deferred().reject(), + otherDocumentsInWorkingFiles; + + if (doc && !doc._masterEditor) { + otherDocumentsInWorkingFiles = DocumentManager.getWorkingSet().length; + DocumentManager.addToWorkingSet(doc.file); + + if (!otherDocumentsInWorkingFiles) { + DocumentManager.setCurrentDocument(doc); + } + } + + // wait for server (StaticServer, Base URL or file:) + prepareServerPromise + .done(function () { + _setStatus(STATUS_CONNECTING); + _doLaunchAfterServerReady(doc); + }) + .fail(function () { + _showWrongDocError(); + }); + }); + } + + /** + * For files that don't support as-you-type live editing, but are loaded by live HTML documents + * (e.g. JS files), we want to reload the full document when they're saved. + * @param {$.Event} event + * @param {Document} doc + */ + function _onDocumentSaved(event, doc) { + if (!isActive() || !_server) { + return; + } + + var absolutePath = doc.file.fullPath, + liveDocument = absolutePath && _server.get(absolutePath), + liveEditingEnabled = liveDocument && liveDocument.isLiveEditingEnabled && liveDocument.isLiveEditingEnabled(); + + // Skip reload if the saved document has live editing enabled + if (liveEditingEnabled) { + return; + } + + // reload the page if the given document is a JS file related + // to the current live document. + if (_liveDocument.isRelated(absolutePath)) { + if (doc.getLanguage().getId() === "javascript") { + _setStatus(STATUS_RELOADING); + _protocol.reload(); + } + } + } + + /** + * For files that don't support as-you-type live editing, but are loaded by live HTML documents + * (e.g. JS files), we want to show a dirty indicator on the live development icon when they + * have unsaved changes, so the user knows s/he needs to save in order to have the page reload. + * @param {$.Event} event + * @param {Document} doc + */ + function _onDirtyFlagChange(event, doc) { + if (!isActive() || !_server) { + return; + } + + var absolutePath = doc.file.fullPath; + + if (_liveDocument.isRelated(absolutePath)) { + // Set status to out of sync if dirty. Otherwise, set it to active status. + _setStatus(_docIsOutOfSync(doc) ? STATUS_OUT_OF_SYNC : STATUS_ACTIVE); + } + } + + /** + * Sets the current transport mechanism to be used by the live development protocol + * (e.g. socket server, iframe postMessage, etc.) + * @param {{launch: function(string), send: function(number|Array., string), close: function(number), getRemoteScript: function(): ?string}} transport + * The low-level transport. Must provide the following methods: + * + * launch(url): Opens the url in the target browser. + * send(idOrArray, string): Dispatches the given protocol message (provided as a JSON string) to the given client ID + * or array of client IDs. (See the "connect" message for an explanation of client IDs.) + * close(id): Closes the connection to the given client ID. + * getRemoteScript(): Returns a script that should be injected into the page's HTML in order to handle the remote side + * of the transport. Should include the "\n"; + } + + /** + * Returns a script that should be injected into the HTML that's launched in the + * browser in order to handle protocol requests. Includes the \n" + + remoteFunctionsScript; + } + + /** + * Protocol method. Evaluates the given script in the browser (in global context), and returns a promise + * that will be fulfilled with the result of the script, if any. + * @param {number|Array.} clients A client ID or array of client IDs that should evaluate + * the script. + * @param {string} script The script to evalute. + * @return {$.Promise} A promise that's resolved with the return value from the first client that responds + * to the evaluation. + */ + function evaluate(script, clients) { + return _send( + { + method: "Runtime.evaluate", + params: { + expression: script + } + }, + clients + ); + } + + /** + * Protocol method. Reloads a CSS styleseet in the browser (by replacing its text) given its url. + * @param {string} url Absolute URL of the stylesheet + * @param {string} text The new text of the stylesheet + * @param {number|Array.} clients A client ID or array of client IDs that should evaluate + * the script. + * @return {$.Promise} A promise that's resolved with the return value from the first client that responds + * to the evaluation. + */ + function setStylesheetText(url, text, clients) { + return _send( + { + method: "CSS.setStylesheetText", + params: { + url: url, + text: text + } + } + ); + } + + /** + * Protocol method. Rretrieves the content of a given stylesheet (for unit testing) + * @param {number|Array.} clients A client ID or array of client IDs that should navigate to the given URL. + * @param {string} url Absolute URL that identifies the stylesheet. + * @return {$.Promise} A promise that's resolved with the return value from the first client that responds + * to the method. + */ + function getStylesheetText(url, clients) { + return _send( + { + method: "CSS.getStylesheetText", + params: { + url: url + } + }, + clients + ); + } + + /** + * Protocol method. Reloads the page that is currently loaded into the browser, optionally ignoring cache. + * @param {number|Array.} clients A client ID or array of client IDs that should reload the page. + * @param {boolean} ignoreCache If true, browser cache is ignored. + * @return {$.Promise} A promise that's resolved with the return value from the first client that responds + * to the method. + */ + function reload(ignoreCache, clients) { + return _send( + { + method: "Page.reload", + params: { + ignoreCache: true + } + }, + clients + ); + } + + /** + * Protocol method. Navigates current page to the given URL. + * @param {number|Array.} clients A client ID or array of client IDs that should navigate to the given URL. + * @param {string} url URL to navigate the page to. + * @return {$.Promise} A promise that's resolved with the return value from the first client that responds + * to the method. + */ + function navigate(url, clients) { + return _send( + { + method: "Page.navigate", + params: { + url: url + } + }, + clients + ); + } + + /** + * Closes the connection to the given client. Proxies to the transport. + * @param {number} clientId + */ + function close(clientId) { + _transport.close(clientId); + } + + function closeAllConnections() { + getConnectionIds().forEach(function (clientId) { + close(clientId); + }); + _connections = {}; + } + + // public API + exports.setTransport = setTransport; + exports.getRemoteScript = getRemoteScript; + exports.evaluate = evaluate; + exports.setStylesheetText = setStylesheetText; + exports.getStylesheetText = getStylesheetText; + exports.reload = reload; + exports.navigate = navigate; + exports.close = close; + exports.getConnectionIds = getConnectionIds; + exports.closeAllConnections = closeAllConnections; +}); diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/remote/DocumentObserver.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/remote/DocumentObserver.js new file mode 100644 index 00000000000..5e60f5a100c --- /dev/null +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/remote/DocumentObserver.js @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2014 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +(function (global) { + "use strict"; + + var ProtocolManager = global._Brackets_LiveDev_ProtocolManager; + + var _document = null; + var _transport; + + + /** + * Common functions. + */ + var Utils = { + + isExternalStylesheet: function (node) { + return (node.nodeName.toUpperCase() === "LINK" && node.rel === "stylesheet" && node.href); + }, + isExternalScript: function (node) { + return (node.nodeName.toUpperCase() === "SCRIPT" && node.src); + } + }; + + /** + * CSS related commands and notifications + */ + var CSS = { + + /** + * Maintains a map of stylesheets loaded thorugh @import rules and their parents. + * Populated by extractImports, consumed by notifyImportsAdded / notifyImportsRemoved. + * @type { + */ + stylesheets : {}, + + /** + * Check the stylesheet that was just added be really loaded + * to be able to extract potential import-ed stylesheets. + * It invokes notifyStylesheetAdded once the sheet is loaded. + * @param {string} href Absolute URL of the stylesheet. + */ + checkForStylesheetLoaded : function (href) { + var self = this; + + + // Inspect CSSRules for @imports: + // styleSheet obejct is required to scan CSSImportRules but + // browsers differ on the implementation of MutationObserver interface. + // Webkit triggers notifications before stylesheets are loaded, + // Firefox does it after loading. + // There are also differences on when 'load' event is triggered for + // the 'link' nodes. Webkit triggers it before stylesheet is loaded. + // Some references to check: + // http://www.phpied.com/when-is-a-stylesheet-really-loaded/ + // http://stackoverflow.com/questions/17747616/webkit-dynamically-created-stylesheet-when-does-it-really-load + // http://stackoverflow.com/questions/11425209/are-dom-mutation-observers-slower-than-dom-mutation-events + // + // TODO: This is just a temporary 'cross-browser' solution, it needs optimization. + var loadInterval = setInterval(function () { + var i; + for (i = 0; i < document.styleSheets.length; i++) { + if (document.styleSheets[i].href === href) { + //clear interval + clearInterval(loadInterval); + // notify stylesheets added + self.notifyStylesheetAdded(href); + break; + } + } + }, 50); + }, + + onStylesheetRemoved : function (url) { + // get style node created when setting new text for stylesheet. + var s = document.getElementById(url); + // remove + if (s && s.parentNode && s.parentNode.removeChild) { + s.parentNode.removeChild(s); + } + }, + + /** + * Send a notification for the stylesheet added and + * its import-ed styleshets based on document.stylesheets diff + * from previous status. It also updates stylesheets status. + */ + notifyStylesheetAdded : function () { + var i, + added = {}, + current, + newStatus; + + current = this.stylesheets; + newStatus = related().stylesheets; + + Object.keys(newStatus).forEach(function (v, i) { + if (!current[v]) { + added[v] = newStatus[v]; + } + }); + + Object.keys(added).forEach(function (v, i) { + _transport.send(JSON.stringify({ + method: "Stylesheet.Added", + href: v, + roots: [added[v]] + })); + }); + + this.stylesheets = newStatus; + }, + + /** + * Send a notification for the removed stylesheet and + * its import-ed styleshets based on document.stylesheets diff + * from previous status. It also updates stylesheets status. + */ + notifyStylesheetRemoved : function () { + + var self = this; + var i, + removed = {}, + newStatus, + current; + + current = self.stylesheets; + newStatus = related().stylesheets; + + Object.keys(current).forEach(function (v, i) { + if (!newStatus[v]) { + removed[v] = current[v]; + // remove node created by setStylesheetText if any + self.onStylesheetRemoved(current[v]); + } + }); + + Object.keys(removed).forEach(function (v, i) { + _transport.send(JSON.stringify({ + method: "Stylesheet.Removed", + href: v, + roots: [removed[v]] + })); + }); + + self.stylesheets = newStatus; + } + }; + + + /* process related docs added */ + function _onNodesAdded(nodes) { + var i; + for (i = 0; i < nodes.length; i++) { + //check for Javascript files + if (Utils.isExternalScript(nodes[i])) { + _transport.send(JSON.stringify({ + method: 'Script.Added', + src: nodes[i].src + })); + } + //check for stylesheets + if (Utils.isExternalStylesheet(nodes[i])) { + CSS.checkForStylesheetLoaded(nodes[i].href); + } + } + } + /* process related docs removed */ + function _onNodesRemoved(nodes) { + var i; + //iterate on removed nodes + for (i = 0; i < nodes.length; i++) { + + // check for external JS files + if (Utils.isExternalScript(nodes[i])) { + _transport.send(JSON.stringify({ + method: 'Script.Removed', + src: nodes[i].src + })); + } + //check for external StyleSheets + if (Utils.isExternalStylesheet(nodes[i])) { + CSS.notifyStylesheetRemoved(nodes[i].href); + } + } + } + + function _enableListeners() { + // enable MutationOberver if it's supported + var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; + if (MutationObserver) { + var observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + if (mutation.addedNodes.length > 0) { + _onNodesAdded(mutation.addedNodes); + } + if (mutation.removedNodes.length > 0) { + _onNodesRemoved(mutation.removedNodes); + } + }); + }); + observer.observe(_document, { + childList: true, + subtree: true + }); + } else { + // use MutationEvents as fallback + document.addEventListener('DOMNodeInserted', function niLstnr(e) { + _onNodesAdded([e.target]); + }); + document.addEventListener('DOMNodeRemoved', function nrLstnr(e) { + _onNodesRemoved([e.target]); + }); + } + } + + /** + * Retrieves related documents (external CSS and JS files) + * + * @return {{scripts: object, stylesheets: object}} Related scripts and stylesheets + */ + function related() { + + var rel = { + scripts: {}, + stylesheets: {} + }; + var i; + // iterate on document scripts (HTMLCollection doesn't provide forEach iterator). + for (i = 0; i < _document.scripts.length; i++) { + // add only external scripts + if (_document.scripts[i].src) { + rel.scripts[_document.scripts[i].src] = true; + } + } + + var s, j; + //traverse @import rules + var traverseRules = function _traverseRules(sheet, base) { + var i; + if (sheet.href && sheet.cssRules) { + if (rel.stylesheets[sheet.href] === undefined) { + rel.stylesheets[sheet.href] = []; + } + rel.stylesheets[sheet.href].push(base); + + + for (i = 0; i < sheet.cssRules.length; i++) { + if (sheet.cssRules[i].href) { + traverseRules(sheet.cssRules[i].styleSheet, base); + } + } + } + }; + //iterate on document.stylesheets (StyleSheetList doesn't provide forEach iterator). + for (j = 0; j < document.styleSheets.length; j++) { + s = document.styleSheets[j]; + traverseRules(s, s.href); + } + return rel; + } + + /** + * Start listening for events and send initial related documents message. + * + * @param {HTMLDocument} document + * @param {object} transport Live development transport connection + */ + function start(document, transport) { + _transport = transport; + _document = document; + // start listening to node changes + _enableListeners(); + + var rel = related(); + + // send the current status of related docs. + _transport.send(JSON.stringify({ + method: "Document.Related", + related: rel + })); + // initialize stylesheets with current status for further notifications. + CSS.stylesheets = rel.stylesheets; + } + + /** + * Stop listening. + * TODO currently a no-op. + */ + function stop() { + + } + + var DocumentObserver = { + start: start, + stop: stop, + related: related + }; + + ProtocolManager.setDocumentObserver(DocumentObserver); + +}(this)); \ No newline at end of file diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/remote/LiveDevProtocolRemote.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/remote/LiveDevProtocolRemote.js new file mode 100644 index 00000000000..390b9b6cb57 --- /dev/null +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/remote/LiveDevProtocolRemote.js @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2014 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +/*jslint evil: true */ + +// This is the script that Brackets live development injects into HTML pages in order to +// establish and maintain the live development socket connection. Note that Brackets may +// also inject other scripts via "evaluate" once this has connected back to Brackets. + +(function (global) { + "use strict"; + + // This protocol handler assumes that there is also an injected transport script that + // has the following methods: + // setCallbacks(obj) - a method that takes an object with a "message" callback that + // will be called with the message string whenever a message is received by the transport. + // send(msgStr) - sends the given message string over the transport. + var transport = global._Brackets_LiveDev_Transport; + + + /** + * Manage messaging between Editor and Browser at the protocol layer. + * Handle messages that arrives through the current transport and dispatch them + * to subscribers. Subscribers are handlers that implements remote commands/functions. + * Property 'method' of messages body is used as the 'key' to identify message types. + * Provide a 'send' operation that allows remote commands sending messages to the Editor. + */ + var MessageBroker = { + + /** + * Collection of handlers (subscribers) per each method. + * To be pushed by 'on' and consumed by 'trigger' stored this way: + * handlers[method] = [handler1, handler2, ...] + */ + handlers: {}, + + /** + * Dispatch messages to handlers according to msg.method value. + * @param {Object} msg Message to be dispatched. + */ + trigger: function (msg) { + var msgHandlers; + if (!msg.method) { + // no message type, ignoring it + // TODO: should we trigger a generic event? + console.log("[Brackets LiveDev] Received message without method."); + return; + } + // get handlers for msg.method + msgHandlers = this.handlers[msg.method]; + + if (msgHandlers && msgHandlers.length > 0) { + // invoke handlers with the received message + msgHandlers.forEach(function (handler) { + try { + // TODO: check which context should be used to call handlers here. + handler(msg); + return; + } catch (e) { + console.log("[Brackets LiveDev] Error executing a handler for " + msg.method); + console.log(e.stack); + return; + } + }); + } else { + // no subscribers, ignore it. + // TODO: any other default handling? (eg. specific respond, trigger as a generic event, etc.); + console.log("[Brackets LiveDev] No subscribers for message " + msg.method); + return; + } + }, + + /** + * Send a response of a particular message to the Editor. + * Original message must provide an 'id' property + * @param {Object} orig Original message. + * @param {Object} response Message to be sent as the response. + */ + respond: function (orig, response) { + if (!orig.id) { + console.log("[Brackets LiveDev] Trying to send a response for a message with no ID"); + return; + } + response.id = orig.id; + this.send(response); + }, + + /** + * Subscribe handlers to specific messages. + * @param {string} method Message type. + * @param {function} handler. + * TODO: add handler name or any identification mechanism to then implement 'off'? + */ + on: function (method, handler) { + if (!method || !handler) { + return; + } + if (!this.handlers[method]) { + //initialize array + this.handlers[method] = []; + } + // add handler to the stack + this.handlers[method].push(handler); + }, + + /** + * Send a message to the Editor. + * @param {string} msgStr Message to be sent. + */ + send: function (msgStr) { + transport.send(JSON.stringify(msgStr)); + } + }; + + /** + * Runtime Domain. Implements remote commands for "Runtime.*" + */ + var Runtime = { + /** + * Evaluate an expresion and return its result. + */ + evaluate: function (msg) { + console.log("Runtime.evaluate"); + var result = eval(msg.params.expression); + MessageBroker.respond(msg, { + result: JSON.stringify(result) // TODO: in original protocol this is an object handle + }); + } + }; + + // subscribe handler to method Runtime.evaluate + MessageBroker.on("Runtime.evaluate", Runtime.evaluate); + + /** + * CSS Domain. + */ + var CSS = { + + setStylesheetText : function (msg) { + + if (!msg || !msg.params || !msg.params.text || !msg.params.url) { + return; + } + + var i, + node; + + var head = document.getElementsByTagName('head')[0]; + // create an style element to replace the one loaded with + var s = document.createElement('style'); + s.type = 'text/css'; + s.appendChild(document.createTextNode(msg.params.text)); + + for (i = 0; i < document.styleSheets.length; i++) { + node = document.styleSheets[i]; + if (node.ownerNode.id === msg.params.url) { + head.insertBefore(s, node.ownerNode); // insert the style element here + // now can remove the style element previously created (if any) + node.ownerNode.parentNode.removeChild(node.ownerNode); + } else if (node.href === msg.params.url && !node.disabled) { + // if the link element to change + head.insertBefore(s, node.ownerNode); // insert the style element here + node.disabled = true; + i++; // since we have just inserted a stylesheet + } + } + s.id = msg.params.url; + }, + + /** + * retrieves the content of the stylesheet + * TODO: it now depends on reloadCSS implementation + */ + getStylesheetText: function (msg) { + var i, + sheet, + text; + for (i = 0; i < document.styleSheets.length; i++) { + sheet = document.styleSheets[i]; + // if it was already 'reloaded' + if (sheet.ownerNode.id === msg.params.url) { + text = sheet.ownerNode.innerText; + } else if (sheet.href === msg.params.url && !sheet.disabled) { + var j, + rules = document.styleSheets[i].cssRules; + text = ""; + for (j = 0; j < rules.length; j++) { + text += rules[j].cssText + '\n'; + } + } + } + MessageBroker.respond(msg, { + text: text + }); + } + }; + + MessageBroker.on("CSS.setStylesheetText", CSS.setStylesheetText); + MessageBroker.on("CSS.getStylesheetText", CSS.getStylesheetText); + + /** + * Page Domain. + */ + var Page = { + /** + * Reload the current page optionally ignoring cache. + * @param {Object} msg + */ + reload: function (msg) { + // just reload the page + window.location.reload(msg.params.ignoreCache); + }, + + /** + * Navigate to a different page. + * @param {Object} msg + */ + navigate: function (msg) { + if (msg.params.url) { + // navigate to a new page. + window.location.replace(msg.params.url); + } + } + }; + + // subscribe handler to method Page.reload + MessageBroker.on("Page.reload", Page.reload); + MessageBroker.on("Page.navigate", Page.navigate); + MessageBroker.on("Connection.close", Page.close); + + + + // By the time this executes, there must already be an active transport. + if (!transport) { + console.error("[Brackets LiveDev] No transport set"); + return; + } + + var ProtocolManager = { + + _documentObserver: {}, + + _protocolHandler: {}, + + enable: function () { + transport.setCallbacks(this._protocolHandler); + transport.enable(); + }, + + onConnect: function () { + this._documentObserver.start(window.document, transport); + }, + + onClose: function () { + // TODO: This is absolutely temporary solution. It shows a message + // when the connection has been closed. UX decision to be taken on what to do when + // the session is explicitly closed from the Editor side. If the browser can't be closed, + // this could be an alternative. A better alternative to this could be a redirection + // to a custom static page being served by StaticServer + var body = document.getElementsByTagName("body")[0]; + body.style.opacity = 0.5; + var status = document.createElement("div"); + status.textContent = "Live Development Session has Ended"; + status.style.width = "100%"; + status.style.color = "#fff"; + status.style.backgroundColor = "#ff0000"; + status.style.position = "absolute"; + status.style.top = 0; + status.style.left = 0; + status.style.padding = "0.2em"; + status.style.zIndex = 2227; + body.appendChild(status); + }, + + setDocumentObserver: function (documentOberver) { + if (!documentOberver) { + return; + } + this._documentObserver = documentOberver; + }, + + setProtocolHandler: function (protocolHandler) { + if (!protocolHandler) { + return; + } + this._protocolHandler = protocolHandler; + } + }; + + // exposing ProtocolManager + global._Brackets_LiveDev_ProtocolManager = ProtocolManager; + + /** + * The remote handler for the protocol. + */ + var ProtocolHandler = { + /** + * Handles a message from the transport. Parses it as JSON and delegates + * to MessageBroker who is in charge of routing them to handlers. + * @param {string} msgStr The protocol message as stringified JSON. + */ + message: function (msgStr) { + var msg; + try { + msg = JSON.parse(msgStr); + } catch (e) { + console.log("[Brackets LiveDev] Invalid Message Received"); + // TODO: we should probably send back an error message here? + return; + } + // delegates handling/routing to MessageBroker. + MessageBroker.trigger(msg); + }, + + close: function (evt) { + ProtocolManager.onClose(); + }, + + connect: function (evt) { + ProtocolManager.onConnect(); + } + }; + + ProtocolManager.setProtocolHandler(ProtocolHandler); + + window.addEventListener('load', function () { + ProtocolManager.enable(); + }); + +}(this)); diff --git a/src/LiveDevelopment/MultiBrowserImpl/transports/NodeSocketTransport.js b/src/LiveDevelopment/MultiBrowserImpl/transports/NodeSocketTransport.js new file mode 100644 index 00000000000..2c72154a289 --- /dev/null +++ b/src/LiveDevelopment/MultiBrowserImpl/transports/NodeSocketTransport.js @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2014 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, forin: true, maxerr: 50, regexp: true */ +/*global define, $, brackets, window, open */ + +// This transport provides a WebSocket connection between Brackets and a live browser preview. +// This is just a thin wrapper around the Node extension (NodeSocketTransportDomain) that actually +// provides the WebSocket server and handles the communication. We also rely on an injected script in +// the browser for the other end of the transport. + +define(function (require, exports, module) { + "use strict"; + + var FileUtils = require("file/FileUtils"), + NodeDomain = require("utils/NodeDomain"); + + // The script that will be injected into the previewed HTML to handle the other side of the socket connection. + var NodeSocketTransportRemote = require("text!LiveDevelopment/MultiBrowserImpl/transports/remote/NodeSocketTransportRemote.js"); + + // The node extension that actually provides the WebSocket server. + + var domainPath = FileUtils.getNativeBracketsDirectoryPath() + "/" + FileUtils.getNativeModuleDirectoryPath(module) + "/node/NodeSocketTransportDomain"; + + var NodeSocketTransportDomain = new NodeDomain("nodeSocketTransport", domainPath); + + // This must match the port declared in NodeSocketTransportDomain.js. + // TODO: randomize this? + var SOCKET_PORT = 8123; + + /** + * Returns the script that should be injected into the browser to handle the other end of the transport. + * @return {string} + */ + function getRemoteScript() { + return "\n"; + } + + // Events + + // We can simply retrigger the events we receive from the node domain directly, since they're in + // the same format expected by clients of the transport. + ["connect", "message", "close"].forEach(function (type) { + $(NodeSocketTransportDomain).on(type, function () { + console.log("NodeSocketTransport - event - " + type + " - " + JSON.stringify(Array.prototype.slice.call(arguments, 1))); + // Remove the event object from the argument list. + $(exports).triggerHandler(type, Array.prototype.slice.call(arguments, 1)); + }); + }); + + // Exports + + exports.getRemoteScript = getRemoteScript; + + // Proxy the node domain methods directly through, since they have exactly the same + // signatures as the ones we're supposed to provide. + ["start", "send", "close"].forEach(function (method) { + exports[method] = function () { + var args = Array.prototype.slice.call(arguments); + args.unshift(method); + console.log("NodeSocketTransport - " + args); + NodeSocketTransportDomain.exec.apply(NodeSocketTransportDomain, args); + }; + }); + +}); diff --git a/src/LiveDevelopment/MultiBrowserImpl/transports/node/NodeSocketTransportDomain.js b/src/LiveDevelopment/MultiBrowserImpl/transports/node/NodeSocketTransportDomain.js new file mode 100644 index 00000000000..e98094661d3 --- /dev/null +++ b/src/LiveDevelopment/MultiBrowserImpl/transports/node/NodeSocketTransportDomain.js @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2014 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, node: true */ + +(function () { + "use strict"; + + var WebSocketServer = require("ws").Server, + _ = require("lodash"); + + /** + * @private + * The WebSocket server we listen for incoming connections on. + * @type {?WebSocketServer} + */ + var _wsServer; + + /** + * @private + * The Brackets domain manager for registering node extensions. + * @type {?DomainManager} + */ + var _domainManager; + + /** + * @private + * The ID that should be allocated to the next client that connects to the transport. + * @type {number} + */ + var _nextClientId = 1; + + /** + * @private + * A map of client IDs to the URL and WebSocket for the given ID. + * @type {Object.} + */ + var _clients = {}; + + // This must match the port declared in NodeSocketTransport.js. + // TODO: randomize this? + var SOCKET_PORT = 8123; + + /** + * @private + * Returns the client info for a given WebSocket, or null if that socket isn't registered. + * @param {WebSocket} ws + * @return {?{id: number, url: string, socket: WebSocket}} + */ + function _clientForSocket(ws) { + return _.find(_clients, function (client) { + return (client.socket === ws); + }); + } + + /** + * @private + * Creates the WebSocketServer and handles incoming connections. + */ + function _createServer() { + if (!_wsServer) { + // TODO: make port configurable, or use random port + _wsServer = new WebSocketServer({port: SOCKET_PORT}); + _wsServer.on("connection", function (ws) { + ws.on("message", function (msg) { + console.log("WebSocketServer - received - " + msg); + var msgObj; + try { + msgObj = JSON.parse(msg); + } catch (e) { + console.error("nodeSocketTransport: Error parsing message: " + msg); + return; + } + + // See the comment in NodeSocketTransportRemote.connect() for why we have an extra + // layer of transport-layer message objects surrounding the protocol messaging. + + if (msgObj.type === "connect") { + if (!msgObj.url) { + console.error("nodeSocketTransport: Malformed connect message: " + msg); + return; + } + var clientId = _nextClientId++; + _clients[clientId] = { + id: clientId, + url: msgObj.url, + socket: ws + }; + console.log("emitting connect event"); + _domainManager.emitEvent("nodeSocketTransport", "connect", [clientId, msgObj.url]); + } else if (msgObj.type === "message") { + var client = _clientForSocket(ws); + if (client) { + _domainManager.emitEvent("nodeSocketTransport", "message", [client.id, msgObj.message]); + } else { + console.error("nodeSocketTransport: Couldn't locate client for message: " + msg); + } + } else { + console.error("nodeSocketTransport: Got bad socket message type: " + msg); + } + }).on("error", function (e) { + // TODO: emit error event + var client = _clientForSocket(ws); + console.error("nodeSocketTransport: Error on socket for client " + JSON.stringify(client) + ": " + e); + }).on("close", function () { + var client = _clientForSocket(ws); + if (client) { + _domainManager.emitEvent("nodeSocketTransport", "close", [client.id]); + delete _clients[client.id]; + } else { + console.error("nodeSocketTransport: Socket closed, but couldn't locate client"); + } + }); + }); + } + } + + /** + * Initializes the socket server. + * @param {string} url + */ + function _cmdStart(url) { + _createServer(); + } + + /** + * Sends a transport-layer message over the socket. + * @param {number|Array.} idOrArray A client ID or array of client IDs to send the message to. + * @param {string} msgStr The message to send as a JSON string. + */ + function _cmdSend(idOrArray, msgStr) { + if (!Array.isArray(idOrArray)) { + idOrArray = [idOrArray]; + } + idOrArray.forEach(function (id) { + var client = _clients[id]; + if (!client) { + console.error("nodeSocketTransport: Couldn't find client ID: " + id); + } else { + client.socket.send(msgStr); + } + }); + } + + /** + * Closes the connection for a given client ID. + * @param {number} clientId + */ + function _cmdClose(clientId) { + var client = _clients[clientId]; + if (client) { + client.socket.close(); + delete _clients[clientId]; + } + } + + /** + * Initializes the domain and registers commands. + * @param {DomainManager} domainManager The DomainManager for the server + */ + function init(domainManager) { + _domainManager = domainManager; + if (!domainManager.hasDomain("nodeSocketTransport")) { + domainManager.registerDomain("nodeSocketTransport", {major: 0, minor: 1}); + } + domainManager.registerCommand( + "nodeSocketTransport", // domain name + "start", // command name + _cmdStart, // command handler function + false, // this command is synchronous in Node + "Creates the WS server", + [] + ); + domainManager.registerCommand( + "nodeSocketTransport", // domain name + "send", // command name + _cmdSend, // command handler function + false, // this command is synchronous in Node + "Sends a message to a given client or list of clients", + [ + {name: "idOrArray", type: "number|Array.", description: "id or array of ids to send the message to"}, + {name: "message", type: "string", description: "JSON message to send"} + ], + [] + ); + domainManager.registerCommand( + "nodeSocketTransport", // domain name + "close", // command name + _cmdClose, // command handler function + false, // this command is synchronous in Node + "Closes the connection to a given client", + [ + {name: "id", type: "number", description: "id of connection to close"} + ], + [] + ); + domainManager.registerEvent( + "nodeSocketTransport", + "connect", + [ + {name: "clientID", type: "number", description: "ID of live preview page connecting to live development"}, + {name: "url", type: "string", description: "URL of page that live preview is connecting from"} + ] + ); + domainManager.registerEvent( + "nodeSocketTransport", + "message", + [ + {name: "clientID", type: "number", description: "ID of live preview page sending message"}, + {name: "msg", type: "string", description: "JSON message from client page"} + ] + ); + domainManager.registerEvent( + "nodeSocketTransport", + "close", + [ + {name: "clientID", type: "number", description: "ID of live preview page being closed"} + ] + ); + } + + exports.init = init; + +}()); diff --git a/src/LiveDevelopment/MultiBrowserImpl/transports/node/package.json b/src/LiveDevelopment/MultiBrowserImpl/transports/node/package.json new file mode 100644 index 00000000000..28aff2810e0 --- /dev/null +++ b/src/LiveDevelopment/MultiBrowserImpl/transports/node/package.json @@ -0,0 +1,7 @@ +{ + "name": "brackets-livedev2-server", + "dependencies": { + "ws": "~0.4.31", + "lodash": "~2.4.1" + } +} diff --git a/src/LiveDevelopment/MultiBrowserImpl/transports/remote/NodeSocketTransportRemote.js b/src/LiveDevelopment/MultiBrowserImpl/transports/remote/NodeSocketTransportRemote.js new file mode 100644 index 00000000000..02b2dacad50 --- /dev/null +++ b/src/LiveDevelopment/MultiBrowserImpl/transports/remote/NodeSocketTransportRemote.js @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2014 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +/*jslint browser: true, vars: true, plusplus: true, devel: true, nomen: true, indent: 4, forin: true, maxerr: 50, regexp: true */ +/*global WebSocket */ + +// This is a transport injected into the browser via a script that handles the low +// level communication between the live development protocol handlers on both sides. +// This transport provides a web socket mechanism. It's injected separately from the +// protocol handler so that the transport can be changed separately. + +(function (global) { + "use strict"; + + var WebSocketTransport = { + /** + * @private + * The WebSocket that we communicate with Brackets over. + * @type {?WebSocket} + */ + _ws: null, + + /** + * @private + * An object that contains callbacks to handle various transport events. See `setCallbacks()`. + * @type {?{connect: ?function, message: ?function(string), close: ?function}} + */ + _callbacks: null, + + /** + * Sets the callbacks that should be called when various transport events occur. All callbacks + * are optional, but you should at least implement "message" or nothing interesting will happen :) + * @param {?{connect: ?function, message: ?function(string), close: ?function}} callbacks + * The callbacks to set. + * connect - called when a connection is established to Brackets + * message(msgStr) - called with a string message sent from Brackets + * close - called when Brackets closes the connection + */ + setCallbacks: function (callbacks) { + if (!global._Brackets_LiveDev_Socket_Transport_URL) { + console.error("[Brackets LiveDev] No socket transport URL injected"); + } else { + this._callbacks = callbacks; + } + }, + + /** + * Connects to the NodeSocketTransport in Brackets at the given WebSocket URL. + * @param {string} url + */ + connect: function (url) { + var self = this; + this._ws = new WebSocket(url); + + // One potential source of confusion: the transport sends two "types" of messages - + // these are distinct from the protocol's own messages. This is because this transport + // needs to send an initial "connect" message telling the Brackets side of the transport + // the URL of the page that it's connecting from, distinct from the actual protocol + // message traffic. Actual protocol messages are sent as a JSON payload in a message of + // type "message". + // + // Other transports might not need to do this - for example, a transport that simply + // talks to an iframe within the same process already knows what URL that iframe is + // pointing to, so the only comunication that needs to happen via postMessage() is the + // actual protocol message strings, and no extra wrapping is necessary. + + this._ws.onopen = function (event) { + // Send the initial "connect" message to tell the other end what URL we're from. + self._ws.send(JSON.stringify({ + type: "connect", + url: global.location.href + })); + console.log("[Brackets LiveDev] Connected to Brackets at " + url); + if (self._callbacks && self._callbacks.connect) { + self._callbacks.connect(); + } + }; + this._ws.onmessage = function (event) { + console.log("[Brackets LiveDev] Got message: " + event.data); + if (self._callbacks && self._callbacks.message) { + self._callbacks.message(event.data); + } + }; + this._ws.onclose = function (event) { + self._ws = null; + if (self._callbacks && self._callbacks.close) { + self._callbacks.close(); + } + }; + // TODO: onerror + }, + + /** + * Sends a message over the transport. + * @param {string} msgStr The message to send. + */ + send: function (msgStr) { + if (this._ws) { + // See comment in `connect()` above about why we wrap the message in a transport message + // object. + this._ws.send(JSON.stringify({ + type: "message", + message: msgStr + })); + } else { + console.log("[Brackets LiveDev] Tried to send message over closed connection: " + msgStr); + } + }, + + /** + * Establish web socket connection. + */ + enable: function() { + this.connect(global._Brackets_LiveDev_Socket_Transport_URL); + } + }; + global._Brackets_LiveDev_Transport = WebSocketTransport; +}(this)); diff --git a/src/LiveDevelopment/main.js b/src/LiveDevelopment/main.js index 7e9690c7961..a3aa25a286a 100644 --- a/src/LiveDevelopment/main.js +++ b/src/LiveDevelopment/main.js @@ -49,6 +49,9 @@ define(function main(require, exports, module) { Strings = require("strings"), ExtensionUtils = require("utils/ExtensionUtils"), StringUtils = require("utils/StringUtils"); + + // expermiental multi-browser implementation + var MultiBrowserLiveDev = require("LiveDevelopment/LiveDevMultiBrowser"); var params = new UrlParams(); var config = { @@ -65,20 +68,14 @@ define(function main(require, exports, module) { } }; // Status labels/styles are ordered: error, not connected, progress1, progress2, connected. - var _statusTooltip = [ - Strings.LIVE_DEV_STATUS_TIP_NOT_CONNECTED, - Strings.LIVE_DEV_STATUS_TIP_NOT_CONNECTED, - Strings.LIVE_DEV_STATUS_TIP_PROGRESS1, - Strings.LIVE_DEV_STATUS_TIP_PROGRESS2, - Strings.LIVE_DEV_STATUS_TIP_CONNECTED, - Strings.LIVE_DEV_STATUS_TIP_OUT_OF_SYNC, - Strings.LIVE_DEV_STATUS_TIP_SYNC_ERROR - ]; - - var _statusStyle = ["warning", "", "info", "info", "success", "out-of-sync", "sync-error"]; // Status indicator's CSS class - var _allStatusStyles = _statusStyle.join(" "); + var _statusTooltip, + _statusStyle, + _allStatusStyles; var _$btnGoLive; // reference to the GoLive button + + // current selected implementation (LiveDevelopment | LiveDevMultiBrowser) + var LiveDevImpl; /** Load Live Development LESS Style */ function _loadStyles() { @@ -123,9 +120,9 @@ define(function main(require, exports, module) { * Do nothing when in a connecting state (CONNECTING, LOADING_AGENTS). */ function _handleGoLiveCommand() { - if (LiveDevelopment.status >= LiveDevelopment.STATUS_ACTIVE) { - LiveDevelopment.close(); - } else if (LiveDevelopment.status <= LiveDevelopment.STATUS_INACTIVE) { + if (LiveDevImpl.status >= LiveDevImpl.STATUS_ACTIVE) { + LiveDevImpl.close(); + } else if (LiveDevImpl.status <= LiveDevImpl.STATUS_INACTIVE) { if (!params.get("skipLiveDevelopmentInfo") && !PreferencesManager.getViewState("livedev.afterFirstLaunch")) { PreferencesManager.setViewState("livedev.afterFirstLaunch", "true"); Dialogs.showModalDialog( @@ -133,10 +130,10 @@ define(function main(require, exports, module) { Strings.LIVE_DEVELOPMENT_INFO_TITLE, Strings.LIVE_DEVELOPMENT_INFO_MESSAGE ).done(function (id) { - LiveDevelopment.open(); + LiveDevImpl.open(); }); } else { - LiveDevelopment.open(); + LiveDevImpl.open(); } } } @@ -177,7 +174,7 @@ define(function main(require, exports, module) { _$btnGoLive.click(function onGoLive() { _handleGoLiveCommand(); }); - LiveDevelopment.on("statusChange", function statusChange(event, status, reason) { + $(LiveDevImpl).on("statusChange", function statusChange(event, status, reason) { // status starts at -1 (error), so add one when looking up name and style // See the comments at the top of LiveDevelopment.js for details on the // various status codes. @@ -194,11 +191,11 @@ define(function main(require, exports, module) { /** Maintains state of the Live Preview menu item */ function _setupGoLiveMenu() { - LiveDevelopment.on("statusChange", function statusChange(event, status) { + $(LiveDevImpl).on("statusChange", function statusChange(event, status) { // Update the checkmark next to 'Live Preview' menu item // Add checkmark when status is STATUS_ACTIVE; otherwise remove it - CommandManager.get(Commands.FILE_LIVE_FILE_PREVIEW).setChecked(status === LiveDevelopment.STATUS_ACTIVE); - CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setEnabled(status === LiveDevelopment.STATUS_ACTIVE); + CommandManager.get(Commands.FILE_LIVE_FILE_PREVIEW).setChecked(status === LiveDevImpl.STATUS_ACTIVE); + CommandManager.get(Commands.FILE_LIVE_HIGHLIGHT).setEnabled(status === LiveDevImpl.STATUS_ACTIVE); }); } @@ -210,13 +207,50 @@ define(function main(require, exports, module) { config.highlight = !config.highlight; _updateHighlightCheckmark(); if (config.highlight) { - LiveDevelopment.showHighlight(); + LiveDevImpl.showHighlight(); } else { - LiveDevelopment.hideHighlight(); + LiveDevImpl.hideHighlight(); } PreferencesManager.setViewState("livedev.highlight", config.highlight); } + // sets the MultiBrowserLiveDev implementation if multibrowser = true, + // keeps default LiveDevelopment implementation based on CDT in other case. + // since UI status are slightly different btw implementations, it also set + // the corresponding style values. + function _setImplementation(multibrowser) { + console.log('set implementation ' + multibrowser); + if (multibrowser) { + // set implemenation + LiveDevImpl = MultiBrowserLiveDev; + // update styles for UI status + _statusTooltip = [ + Strings.LIVE_DEV_STATUS_TIP_NOT_CONNECTED, + Strings.LIVE_DEV_STATUS_TIP_NOT_CONNECTED, + Strings.LIVE_DEV_STATUS_TIP_PROGRESS1, + Strings.LIVE_DEV_STATUS_TIP_CONNECTED, + Strings.LIVE_DEV_STATUS_TIP_OUT_OF_SYNC, + Strings.LIVE_DEV_STATUS_TIP_SYNC_ERROR, + Strings.LIVE_DEV_STATUS_TIP_PROGRESS1, + Strings.LIVE_DEV_STATUS_TIP_PROGRESS1 + ]; + _statusStyle = ["warning", "", "info", "success", "out-of-sync", "sync-error", "info", "info"]; // Status indicator's CSS class + } else { + LiveDevImpl = LiveDevelopment; + _statusTooltip = [ + Strings.LIVE_DEV_STATUS_TIP_NOT_CONNECTED, + Strings.LIVE_DEV_STATUS_TIP_NOT_CONNECTED, + Strings.LIVE_DEV_STATUS_TIP_PROGRESS1, + Strings.LIVE_DEV_STATUS_TIP_PROGRESS2, + Strings.LIVE_DEV_STATUS_TIP_CONNECTED, + Strings.LIVE_DEV_STATUS_TIP_OUT_OF_SYNC, + Strings.LIVE_DEV_STATUS_TIP_SYNC_ERROR + ]; + _statusStyle = ["warning", "", "info", "info", "success", "out-of-sync", "sync-error"]; // Status indicator's CSS class + } + _allStatusStyles = _statusStyle.join(" "); + } + /** Setup window references to useful LiveDevelopment modules */ function _setupDebugHelpers() { window.ld = LiveDevelopment; @@ -233,10 +267,19 @@ define(function main(require, exports, module) { /** Initialize LiveDevelopment */ AppInit.appReady(function () { + params.parse(); - + + // init LiveDevelopment Inspector.init(config); LiveDevelopment.init(config); + + // init experimental multi-browser implementation + // it can be enable by setting 'livedev.multibrowser' preference to true. + // It has to be initiated at this point in case of dynamically switching + // by changing the preference value. + MultiBrowserLiveDev.init(config); + _loadStyles(); _setupGoLiveButton(); _setupGoLiveMenu(); @@ -275,6 +318,17 @@ define(function main(require, exports, module) { "afterFirstLaunch": "user livedev.afterFirstLaunch" }, true); + PreferencesManager.definePreference("livedev.multibrowser", "boolean", false) + .on("change", function () { + // stop the current session if it is open and set implementation based on + // the pref value. It could be automaticallty restarted but, since the preference file, + // is the document open in the editor, it will no launch a live document. + if (LiveDevImpl && LiveDevImpl.status >= LiveDevImpl.STATUS_ACTIVE) { + LiveDevImpl.close(); + } + _setImplementation(PreferencesManager.get('livedev.multibrowser')); + }); + config.highlight = PreferencesManager.getViewState("livedev.highlight"); // init commands diff --git a/src/brackets.js b/src/brackets.js index e0d5e728334..1a78a1f5502 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -191,6 +191,7 @@ define(function (require, exports, module) { KeyBindingManager : KeyBindingManager, LanguageManager : LanguageManager, LiveDevelopment : require("LiveDevelopment/LiveDevelopment"), + LiveDevMultiBrowser : require("LiveDevelopment/LiveDevMultiBrowser"), LiveDevServerManager : require("LiveDevelopment/LiveDevServerManager"), MainViewManager : MainViewManager, MainViewFactory : require("view/MainViewFactory"), diff --git a/src/thirdparty/CodeMirror2 b/src/thirdparty/CodeMirror2 index 4bce53423da..8be56518bef 160000 --- a/src/thirdparty/CodeMirror2 +++ b/src/thirdparty/CodeMirror2 @@ -1 +1 @@ -Subproject commit 4bce53423da19c95ab1420885497f19e3ace5db1 +Subproject commit 8be56518bef493d2155e4a3bea18a196bc43e1db diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 336cc49f608..d59cc594f07 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -63,6 +63,7 @@ define(function (require, exports, module) { require("spec/KeyBindingManager-test"); require("spec/LanguageManager-test"); require("spec/LiveDevelopment-test"); + require("spec/LiveDevelopmentMultiBrowser-test"); require("spec/LowLevelFileIO-test"); require("spec/MainViewFactory-test"); require("spec/MainViewManager-test"); diff --git a/test/spec/LiveDevelopment-MultiBrowser-test-files/import1.css b/test/spec/LiveDevelopment-MultiBrowser-test-files/import1.css new file mode 100644 index 00000000000..9b001e53e6e --- /dev/null +++ b/test/spec/LiveDevelopment-MultiBrowser-test-files/import1.css @@ -0,0 +1,3 @@ +.testClass2 { + color: #000; +} \ No newline at end of file diff --git a/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.css b/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.css new file mode 100644 index 00000000000..03171114c35 --- /dev/null +++ b/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.css @@ -0,0 +1,6 @@ +@import url('import1.css'); + +.testClass { + color: #000; +} + diff --git a/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.html b/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.html new file mode 100644 index 00000000000..ac9451e2225 --- /dev/null +++ b/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.html @@ -0,0 +1,15 @@ + + + + +Simple Test + + + + + + +

Brackets is awesome!

+

Red is bad. Green is good.

+ + diff --git a/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.js b/test/spec/LiveDevelopment-MultiBrowser-test-files/simple1.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/spec/LiveDevelopment-MultiBrowser-test-files/simpleShared.css b/test/spec/LiveDevelopment-MultiBrowser-test-files/simpleShared.css new file mode 100644 index 00000000000..12f7b219774 --- /dev/null +++ b/test/spec/LiveDevelopment-MultiBrowser-test-files/simpleShared.css @@ -0,0 +1,4 @@ +body { + background-color: red; +} + diff --git a/test/spec/LiveDevelopmentMultiBrowser-test.js b/test/spec/LiveDevelopmentMultiBrowser-test.js new file mode 100644 index 00000000000..b597da85780 --- /dev/null +++ b/test/spec/LiveDevelopmentMultiBrowser-test.js @@ -0,0 +1,315 @@ +/* + * Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +/*jslint evil: true */ + +/*global $, brackets, define, describe, xdescribe, it, xit, expect, beforeEach, afterEach, beforeFirst, afterLast, waitsFor, waitsForDone, runs, window, spyOn, jasmine */ + +define(function (require, exports, module) { + "use strict"; + + var SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + describe("MultiBrowser (experimental) - LiveDevelopment", function () { + + this.category = "livepreview"; + + var testWindow, + brackets, + CommandManager, + Commands, + EditorManager, + DocumentManager, + LiveDevelopment, + LiveDevProtocol, + LiveHTMLDocument, + PreferencesManager, + editor; + + var testFolder = SpecRunnerUtils.getTestPath("/spec/LiveDevelopment-MultiBrowser-test-files"), + allSpacesRE = /\s+/gi; + + function fixSpaces(str) { + return str.replace(allSpacesRE, " "); + } + + beforeEach(function () { + // Create a new window that will be shared by ALL tests in this spec. + if (!testWindow) { + runs(function () { + SpecRunnerUtils.createTestWindowAndRun(this, function (w) { + testWindow = w; + // Load module instances from brackets.test + brackets = testWindow.brackets; + CommandManager = brackets.test.CommandManager; + Commands = brackets.test.Commands; + EditorManager = brackets.test.EditorManager; + DocumentManager = brackets.test.DocumentManager; + LiveDevelopment = brackets.test.LiveDevMultiBrowser; + LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol"); + PreferencesManager = brackets.test.PreferencesManager; + }); + }); + + runs(function () { + SpecRunnerUtils.loadProjectInTestWindow(testFolder); + }); + } + }); + + afterEach(function () { + SpecRunnerUtils.closeTestWindow(); + testWindow = null; + brackets = null; + LiveDevelopment = null; + LiveDevProtocol = null; + }); + + function waitsForLiveDevelopmentToOpen() { + runs(function () { + LiveDevelopment.open(); + }); + waitsFor( + function isLiveDevelopmentActive() { + return LiveDevelopment.status === LiveDevelopment.STATUS_ACTIVE; + }, + "livedevelopment.done.opened", + 5000 + ); + } + + describe("Init Session", function () { + + + it("should establish a browser connection for an opened html file", function () { + //open a file + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html", 1000); + }); + + waitsForLiveDevelopmentToOpen(); + + runs(function () { + expect(LiveDevelopment.status).toBe(LiveDevelopment.STATUS_ACTIVE); + }); + }); + + it("should send all external stylesheets as related docs on start-up", function () { + var liveDoc; + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html", 1000); + }); + waitsForLiveDevelopmentToOpen(); + runs(function () { + liveDoc = LiveDevelopment._getCurrentLiveDoc(); + }); + waitsFor( + function relatedDocsReceived() { + return (Object.getOwnPropertyNames(liveDoc.getRelated().stylesheets).length > 0); + }, + "relatedDocuments.done.received", + 10000 + ); + runs(function () { + expect(liveDoc.isRelated(testFolder + "/simple1.css")).toBeTruthy(); + }); + runs(function () { + expect(liveDoc.isRelated(testFolder + "/simpleShared.css")).toBeTruthy(); + }); + }); + + it("should send all import-ed stylesheets as related docs on start-up", function () { + var liveDoc; + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html", 1000); + }); + waitsForLiveDevelopmentToOpen(); + runs(function () { + liveDoc = LiveDevelopment._getCurrentLiveDoc(); + }); + waitsFor( + function relatedDocsReceived() { + return (Object.getOwnPropertyNames(liveDoc.getRelated().scripts).length > 0); + }, + "relatedDocuments.done.received", + 10000 + ); + runs(function () { + expect(liveDoc.isRelated(testFolder + "/import1.css")).toBeTruthy(); + }); + }); + + it("should send all external javascript files as related docs on start-up", function () { + var liveDoc; + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html", 1000); + }); + waitsForLiveDevelopmentToOpen(); + + runs(function () { + liveDoc = LiveDevelopment._getCurrentLiveDoc(); + }); + waitsFor( + function relatedDocsReceived() { + return (Object.getOwnPropertyNames(liveDoc.getRelated().scripts).length > 0); + }, + "relatedDocuments.done.received", + 10000 + ); + runs(function () { + expect(liveDoc.isRelated(testFolder + "/simple1.js")).toBeTruthy(); + }); + }); + + it("should send notifications for added/removed stylesheets through link nodes", function () { + var liveDoc; + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html", 1000); + }); + waitsForLiveDevelopmentToOpen(); + + runs(function () { + liveDoc = LiveDevelopment._getCurrentLiveDoc(); + }); + + runs(function () { + var curDoc = DocumentManager.getCurrentDocument(); + curDoc.replaceRange('\n', {line: 8, ch: 0}); + }); + + waitsFor( + function relatedDocsReceived() { + return (Object.getOwnPropertyNames(liveDoc.getRelated().stylesheets).length === 4); + }, + "relatedDocuments.done.received", + 10000 + ); + + runs(function () { + expect(liveDoc.isRelated(testFolder + "/simple2.css")).toBeTruthy(); + }); + + runs(function () { + var curDoc = DocumentManager.getCurrentDocument(); + curDoc.replaceRange('', {line: 8, ch: 0}, {line: 8, ch: 50}); + }); + + waitsFor( + function relatedDocsReceived() { + return (Object.getOwnPropertyNames(liveDoc.getRelated().stylesheets).length === 3); + }, + "relatedDocuments.done.received", + 10000 + ); + + runs(function () { + expect(liveDoc.isRelated(testFolder + "/simple2.css")).toBeFalsy(); + }); + }); + + + it("should push changes through browser connection when editing a related CSS", function () { + var localText, + browserText, + liveDoc, + curDoc; + + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html", 1000); + }); + + waitsForLiveDevelopmentToOpen(); + + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.css"]), "SpecRunnerUtils.openProjectFiles simple1.css", 1000); + }); + runs(function () { + curDoc = DocumentManager.getCurrentDocument(); + localText = curDoc.getText(); + localText += "\n .testClass { background-color:#090; }\n"; + curDoc.setText(localText); + }); + runs(function () { + liveDoc = LiveDevelopment.getLiveDocForPath(testFolder + "/simple1.css"); + }); + var doneSyncing = false; + runs(function () { + liveDoc.getSourceFromBrowser().done(function (text) { + browserText = text; + }).always(function () { + doneSyncing = true; + }); + }); + waitsFor(function () { return doneSyncing; }, "Browser to sync changes", 5000); + + runs(function () { + expect(fixSpaces(browserText)).toBe(fixSpaces(localText)); + }); + }); + + it("should push in memory css changes made before the session starts", function () { + var localText, + browserText; + + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.css"]), "SpecRunnerUtils.openProjectFiles simple1.css", 1000); + }); + + runs(function () { + var curDoc = DocumentManager.getCurrentDocument(); + localText = curDoc.getText(); + localText += "\n .testClass { background-color:#090; }\n"; + curDoc.setText(localText); + + // Document should not be marked dirty + expect(LiveDevelopment.status).not.toBe(LiveDevelopment.STATUS_OUT_OF_SYNC); + }); + + runs(function () { + waitsForDone(SpecRunnerUtils.openProjectFiles(["simple1.html"]), "SpecRunnerUtils.openProjectFiles simple1.html", 1000); + }); + + waitsForLiveDevelopmentToOpen(); + + + var liveDoc, doneSyncing = false; + runs(function () { + liveDoc = LiveDevelopment.getLiveDocForPath(testFolder + "/simple1.css"); + }); + + runs(function () { + liveDoc.getSourceFromBrowser().done(function (text) { + browserText = text; + }).always(function () { + doneSyncing = true; + }); + }); + waitsFor(function () { return doneSyncing; }, "Browser to sync changes", 10000); + + runs(function () { + expect(fixSpaces(browserText)).toBe(fixSpaces(localText)); + }); + }); + }); + }); +});