UNPKG

25.5 kBJavaScriptView Raw
1/*
2 * Copyright (c) 2012 Adobe Systems Incorporated. All rights reserved.
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining a
5 * copy of this software and associated documentation files (the "Software"),
6 * to deal in the Software without restriction, including without limitation
7 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 * and/or sell copies of the Software, and to permit persons to whom the
9 * Software is furnished to do so, subject to the following conditions:
10 *
11 * The above copyright notice and this permission notice shall be included in
12 * all copies or substantial portions of the Software.
13 *
14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 * DEALINGS IN THE SOFTWARE.
21 *
22 */
23
24/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, forin: true, maxerr: 50, regexp: true */
25/*global define, $, FileError, brackets, window */
26
27/**
28 * LiveDevelopment manages the Inspector, all Agents, and the active LiveDocument
29 *
30 * # STARTING
31 *
32 * To start a session call `open`. This will read the currentDocument from brackets,
33 * launch the LiveBrowser (currently Chrome) with the remote debugger port open,
34 * establish the Inspector connection to the remote debugger, and finally load all
35 * agents.
36 *
37 * # STOPPING
38 *
39 * To stop a session call `close`. This will close the active browser window,
40 * disconnect the Inspector, unload all agents, and clean up.
41 *
42 * # STATUS
43 *
44 * Status updates are dispatched as `statusChange` jQuery events. The status
45 * codes are:
46 *
47 * -1: Error
48 * 0: Inactive
49 * 1: Connecting to the remote debugger
50 * 2: Loading agents
51 * 3: Active
52 * 4: Out of sync
53 */
54define(function LiveDevelopment(require, exports, module) {
55 "use strict";
56
57 require("utils/Global");
58
59 // Status Codes
60 var STATUS_ERROR = exports.STATUS_ERROR = -1;
61 var STATUS_INACTIVE = exports.STATUS_INACTIVE = 0;
62 var STATUS_CONNECTING = exports.STATUS_CONNECTING = 1;
63 var STATUS_LOADING_AGENTS = exports.STATUS_LOADING_AGENTS = 2;
64 var STATUS_ACTIVE = exports.STATUS_ACTIVE = 3;
65 var STATUS_OUT_OF_SYNC = exports.STATUS_OUT_OF_SYNC = 4;
66
67 var Dialogs = require("widgets/Dialogs"),
68 DocumentManager = require("document/DocumentManager"),
69 EditorManager = require("editor/EditorManager"),
70 FileUtils = require("file/FileUtils"),
71 NativeApp = require("utils/NativeApp"),
72 PreferencesDialogs = require("preferences/PreferencesDialogs"),
73 ProjectManager = require("project/ProjectManager"),
74 Strings = require("strings"),
75 StringUtils = require("utils/StringUtils");
76
77 // Inspector
78 var Inspector = require("LiveDevelopment/Inspector/Inspector");
79
80 // Documents
81 var CSSDocument = require("LiveDevelopment/Documents/CSSDocument"),
82 HTMLDocument = require("LiveDevelopment/Documents/HTMLDocument"),
83 JSDocument = require("LiveDevelopment/Documents/JSDocument");
84
85 // Agents
86 var agents = {
87 "console" : require("LiveDevelopment/Agents/ConsoleAgent"),
88 "remote" : require("LiveDevelopment/Agents/RemoteAgent"),
89 "network" : require("LiveDevelopment/Agents/NetworkAgent"),
90 "dom" : require("LiveDevelopment/Agents/DOMAgent"),
91 "css" : require("LiveDevelopment/Agents/CSSAgent"),
92 "script" : require("LiveDevelopment/Agents/ScriptAgent"),
93 "highlight" : require("LiveDevelopment/Agents/HighlightAgent"),
94 "goto" : require("LiveDevelopment/Agents/GotoAgent"),
95 "edit" : require("LiveDevelopment/Agents/EditAgent")
96 };
97
98 // Some agents are still experimental, so we don't enable them all by default
99 // However, extensions can enable them by calling enableAgent().
100 // This object is used as a set (thus all properties have the value 'true').
101 // Property names should match property names in the 'agents' object.
102 var _enabledAgentNames = {
103 "console" : true,
104 "remote" : true,
105 "network" : true,
106 "dom" : true,
107 "css" : true
108 };
109
110 // store the names (matching property names in the 'agent' object) of agents that we've loaded
111 var _loadedAgentNames = [];
112
113 var _liveDocument; // the document open for live editing.
114 var _relatedDocuments; // CSS and JS documents that are used by the live HTML document
115
116 function _isHtmlFileExt(ext) {
117 return (FileUtils.isStaticHtmlFileExt(ext) ||
118 (ProjectManager.getBaseUrl() && FileUtils.isServerHtmlFileExt(ext)));
119 }
120
121 /** Convert a URL to a local full file path */
122 function _urlToPath(url) {
123 var path,
124 baseUrl = ProjectManager.getBaseUrl();
125
126 if (baseUrl !== "" && url.indexOf(baseUrl) === 0) {
127 // Use base url to translate to local file path.
128 // Need to use encoded project path because it's decoded below.
129 path = url.replace(baseUrl, encodeURI(ProjectManager.getProjectRoot().fullPath));
130
131 } else if (url.indexOf("file://") === 0) {
132 // Convert a file URL to local file path
133 path = url.slice(7);
134 if (path && brackets.platform === "win" && path.charAt(0) === "/") {
135 path = path.slice(1);
136 }
137 }
138 return decodeURI(path);
139 }
140
141 /** Convert a local full file path to a URL */
142 function _pathToUrl(path) {
143 var url,
144 baseUrl = ProjectManager.getBaseUrl();
145
146 // See if base url has been specified and path is within project
147 if (baseUrl !== "" && ProjectManager.isWithinProject(path)) {
148 // Map to server url. Base url is already encoded, so don't encode again.
149 var encodedDocPath = encodeURI(path);
150 var encodedProjectPath = encodeURI(ProjectManager.getProjectRoot().fullPath);
151 url = encodedDocPath.replace(encodedProjectPath, baseUrl);
152
153 } else {
154 var prefix = "file://";
155
156 if (brackets.platform === "win") {
157 // The path on Windows starts with a drive letter (e.g. "C:").
158 // In order to make it a valid file: URL we need to add an
159 // additional slash to the prefix.
160 prefix += "/";
161 }
162
163 url = encodeURI(prefix + path);
164 }
165
166 return url;
167 }
168
169 /** Augments the given Brackets document with information that's useful for live development. */
170 function _setDocInfo(doc) {
171
172 var parentUrl,
173 rootUrl,
174 matches;
175
176 // FUTURE: some of these things should just be moved into core Document; others should
177 // be in a LiveDevelopment-specific object attached to the doc.
178 matches = /^(.*\/)(.+\.([^.]+))$/.exec(doc.file.fullPath);
179 if (!matches) {
180 return;
181 }
182
183 doc.extension = matches[3];
184
185 parentUrl = _pathToUrl(matches[1]);
186 doc.url = parentUrl + encodeURI(matches[2]);
187
188 // the root represents the document that should be displayed in the browser
189 // for live development (the file for HTML files, index.html for others)
190 // TODO: Issue #2033 Improve how default page is determined
191 rootUrl = (_isHtmlFileExt(matches[3]) ? doc.url : parentUrl + "index.html");
192 doc.root = { url: rootUrl };
193 }
194
195 /** Get the current document from the document manager
196 * _adds extension, url and root to the document
197 */
198 function _getCurrentDocument() {
199 var doc = DocumentManager.getCurrentDocument();
200 if (doc) {
201 _setDocInfo(doc);
202 }
203 return doc;
204 }
205
206 /** Determine which document class should be used for a given document
207 * @param {Document} document
208 */
209 function _classForDocument(doc) {
210 switch (doc.extension) {
211 case "css":
212 return CSSDocument;
213 case "js":
214 return exports.config.experimental ? JSDocument : null;
215 }
216
217 if (exports.config.experimental && _isHtmlFileExt(doc.extension)) {
218 return HTMLDocument;
219 }
220
221 return null;
222 }
223
224 /**
225 * Removes the given CSS/JSDocument from _relatedDocuments. Signals that the
226 * given file is no longer associated with the HTML document that is live (e.g.
227 * if the related file has been deleted on disk).
228 */
229 function _handleRelatedDocumentDeleted(event, liveDoc) {
230 var index = _relatedDocuments.indexOf(liveDoc);
231 if (index !== -1) {
232 $(liveDoc).on("deleted", _handleRelatedDocumentDeleted);
233 _relatedDocuments.splice(index, 1);
234 }
235 }
236
237 /** Close a live document */
238 function _closeDocument() {
239 if (_liveDocument) {
240 _liveDocument.close();
241 _liveDocument = undefined;
242 }
243 if (_relatedDocuments) {
244 _relatedDocuments.forEach(function (liveDoc) {
245 liveDoc.close();
246 $(liveDoc).off("deleted", _handleRelatedDocumentDeleted);
247 });
248 _relatedDocuments = undefined;
249 }
250 }
251
252 /** Create a live version of a Brackets document */
253 function _createDocument(doc, editor) {
254 var DocClass = _classForDocument(doc);
255 if (DocClass) {
256 return new DocClass(doc, editor);
257 } else {
258 return null;
259 }
260 }
261
262 /** Open a live document
263 * @param {Document} source document to open
264 */
265 function _openDocument(doc, editor) {
266 _closeDocument();
267 _liveDocument = _createDocument(doc, editor);
268
269 // Gather related CSS documents.
270 // FUTURE: Gather related JS documents as well.
271 _relatedDocuments = [];
272 agents.css.getStylesheetURLs().forEach(function (url) {
273 // FUTURE: when we get truly async file handling, we might need to prevent other
274 // stuff from happening while we wait to add these listeners
275 DocumentManager.getDocumentForPath(_urlToPath(url))
276 .done(function (doc) {
277 _setDocInfo(doc);
278 var liveDoc = _createDocument(doc);
279 if (liveDoc) {
280 _relatedDocuments.push(liveDoc);
281 $(liveDoc).on("deleted", _handleRelatedDocumentDeleted);
282 }
283 });
284 });
285 }
286
287 /** Unload the agents */
288 function unloadAgents() {
289 _loadedAgentNames.forEach(function (name) {
290 agents[name].unload();
291 });
292 _loadedAgentNames = [];
293 }
294
295 /** Load the agents */
296 function loadAgents() {
297 var name, promises = [];
298 var agentsToLoad;
299 if (exports.config.experimental) {
300 // load all agents
301 agentsToLoad = agents;
302 } else {
303 // load only enabled agents
304 agentsToLoad = _enabledAgentNames;
305 }
306 for (name in agentsToLoad) {
307 if (agentsToLoad.hasOwnProperty(name) && agents[name] && agents[name].load) {
308 promises.push(agents[name].load());
309 _loadedAgentNames.push(name);
310 }
311 }
312 return promises;
313 }
314
315 /** Enable an agent. Takes effect next time a connection is made. Does not affect
316 * current live development sessions.
317 *
318 * @param {string} name of agent to enable
319 */
320 function enableAgent(name) {
321 if (agents.hasOwnProperty(name) && !_enabledAgentNames.hasOwnProperty(name)) {
322 _enabledAgentNames[name] = true;
323 }
324 }
325
326 /** Disable an agent. Takes effect next time a connection is made. Does not affect
327 * current live development sessions.
328 *
329 * @param {string} name of agent to disable
330 */
331 function disableAgent(name) {
332 if (_enabledAgentNames.hasOwnProperty(name)) {
333 delete _enabledAgentNames[name];
334 }
335 }
336
337 /** Update the status
338 * @param {integer} new status
339 */
340 function _setStatus(status) {
341 exports.status = status;
342 $(exports).triggerHandler("statusChange", status);
343 }
344
345 /** Triggered by Inspector.error */
346 function _onError(event, error) {
347 var message;
348
349 // Sometimes error.message is undefined
350 if (!error.message) {
351 console.warn("Expected a non-empty string in error.message, got this instead:", error.message);
352 message = JSON.stringify(error);
353 } else {
354 message = error.message;
355 }
356
357 // Remove "Uncaught" from the beginning to avoid the inspector popping up
358 if (message && message.substr(0, 8) === "Uncaught") {
359 message = message.substr(9);
360 }
361
362 // Additional information, like exactly which parameter could not be processed.
363 var data = error.data;
364 if (Array.isArray(data)) {
365 message += "\n" + data.join("\n");
366 }
367
368 // Show the message, but include the error object for further information (e.g. error code)
369 console.error(message, error);
370 _setStatus(STATUS_ERROR);
371 }
372
373 /** Run when all agents are loaded */
374 function _onLoad() {
375 var doc = _getCurrentDocument();
376 if (doc) {
377 var editor = EditorManager.getCurrentFullEditor(),
378 status = STATUS_ACTIVE;
379
380 _openDocument(doc, editor);
381 if (doc.isDirty && _classForDocument(doc) !== CSSDocument) {
382 status = STATUS_OUT_OF_SYNC;
383 }
384 _setStatus(status);
385 }
386 }
387
388 /** Triggered by Inspector.detached */
389 function _onDetached(event, res) {
390 // res.reason, e.g. "replaced_with_devtools", "target_closed", "canceled_by_user"
391 // Sample list taken from https://chromiumcodereview.appspot.com/10947037/patch/12001/13004
392 // However, the link refers to the Chrome Extension API, it may not apply 100% to the Inspector API
393 }
394
395 /** Triggered by Inspector.connect */
396 function _onConnect(event) {
397 $(Inspector.Inspector).on("detached", _onDetached);
398 var promises = loadAgents();
399 _setStatus(STATUS_LOADING_AGENTS);
400 $.when.apply(undefined, promises).then(_onLoad, _onError);
401 }
402
403 /** Triggered by Inspector.disconnect */
404 function _onDisconnect(event) {
405 $(Inspector.Inspector).off("detached", _onDetached);
406 unloadAgents();
407 _closeDocument();
408 _setStatus(STATUS_INACTIVE);
409 }
410
411 function _onReconnect() {
412 unloadAgents();
413 var promises = loadAgents();
414 _setStatus(STATUS_LOADING_AGENTS);
415 $.when.apply(undefined, promises).then(_onLoad, _onError);
416 }
417
418 /** Open the Connection and go live */
419 function open() {
420 var result = new $.Deferred(),
421 promise = result.promise();
422 var doc = _getCurrentDocument();
423 var browserStarted = false;
424 var retryCount = 0;
425
426 function showWrongDocError() {
427 Dialogs.showModalDialog(
428 Dialogs.DIALOG_ID_ERROR,
429 Strings.LIVE_DEVELOPMENT_ERROR_TITLE,
430 Strings.LIVE_DEV_NEED_HTML_MESSAGE
431 );
432 result.reject();
433 }
434
435 function showNeedBaseUrlError() {
436 PreferencesDialogs.showProjectPreferencesDialog("", Strings.LIVE_DEV_NEED_BASEURL_MESSAGE)
437 .done(function (id) {
438 if (id === Dialogs.DIALOG_BTN_OK && ProjectManager.getBaseUrl()) {
439 // If base url is specifed, then re-invoke open() to continue
440 open();
441 result.resolve();
442 } else {
443 result.reject();
444 }
445 })
446 .fail(function () {
447 result.reject();
448 });
449 }
450
451 if (!doc || !doc.root) {
452 showWrongDocError();
453
454 } else {
455 if (!exports.config.experimental) {
456 if (FileUtils.isServerHtmlFileExt(doc.extension)) {
457 if (!ProjectManager.getBaseUrl()) {
458 showNeedBaseUrlError();
459 return promise;
460 }
461 } else if (!FileUtils.isStaticHtmlFileExt(doc.extension)) {
462 showWrongDocError();
463 return promise;
464 }
465 }
466
467 _setStatus(STATUS_CONNECTING);
468 Inspector.connectToURL(doc.root.url).then(result.resolve, function onConnectFail(err) {
469 if (err === "CANCEL") {
470 result.reject(err);
471 return;
472 }
473 if (retryCount > 6) {
474 _setStatus(STATUS_ERROR);
475 Dialogs.showModalDialog(
476 Dialogs.DIALOG_ID_LIVE_DEVELOPMENT,
477 Strings.LIVE_DEVELOPMENT_RELAUNCH_TITLE,
478 Strings.LIVE_DEVELOPMENT_ERROR_MESSAGE
479 ).done(function (id) {
480 if (id === Dialogs.DIALOG_BTN_OK) {
481 // User has chosen to reload Chrome, quit the running instance
482 _setStatus(STATUS_INACTIVE);
483 NativeApp.closeLiveBrowser()
484 .done(function () {
485 browserStarted = false;
486 window.setTimeout(function () {
487 open().then(result.resolve, result.reject);
488 });
489 })
490 .fail(function (err) {
491 // Report error?
492 _setStatus(STATUS_ERROR);
493 browserStarted = false;
494 result.reject("CLOSE_LIVE_BROWSER");
495 });
496 } else {
497 result.reject("CANCEL");
498 }
499 });
500 return;
501 }
502 retryCount++;
503
504 if (!browserStarted && exports.status !== STATUS_ERROR) {
505 // If err === FileError.ERR_NOT_FOUND, it means a remote debugger connection
506 // is available, but the requested URL is not loaded in the browser. In that
507 // case we want to launch the live browser (to open the url in a new tab)
508 // without using the --remote-debugging-port flag. This works around issues
509 // on Windows where Chrome can't be opened more than once with the
510 // --remote-debugging-port flag set.
511 NativeApp.openLiveBrowser(
512 doc.root.url,
513 err !== FileError.ERR_NOT_FOUND
514 )
515 .done(function () {
516 browserStarted = true;
517 })
518 .fail(function (err) {
519 var message;
520
521 _setStatus(STATUS_ERROR);
522 if (err === FileError.NOT_FOUND_ERR) {
523 message = Strings.ERROR_CANT_FIND_CHROME;
524 } else {
525 message = StringUtils.format(Strings.ERROR_LAUNCHING_BROWSER, err);
526 }
527
528 // Append a message to direct users to the troubleshooting page.
529 if (message) {
530 message += " " + StringUtils.format(Strings.LIVE_DEVELOPMENT_TROUBLESHOOTING, brackets.config.troubleshoot_url);
531 }
532
533 Dialogs.showModalDialog(
534 Dialogs.DIALOG_ID_ERROR,
535 Strings.ERROR_LAUNCHING_BROWSER_TITLE,
536 message
537 );
538
539 result.reject("OPEN_LIVE_BROWSER");
540 });
541 }
542
543 if (exports.status !== STATUS_ERROR) {
544 window.setTimeout(function retryConnect() {
545 Inspector.connectToURL(doc.root.url).then(result.resolve, onConnectFail);
546 }, 500);
547 }
548 });
549 }
550
551 return promise;
552 }
553
554 /** Close the Connection */
555 function close() {
556 if (Inspector.connected()) {
557 Inspector.Runtime.evaluate("window.close()");
558 }
559 Inspector.disconnect();
560 _setStatus(STATUS_INACTIVE);
561 }
562
563 /** Triggered by a document change from the DocumentManager */
564 function _onDocumentChange() {
565 var doc = _getCurrentDocument(),
566 status = STATUS_ACTIVE;
567 if (!doc) {
568 return;
569 }
570
571 if (Inspector.connected()) {
572 if (agents.network && agents.network.wasURLRequested(doc.url)) {
573 _closeDocument();
574 var editor = EditorManager.getCurrentFullEditor();
575 _openDocument(doc, editor);
576 } else {
577 if (exports.config.experimental || _isHtmlFileExt(doc.extension)) {
578 close();
579 window.setTimeout(open);
580 }
581 }
582
583 if (doc.isDirty && _classForDocument(doc) !== CSSDocument) {
584 status = STATUS_OUT_OF_SYNC;
585 }
586 _setStatus(status);
587 }
588 }
589
590 /** Triggered by a document saved from the DocumentManager */
591 function _onDocumentSaved(event, doc) {
592 if (doc && Inspector.connected() && _classForDocument(doc) !== CSSDocument &&
593 agents.network && agents.network.wasURLRequested(doc.url)) {
594 // Reload HTML page
595 Inspector.Page.reload();
596
597 // Reload unsaved changes
598 _onReconnect();
599 }
600 }
601
602 /** Triggered by a change in dirty flag from the DocumentManager */
603 function _onDirtyFlagChange(event, doc) {
604 if (doc && Inspector.connected() && _classForDocument(doc) !== CSSDocument &&
605 agents.network && agents.network.wasURLRequested(doc.url)) {
606 // Set status to out of sync if dirty. Otherwise, set it to active status.
607 _setStatus(doc.isDirty ? STATUS_OUT_OF_SYNC : STATUS_ACTIVE);
608 }
609 }
610
611 function getLiveDocForPath(path) {
612 var docsToSearch = [];
613 if (_relatedDocuments) {
614 docsToSearch = docsToSearch.concat(_relatedDocuments);
615 }
616 if (_liveDocument) {
617 docsToSearch = docsToSearch.concat(_liveDocument);
618 }
619 var foundDoc;
620 docsToSearch.some(function matchesPath(ele) {
621 if (ele.doc.file.fullPath === path) {
622 foundDoc = ele;
623 return true;
624 }
625 return false;
626 });
627
628 return foundDoc;
629 }
630
631 /** Hide any active highlighting */
632 function hideHighlight() {
633 if (Inspector.connected() && agents.highlight) {
634 agents.highlight.hide();
635 }
636 }
637
638 /** Initialize the LiveDevelopment Session */
639 function init(theConfig) {
640 exports.config = theConfig;
641 $(Inspector).on("connect", _onConnect)
642 .on("disconnect", _onDisconnect)
643 .on("error", _onError);
644 $(DocumentManager).on("currentDocumentChange", _onDocumentChange)
645 .on("documentSaved", _onDocumentSaved)
646 .on("dirtyFlagChange", _onDirtyFlagChange);
647 }
648
649 // For unit testing
650 exports._pathToUrl = _pathToUrl;
651 exports._urlToPath = _urlToPath;
652
653 // Export public functions
654 exports.agents = agents;
655 exports.open = open;
656 exports.close = close;
657 exports.enableAgent = enableAgent;
658 exports.disableAgent = disableAgent;
659 exports.getLiveDocForPath = getLiveDocForPath;
660 exports.hideHighlight = hideHighlight;
661 exports.init = init;
662});
\No newline at end of file