src/LiveDevelopment/LiveDevelopment.js
/*
* Copyright (c) 2012 - present 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.
*
*/
/*global open */
/**
* LiveDevelopment manages the Inspector, all Agents, and the active LiveDocument
*
* __STARTING__
*
* To start a session call `open`. This will read the currentDocument from brackets,
* launch the LiveBrowser (currently Chrome) with the remote debugger port open,
* establish the Inspector connection to the remote debugger, and finally load all
* agents.
*
* __STOPPING__
*
* To stop a session call `close`. This will close the active browser window,
* disconnect the Inspector, unload all agents, and clean up.
*
* __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 to the remote debugger
* 2: Loading agents
* 3: Active
* 4: Out of sync
* 5: Sync error
*
* 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)
* - "detached_replaced_with_devtools" (The developer tools were opened in the browser)
*/
define(function LiveDevelopment(require, exports, module) {
"use strict";
require("utils/Global");
var _ = require("thirdparty/lodash");
// 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_LOADING_AGENTS = exports.STATUS_LOADING_AGENTS = 2;
var STATUS_ACTIVE = exports.STATUS_ACTIVE = 3;
var STATUS_OUT_OF_SYNC = exports.STATUS_OUT_OF_SYNC = 4;
var STATUS_SYNC_ERROR = exports.STATUS_SYNC_ERROR = 5;
var Async = require("utils/Async"),
CSSUtils = require("language/CSSUtils"),
Dialogs = require("widgets/Dialogs"),
DefaultDialogs = require("widgets/DefaultDialogs"),
DocumentManager = require("document/DocumentManager"),
EditorManager = require("editor/EditorManager"),
EventDispatcher = require("utils/EventDispatcher"),
FileServer = require("LiveDevelopment/Servers/FileServer").FileServer,
FileSystemError = require("filesystem/FileSystemError"),
FileUtils = require("file/FileUtils"),
LiveDevelopmentUtils = require("LiveDevelopment/LiveDevelopmentUtils"),
LiveDevServerManager = require("LiveDevelopment/LiveDevServerManager"),
MainViewManager = require("view/MainViewManager"),
NativeApp = require("utils/NativeApp"),
PreferencesDialogs = require("preferences/PreferencesDialogs"),
ProjectManager = require("project/ProjectManager"),
Strings = require("strings"),
StringUtils = require("utils/StringUtils"),
UserServer = require("LiveDevelopment/Servers/UserServer").UserServer,
WebSocketTransport = require("LiveDevelopment/transports/WebSocketTransport"),
PreferencesManager = require("preferences/PreferencesManager"),
HealthLogger = require("utils/HealthLogger");
// Inspector
var Inspector = require("LiveDevelopment/Inspector/Inspector");
// Documents
var CSSDocument = require("LiveDevelopment/Documents/CSSDocument"),
CSSPreprocessorDocument = require("LiveDevelopment/Documents/CSSPreprocessorDocument"),
HTMLDocument = require("LiveDevelopment/Documents/HTMLDocument"),
JSDocument = require("LiveDevelopment/Documents/JSDocument");
// Document errors
var SYNC_ERROR_CLASS = "live-preview-sync-error";
// Agents
var CSSAgent = require("LiveDevelopment/Agents/CSSAgent");
var agents = {
"console" : require("LiveDevelopment/Agents/ConsoleAgent"),
"remote" : require("LiveDevelopment/Agents/RemoteAgent"),
"network" : require("LiveDevelopment/Agents/NetworkAgent"),
"dom" : require("LiveDevelopment/Agents/DOMAgent"),
"css" : CSSAgent,
"script" : require("LiveDevelopment/Agents/ScriptAgent"),
"highlight" : require("LiveDevelopment/Agents/HighlightAgent"),
"goto" : require("LiveDevelopment/Agents/GotoAgent"),
"edit" : require("LiveDevelopment/Agents/EditAgent")
};
// construct path to launch.html
// window location is can be one of the following:
// Installed: /path/to/Brackets.app/Contents/www/index.html
// Installed, dev: /path/to/Brackets.app/Contents/dev/src/index.html
// Installed, dev, test: /path/to/Brackets.app/Contents/dev/test/SpecRunner.html
// Arbitrary git repo: /path/to/brackets/src/index.html
// Arbitrary git repo, test: /path/to/brackets/test/SpecRunner.html
var launcherUrl = window.location.pathname;
// special case for test/SpecRunner.html since we can't tell how requirejs
// baseUrl is configured dynamically
launcherUrl = launcherUrl.replace("/test/SpecRunner.html", "/src/index.html");
launcherUrl = launcherUrl.substr(0, launcherUrl.lastIndexOf("/")) + "/LiveDevelopment/launch.html";
launcherUrl = window.location.origin + launcherUrl;
// Some agents are still experimental, so we don't enable them all by default
// However, extensions can enable them by calling enableAgent().
// This object is used as a set (thus all properties have the value 'true').
// Property names should match property names in the 'agents' object.
var _enabledAgentNames = {
"console" : true,
"remote" : true,
"network" : true,
"css" : true,
"highlight" : true
};
/**
* Store the names (matching property names in the 'agent' object) of agents that we've loaded
* @type {string}
*/
var _loadedAgentNames = [];
/**
* Live Preview current Document info
* @type {HTMLDocument}
*/
var _liveDocument;
/**
* Related Live Documents
* @type {Object.<string: (HTMLDocument|CSSDocument)>}
*/
var _relatedDocuments = {};
/**
* Promise returned for each call to open()
* @type {jQuery.Deferred}
*/
var _openDeferred;
/**
* Promise returned for each call to close()
* @type {jQuery.Deferred}
*/
var _closeDeferred;
// Disallow re-entrancy of loadAgents()
var _loadAgentsPromise;
/**
* Current live preview server
* @type {BaseServer}
*/
var _server;
/**
* @private
* Handles of registered servers
*/
var _regServers = [];
PreferencesManager.definePreference("livedev.wsPort", "number", 8125, {
description: Strings.DESCRIPTION_LIVEDEV_WEBSOCKET_PORT
});
PreferencesManager.definePreference("livedev.enableReverseInspect", "boolean", true, {
description: Strings.DESCRIPTION_LIVEDEV_ENABLE_REVERSE_INSPECT
});
function _isPromisePending(promise) {
return promise && promise.state() === "pending";
}
/** Get the current document from the document manager
* _adds extension, url and root to the document
*/
function _getCurrentDocument() {
return DocumentManager.getCurrentDocument();
}
/** Determine which document class should be used for a given document
* @param {Document} document
*/
function _classForDocument(doc) {
switch (doc.getLanguage().getId()) {
case "less":
case "scss":
return CSSPreprocessorDocument;
case "css":
return CSSDocument;
case "javascript":
return exports.config.experimental ? JSDocument : null;
}
if (LiveDevelopmentUtils.isHtmlFileExt(doc.file.fullPath)) {
return HTMLDocument;
}
return null;
}
function getLiveDocForPath(path) {
if (!_server) {
return undefined;
}
return _server.get(path);
}
function getLiveDocForEditor(editor) {
if (!editor) {
return null;
}
return getLiveDocForPath(editor.document.file.fullPath);
}
/**
* @private
* Clears errors from line number gutter (line class)
* @param {HTMLDocument|CSSDocument} liveDocument
*/
function _doClearErrors(liveDocument) {
var lineHandle;
if (!liveDocument.editor ||
!liveDocument._errorLineHandles ||
!liveDocument._errorLineHandles.length) {
return;
}
liveDocument.editor._codeMirror.operation(function () {
while (true) {
// Iterate over all lines that were previously marked with an error
lineHandle = liveDocument._errorLineHandles.pop();
if (!lineHandle) {
break;
}
liveDocument.editor._codeMirror.removeLineClass(lineHandle, "wrap", SYNC_ERROR_CLASS);
}
});
}
/**
* @private
* Make a message to direct users to the troubleshooting page
* @param {string} msg Original message
* @return {string} Original message plus link to troubleshooting page.
*/
function _makeTroubleshootingMessage(msg) {
return msg + " " + StringUtils.format(Strings.LIVE_DEVELOPMENT_TROUBLESHOOTING, brackets.config.troubleshoot_url);
}
/**
* @private
* Close a live document
*/
function _closeDocument(liveDocument) {
_doClearErrors(liveDocument);
liveDocument.close();
if (liveDocument.editor) {
liveDocument.editor.off(".livedev");
}
liveDocument.off(".livedev");
}
/**
* 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).
*/
function _closeRelatedDocument(liveDoc) {
if (_relatedDocuments[liveDoc.doc.url]) {
delete _relatedDocuments[liveDoc.doc.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.trigger("statusChange", status, reason);
}
/**
* @private
* Event handler for live document errors. Displays error status in the editor gutter.
* @param {$.Event} event
* @param {HTMLDocument|CSSDocument} liveDocument
* @param {Array.<{token: SimpleNode, startPos: Pos, endPos: Pos}>} errors
*/
function _handleLiveDocumentStatusChanged(liveDocument) {
var startLine,
endLine,
i,
lineHandle,
status = (liveDocument.errors.length) ? STATUS_SYNC_ERROR : STATUS_ACTIVE;
_setStatus(status);
if (!liveDocument.editor) {
return;
}
// Buffer addLineClass DOM changes in a CodeMirror operation
liveDocument.editor._codeMirror.operation(function () {
// Remove existing errors before marking new ones
_doClearErrors(liveDocument);
liveDocument._errorLineHandles = liveDocument._errorLineHandles || [];
liveDocument.errors.forEach(function (error) {
startLine = error.startPos.line;
endLine = error.endPos.line;
for (i = startLine; i < endLine + 1; i++) {
lineHandle = liveDocument.editor._codeMirror.addLineClass(i, "wrap", SYNC_ERROR_CLASS);
liveDocument._errorLineHandles.push(lineHandle);
}
});
});
}
/**
* @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
* Create a live version of a Brackets document
* @param {Document} doc Current document
* @param {Editor} editor Current editor
* @return {?(HTMLDocument|CSSDocument)}
*/
function _createDocument(doc, editor) {
var DocClass = _classForDocument(doc),
liveDocument = new DocClass(doc, editor);
if (!DocClass) {
return null;
}
liveDocument.on("statusChanged.livedev", function () {
_handleLiveDocumentStatusChanged(liveDocument);
});
return liveDocument;
}
/**
* @private
* Initialize `_liveDocument`.
* @param {Document} doc Current document
*/
function _createLiveDocumentForFrame(doc) {
// create live document
doc._ensureMasterEditor();
_liveDocument = _createDocument(doc, doc._masterEditor);
_server.add(_liveDocument);
}
/** Enable an agent. Takes effect next time a connection is made. Does not affect
* current live development sessions.
*
* @param {string} name of agent to enable
*/
function enableAgent(name) {
if (agents.hasOwnProperty(name) && !_enabledAgentNames.hasOwnProperty(name)) {
_enabledAgentNames[name] = true;
}
}
/** Disable an agent. Takes effect next time a connection is made. Does not affect
* current live development sessions.
*
* @param {string} name of agent to disable
*/
function disableAgent(name) {
if (_enabledAgentNames.hasOwnProperty(name)) {
delete _enabledAgentNames[name];
}
}
/** Documents are considered to be out-of-sync if they are dirty and
* do not have "update while editing" support
* @param {Document} doc
*/
function _docIsOutOfSync(doc) {
var liveDoc = _server && _server.get(doc.file.fullPath),
isLiveEditingEnabled = liveDoc && liveDoc.isLiveEditingEnabled();
return doc.isDirty && !isLiveEditingEnabled;
}
/** Triggered by Inspector.error */
function _onError(event, error, msgData) {
var message;
// Sometimes error.message is undefined
if (!error.message) {
console.warn("Expected a non-empty string in error.message, got this instead:", error.message);
message = JSON.stringify(error);
} else {
message = error.message;
}
// Remove "Uncaught" from the beginning to avoid the inspector popping up
if (message && message.substr(0, 8) === "Uncaught") {
message = message.substr(9);
}
// Additional information, like exactly which parameter could not be processed.
var data = error.data;
if (Array.isArray(data)) {
message += "\n" + data.join("\n");
}
// Show the message, but include the error object for further information (e.g. error code)
console.error(message, error, msgData);
}
function _styleSheetAdded(event, url) {
var path = _server && _server.urlToPath(url),
exists = !!_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 || exists) {
return;
}
var docPromise = DocumentManager.getDocumentForPath(path);
docPromise.done(function (doc) {
if ((_classForDocument(doc) === CSSDocument ||
_classForDocument(doc) === CSSPreprocessorDocument) &&
(!_liveDocument || (doc !== _liveDocument.doc))) {
// The doc may already have an editor (e.g. starting live preview from an css file),
// so pass the editor if any
var liveDoc = _createDocument(doc, doc._masterEditor);
if (liveDoc) {
_server.add(liveDoc);
_relatedDocuments[doc.url] = liveDoc;
liveDoc.on("deleted.livedev", function (event, liveDoc) {
_closeRelatedDocument(liveDoc);
});
}
}
});
}
/** Unload the agents */
function unloadAgents() {
_loadedAgentNames.forEach(function (name) {
agents[name].unload();
});
_loadedAgentNames = [];
}
/**
* @private
* Invoke a no-arg method on an inspector agent
* @param {string} name Agent name
* @param {stirng} methodName Method name to call on the agent
*/
function _invokeAgentMethod(name, methodName) {
var oneAgentPromise;
if (agents[name] && agents[name][methodName]) {
oneAgentPromise = agents[name][methodName].call();
}
if (!oneAgentPromise) {
oneAgentPromise = new $.Deferred().resolve().promise();
} else {
oneAgentPromise.fail(function () {
console.error(methodName + " failed on agent", name);
});
}
return oneAgentPromise;
}
function getEnabledAgents() {
var enabledAgents;
// Select agents to use
if (exports.config.experimental) {
// load all agents
enabledAgents = agents;
} else {
// load only enabled agents
enabledAgents = _enabledAgentNames;
}
return Object.keys(enabledAgents);
}
/**
* @private
* Setup agents that need inspector domains enabled before loading
*/
function _enableAgents() {
// enable agents in parallel
return Async.doInParallel(
getEnabledAgents(),
function (name) {
return _invokeAgentMethod(name, "enable");
},
true
);
}
/** Load the agents */
function loadAgents() {
// If we're already loading agents return same promise
if (_loadAgentsPromise) {
return _loadAgentsPromise;
}
var result = new $.Deferred(),
allAgentsPromise;
_loadAgentsPromise = result.promise();
_setStatus(STATUS_LOADING_AGENTS);
// load agents in parallel
allAgentsPromise = Async.doInParallel(
getEnabledAgents(),
function (name) {
return _invokeAgentMethod(name, "load").done(function () {
_loadedAgentNames.push(name);
});
},
true
);
// wrap agent loading with a timeout
allAgentsPromise = Async.withTimeout(allAgentsPromise, 10000);
allAgentsPromise.done(function () {
var doc = (_liveDocument) ? _liveDocument.doc : null;
if (doc) {
var status = STATUS_ACTIVE;
if (_docIsOutOfSync(doc)) {
status = STATUS_OUT_OF_SYNC;
}
_setStatus(status);
result.resolve();
} else {
result.reject();
}
});
allAgentsPromise.fail(result.reject);
_loadAgentsPromise
.fail(function () {
// show error loading live dev dialog
_setStatus(STATUS_ERROR);
Dialogs.showModalDialog(
Dialogs.DIALOG_ID_ERROR,
Strings.LIVE_DEVELOPMENT_ERROR_TITLE,
_makeTroubleshootingMessage(Strings.LIVE_DEV_LOADING_ERROR_MESSAGE)
);
})
.always(function () {
_loadAgentsPromise = null;
});
return _loadAgentsPromise;
}
/**
* @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 development, 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 = _getCurrentDocument(),
refPath,
i;
// Is the currently opened document already a file we can use for Live Development?
if (doc) {
refPath = doc.file.fullPath;
if (LiveDevelopmentUtils.isStaticHtmlFileExt(refPath) || LiveDevelopmentUtils.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.getParentPath(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 ((LiveDevelopmentUtils.isServerHtmlFileExt(fileInfo.name)) ||
(LiveDevelopmentUtils.isStaticHtmlFileExt(fileInfo.name))) {
return true;
}
} else if (LiveDevelopmentUtils.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.getParentPath(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();
}
/**
* If the current editor is for a CSS preprocessor file, then add it to the style sheet
* so that we can track cursor positions in the editor to show live preview highlighting.
* For normal CSS we only do highlighting from files we know for sure are referenced by the
* current live preview document, but for preprocessors we just assume that any preprocessor
* file you edit is probably related to the live preview.
*
* @param {Event} event (unused)
* @param {Editor} current Current editor
* @param {Editor} previous Previous editor
*
*/
function onActiveEditorChange(event, current, previous) {
if (previous && previous.document &&
CSSUtils.isCSSPreprocessorFile(previous.document.file.fullPath)) {
var prevDocUrl = _server && _server.pathToUrl(previous.document.file.fullPath);
if (_relatedDocuments && _relatedDocuments[prevDocUrl]) {
_closeRelatedDocument(_relatedDocuments[prevDocUrl]);
}
}
if (current && current.document &&
CSSUtils.isCSSPreprocessorFile(current.document.file.fullPath)) {
var docUrl = _server && _server.pathToUrl(current.document.file.fullPath);
_styleSheetAdded(null, docUrl);
}
}
/**
* @private
* While still connected to the Inspector, do cleanup for agents,
* documents and server.
* @param {boolean} doCloseWindow Use true to close the window/tab in the browser
* @return {jQuery.Promise} A promise that is always resolved
*/
function _doInspectorDisconnect(doCloseWindow) {
var closePromise,
deferred = new $.Deferred(),
connected = Inspector.connected();
EditorManager.off("activeEditorChange", onActiveEditorChange);
Inspector.Page.off(".livedev");
Inspector.off(".livedev");
// Wait if agents are loading
if (_loadAgentsPromise) {
_loadAgentsPromise.always(unloadAgents);
} else {
unloadAgents();
}
// Close live documents
_closeDocuments();
if (_server) {
// Stop listening for requests when disconnected
_server.stop();
// Dispose server
_server = null;
}
if (doCloseWindow && connected) {
closePromise = Inspector.Runtime.evaluate("window.open('', '_self').close();");
// Add a timeout to continue cleanup if Inspector does not respond
closePromise = Async.withTimeout(closePromise, 5000);
} else {
closePromise = new $.Deferred().resolve();
}
// Disconnect WebSocket if connected
closePromise.always(function () {
if (Inspector.connected()) {
Inspector.disconnect().always(deferred.resolve);
} else {
deferred.resolve();
}
});
return deferred.promise();
}
/**
* @private
* Close the connection and the associated window asynchronously
* @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)
* @return {jQuery.Promise} Always return a resolved promise once the connection is closed
*/
function _close(doCloseWindow, reason) {
WebSocketTransport.closeWebSocketServer();
if (_closeDeferred) {
return _closeDeferred;
} else {
_closeDeferred = new $.Deferred();
_closeDeferred.always(function () {
_closeDeferred = null;
});
}
var promise = _closeDeferred.promise();
/*
* Finish closing the live development connection, including setting
* the status accordingly.
*/
function cleanup() {
// Need to do this in order to trigger the corresponding CloseLiveBrowser cleanups required on
// the native Mac side
var closeDeferred = (brackets.platform === "mac") ? NativeApp.closeLiveBrowser() : $.Deferred().resolve();
closeDeferred.done(function () {
_setStatus(STATUS_INACTIVE, reason || "explicit_close");
// clean-up registered servers
_regServers.forEach(function (server) {
LiveDevServerManager.removeServer(server);
});
_regServers = [];
_closeDeferred.resolve();
}).fail(function (err) {
if (err) {
reason += " (" + err + ")";
}
_setStatus(STATUS_INACTIVE, reason || "explicit_close");
_closeDeferred.resolve();
});
}
if (_isPromisePending(_openDeferred)) {
// Reject calls to open if requests are still pending
_openDeferred.reject();
}
if (exports.status === STATUS_INACTIVE) {
// Ignore close if status is inactive
_closeDeferred.resolve();
} else {
_doInspectorDisconnect(doCloseWindow).always(cleanup);
}
return promise;
}
// WebInspector Event: Page.frameNavigated
function _onFrameNavigated(event, res) {
// res = {frame}
var url = res.frame.url,
baseUrl,
baseUrlRegExp;
// Only check domain of root frame (with undefined parentId)
if (res.frame.parentId) {
return;
}
// Any local file is OK
if (url.match(/^file:\/\//i) || !_server) {
return;
}
// Need base url to build reg exp
baseUrl = _server.getBaseUrl();
if (!baseUrl) {
return;
}
// Test that url is within site
baseUrlRegExp = new RegExp("^" + StringUtils.regexEscape(baseUrl), "i");
if (!url.match(baseUrlRegExp)) {
// No longer in site, so terminate live dev, but don't close browser window
_close(false, "navigated_away");
}
}
/**
* @private
* Triggered by unexpected Inspector disconnect event
*/
function _onDisconnect(event) {
_close(false, "closed_unknown_reason");
}
function _onDetached(event, res) {
var closeReason;
if (res && res.reason) {
// Get the explanation from res.reason, e.g. "replaced_with_devtools", "target_closed", "canceled_by_user"
// Examples taken from https://chromiumcodereview.appspot.com/10947037/patch/12001/13004
// However, the link refers to the Chrome Extension API, it may not apply 100% to the Inspector API
// Prefix with "detached_" to create a quasi-namespace for Chrome's reasons
closeReason = "detached_" + res.reason;
}
_close(false, closeReason);
}
/**
* Unload and reload agents
* @return {jQuery.Promise} Resolves once the agents are loaded
*/
function reconnect() {
if (_loadAgentsPromise) {
// Agents are already loading, so don't unload
return _loadAgentsPromise;
}
unloadAgents();
// Clear any existing related documents before we reload the agents.
// We need to recreate them for the reloaded document due to some
// desirable side-effects (see #7606). Eventually, we should simplify
// the way we get that behavior.
_.forOwn(_relatedDocuments, function (relatedDoc) {
_closeRelatedDocument(relatedDoc);
});
return loadAgents();
}
/** reload the live preview */
function reload() {
// Unload and reload agents before reloading the page
// Some agents (e.g. DOMAgent and RemoteAgent) require us to
// navigate to the page first before loading can complete.
// To accomodate this, we load all agents (in reconnect())
// and navigate in parallel.
reconnect();
// Reload HTML page
Inspector.Page.reload();
}
/**
* Close the connection and the associated window asynchronously
* @return {jQuery.Promise} Resolves once the connection is closed
*/
function close() {
return _close(true);
}
/**
* @private
* Create a promise that resolves when the interstitial page has
* finished loading.
*
* @return {jQuery.Promise} Resolves once page is loaded
*/
function _waitForInterstitialPageLoad() {
var deferred = $.Deferred(),
keepPolling = true,
timer = window.setTimeout(function () {
keepPolling = false;
deferred.reject();
}, 10000); // 10 seconds
/*
* Asynchronously check to see if the interstitial page has
* finished loading; if not, check again until timing out.
*/
function pollInterstitialPage() {
if (keepPolling && Inspector.connected()) {
Inspector.Runtime.evaluate("window.isBracketsLiveDevelopmentInterstitialPageLoaded", function (response) {
var result = response.result;
if (result.type === "boolean" && result.value) {
window.clearTimeout(timer);
deferred.resolve();
} else {
window.setTimeout(pollInterstitialPage, 100);
}
});
} else {
deferred.reject();
}
}
pollInterstitialPage();
return deferred.promise();
}
/**
* @private
* Load agents and navigate to the target document once the
* interstitial page has finished loading.
*/
function _onInterstitialPageLoad() {
Inspector.Runtime.evaluate("window.navigator.userAgent", function (uaResponse) {
Inspector.setUserAgent(uaResponse.result.value);
});
// Domains for some agents must be enabled first before loading
var enablePromise = Inspector.Page.enable().then(function () {
return Inspector.DOM.enable().then(_enableAgents, _enableAgents);
});
enablePromise.done(function () {
// Some agents (e.g. DOMAgent and RemoteAgent) require us to
// navigate to the page first before loading can complete.
// To accomodate this, we load all agents and navigate in
// parallel.
// resolve/reject the open() promise after agents complete
loadAgents().then(_openDeferred.resolve, _openDeferred.reject);
_getInitialDocFromCurrent().done(function (doc) {
if (doc && _liveDocument) {
if (doc !== _liveDocument.doc) {
_createLiveDocumentForFrame(doc);
}
// Navigate from interstitial to the document
// Fires a frameNavigated event
if (_server) {
Inspector.Page.navigate(_server.pathToUrl(doc.file.fullPath));
} else {
console.error("LiveDevelopment._onInterstitialPageLoad(): 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();
}
});
});
}
/** Triggered by Inspector.connect */
function _onConnect(event) {
// When the browser navigates away from the primary live document
Inspector.Page.on("frameNavigated.livedev", _onFrameNavigated);
// When the Inspector WebSocket disconnects unexpectedely
Inspector.on("disconnect.livedev", _onDisconnect);
_waitForInterstitialPageLoad()
.fail(function () {
close();
Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_ERROR,
Strings.LIVE_DEVELOPMENT_ERROR_TITLE,
_makeTroubleshootingMessage(Strings.LIVE_DEV_LOADING_ERROR_MESSAGE)
);
})
.done(_onInterstitialPageLoad);
}
function _showWrongDocError() {
Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_ERROR,
Strings.LIVE_DEVELOPMENT_ERROR_TITLE,
_makeTroubleshootingMessage(Strings.LIVE_DEV_NEED_HTML_MESSAGE)
);
_openDeferred.reject();
}
function _showLiveDevServerNotReadyError() {
Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_ERROR,
Strings.LIVE_DEVELOPMENT_ERROR_TITLE,
_makeTroubleshootingMessage(Strings.LIVE_DEV_SERVER_NOT_READY_MESSAGE)
);
_openDeferred.reject();
}
function _openInterstitialPage() {
var browserStarted = false,
retryCount = 0;
// Open the live browser if the connection fails, retry 3 times
Inspector.connectToURL(launcherUrl).fail(function onConnectFail(err) {
if (err === "CANCEL") {
_openDeferred.reject(err);
return;
}
if (retryCount > 3) {
_setStatus(STATUS_ERROR);
var dialogPromise = Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_LIVE_DEVELOPMENT,
Strings.LIVE_DEVELOPMENT_RELAUNCH_TITLE,
_makeTroubleshootingMessage(Strings.LIVE_DEVELOPMENT_ERROR_MESSAGE),
[
{
className: Dialogs.DIALOG_BTN_CLASS_LEFT,
id: Dialogs.DIALOG_BTN_CANCEL,
text: Strings.CANCEL
},
{
className: Dialogs.DIALOG_BTN_CLASS_PRIMARY,
id: Dialogs.DIALOG_BTN_OK,
text: Strings.RELAUNCH_CHROME
}
]
);
dialogPromise.done(function (id) {
if (id === Dialogs.DIALOG_BTN_OK) {
// User has chosen to reload Chrome, quit the running instance
_setStatus(STATUS_INACTIVE);
_close()
.done(function () {
browserStarted = false;
// Continue to use _openDeferred
open(true);
})
.fail(function (err) {
// Report error?
_setStatus(STATUS_ERROR);
browserStarted = false;
_openDeferred.reject("CLOSE_LIVE_BROWSER");
});
} else {
_close()
.done(function () {
browserStarted = false;
_openDeferred.reject("CANCEL");
})
.fail(function (err) {
// Report error?
_setStatus(STATUS_ERROR);
browserStarted = false;
_openDeferred.reject("CLOSE_LIVE_BROWSER");
});
}
});
return;
}
retryCount++;
if (!browserStarted && exports.status !== STATUS_ERROR) {
NativeApp.openLiveBrowser(
launcherUrl,
true // enable remote debugging
)
.done(function () {
browserStarted = true;
})
.fail(function (err) {
var message;
_setStatus(STATUS_ERROR);
if (err === FileSystemError.NOT_FOUND) {
message = Strings.ERROR_CANT_FIND_CHROME;
} else {
message = StringUtils.format(Strings.ERROR_LAUNCHING_BROWSER, err);
}
Dialogs.showModalDialog(
DefaultDialogs.DIALOG_ID_ERROR,
Strings.ERROR_LAUNCHING_BROWSER_TITLE,
_makeTroubleshootingMessage(message)
);
_openDeferred.reject("OPEN_LIVE_BROWSER");
});
}
if (exports.status !== STATUS_ERROR) {
window.setTimeout(function retryConnect() {
Inspector.connectToURL(launcherUrl).fail(onConnectFail);
}, 3000);
}
});
}
// helper function that actually does the launch once we are sure we have
// a doc and the server for that doc is up and running.
function _doLaunchAfterServerReady(initialDoc) {
// update status
_setStatus(STATUS_CONNECTING);
_createLiveDocumentForFrame(initialDoc);
// start listening for requests
_server.start();
// Install a one-time event handler when connected to the launcher page
Inspector.one("connect", _onConnect);
// open browser to the interstitial page to prepare for loading agents
_openInterstitialPage();
// Once all agents loaded (see _onInterstitialPageLoad()), begin Live Highlighting for preprocessor documents
_openDeferred.done(function () {
// Setup activeEditorChange event listener so that we can track cursor positions in
// CSS preprocessor files and perform live preview highlighting on all elements with
// the current selector in the preprocessor file.
EditorManager.on("activeEditorChange", onActiveEditorChange);
// Explicitly trigger onActiveEditorChange so that live preview highlighting
// can be set up for the preprocessor files.
onActiveEditorChange(null, EditorManager.getActiveEditor(), null);
});
}
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 = !exports.config.experimental && !_server &&
LiveDevelopmentUtils.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();
}
function getCurrentProjectServerConfig() {
return {
baseUrl: ProjectManager.getBaseUrl(),
pathResolver: ProjectManager.makeProjectRelativeIfPossible,
root: ProjectManager.getProjectRoot().fullPath
};
}
function _createUserServer() {
return new UserServer(getCurrentProjectServerConfig());
}
function _createFileServer() {
return new FileServer(getCurrentProjectServerConfig());
}
/**
* Open the Connection and go live
*
* @param {!boolean} restart true if relaunching and _openDeferred already exists
* @return {jQuery.Promise} Resolves once live preview is open
*/
function open(restart) {
// If close() is still pending, wait for close to finish before opening
if (_isPromisePending(_closeDeferred)) {
return _closeDeferred.then(function () {
return open(restart);
});
}
if (!restart) {
// Return existing promise if it is still pending
if (_isPromisePending(_openDeferred)) {
return _openDeferred;
} else {
_openDeferred = new $.Deferred();
_openDeferred.always(function () {
_openDeferred = null;
});
}
}
// Send analytics data when Live Preview is opened
HealthLogger.sendAnalyticsData(
"livePreviewOpen",
"usage",
"livePreview",
"open"
);
// Register user defined server provider and keep handlers for further clean-up
_regServers.push(LiveDevServerManager.registerServer({ create: _createUserServer }, 99));
_regServers.push(LiveDevServerManager.registerServer({ create: _createFileServer }, 0));
// TODO: need to run _onFileChanged() 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 = MainViewManager.getWorkingSet(MainViewManager.ALL_PANES).length;
MainViewManager.addToWorkingSet(MainViewManager.ACTIVE_PANE, doc.file);
if (!otherDocumentsInWorkingFiles) {
MainViewManager._edit(MainViewManager.ACTIVE_PANE, doc);
}
}
// wait for server (StaticServer, Base URL or file:)
prepareServerPromise
.done(function () {
var reverseInspectPref = PreferencesManager.get("livedev.enableReverseInspect"),
wsPort = PreferencesManager.get("livedev.wsPort");
if (wsPort && reverseInspectPref) {
WebSocketTransport.createWebSocketServer(wsPort);
}
_doLaunchAfterServerReady(doc);
})
.fail(function () {
_showWrongDocError();
});
});
return _openDeferred.promise();
}
/** Enable highlighting */
function showHighlight() {
var doc = getLiveDocForEditor(EditorManager.getActiveEditor());
if (doc && doc.updateHighlight) {
doc.updateHighlight();
}
}
/** Hide any active highlighting */
function hideHighlight() {
if (Inspector.connected() && agents.highlight) {
agents.highlight.hide();
}
}
/** Redraw highlights **/
function redrawHighlight() {
if (Inspector.connected() && agents.highlight) {
agents.highlight.redraw();
}
}
/**
* @private
* MainViewManager.currentFileChange event handler.
*/
function _onFileChanged() {
var doc = _getCurrentDocument();
if (!doc || !Inspector.connected()) {
return;
}
// close the current session and begin a new session if the current
// document changes to an HTML document that was not loaded yet
var docUrl = _server && _server.pathToUrl(doc.file.fullPath),
wasRequested = agents.network && agents.network.wasURLRequested(docUrl),
isViewable = exports.config.experimental || (_server && _server.canServe(doc.file.fullPath));
if (!wasRequested && isViewable) {
// Update status
_setStatus(STATUS_CONNECTING);
// clear live doc and related docs
_closeDocuments();
// create new live doc
_createLiveDocumentForFrame(doc);
// Navigate to the new page within this site. Agents must handle
// frameNavigated event to clear any saved state.
Inspector.Page.navigate(docUrl).then(function () {
_setStatus(STATUS_ACTIVE);
}, function () {
_close(false, "closed_unknown_reason");
});
} else if (wasRequested) {
// Update highlight
showHighlight();
}
}
/**
* Triggered by a documentSaved event from DocumentManager.
* @param {$.Event} event
* @param {Document} doc
*/
function _onDocumentSaved(event, doc) {
if (!Inspector.connected() || !_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;
}
var documentUrl = _server.pathToUrl(absolutePath),
wasRequested = agents.network && agents.network.wasURLRequested(documentUrl);
if (wasRequested) {
reload();
}
}
/** Triggered by a change in dirty flag from the DocumentManager */
function _onDirtyFlagChange(event, doc) {
if (doc && Inspector.connected() &&
_server && agents.network && agents.network.wasURLRequested(_server.pathToUrl(doc.file.fullPath))) {
// Set status to out of sync if dirty. Otherwise, set it to active status.
_setStatus(_docIsOutOfSync(doc) ? STATUS_OUT_OF_SYNC : STATUS_ACTIVE);
}
}
/** Initialize the LiveDevelopment Session */
function init(theConfig) {
exports.config = theConfig;
Inspector.on("error", _onError);
Inspector.Inspector.on("detached", _onDetached);
// Only listen for styleSheetAdded
// We may get interim added/removed events when pushing incremental updates
CSSAgent.on("styleSheetAdded.livedev", _styleSheetAdded);
MainViewManager
.on("currentFileChange", _onFileChanged);
DocumentManager
.on("documentSaved", _onDocumentSaved)
.on("dirtyFlagChange", _onDirtyFlagChange);
ProjectManager
.on("beforeProjectClose beforeAppClose", close);
// Initialize exports.status
_setStatus(STATUS_INACTIVE);
}
function _getServer() {
return _server;
}
function getServerBaseUrl() {
return _server && _server.getBaseUrl();
}
EventDispatcher.makeEventDispatcher(exports);
// For unit testing
exports.launcherUrl = launcherUrl;
exports._getServer = _getServer;
exports._getInitialDocFromCurrent = _getInitialDocFromCurrent;
// Export public functions
exports.agents = agents;
exports.open = open;
exports.close = close;
exports.reconnect = reconnect;
exports.reload = reload;
exports.enableAgent = enableAgent;
exports.disableAgent = disableAgent;
exports.getLiveDocForPath = getLiveDocForPath;
exports.showHighlight = showHighlight;
exports.hideHighlight = hideHighlight;
exports.redrawHighlight = redrawHighlight;
exports.init = init;
exports.getCurrentProjectServerConfig = getCurrentProjectServerConfig;
exports.getServerBaseUrl = getServerBaseUrl;
});