1 /* 2 * Copyright (C) 2012 Google Inc. All rights reserved. 3 * 4 * Redistribution and use in source and binary forms, with or without 5 * modification, are permitted provided that the following conditions are 6 * met: 7 * 8 * * Redistributions of source code must retain the above copyright 9 * notice, this list of conditions and the following disclaimer. 10 * * Redistributions in binary form must reproduce the above 11 * copyright notice, this list of conditions and the following disclaimer 12 * in the documentation and/or other materials provided with the 13 * distribution. 14 * * Neither the name of Google Inc. nor the names of its 15 * contributors may be used to endorse or promote products derived from 16 * this software without specific prior written permission. 17 * 18 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 */ 30 31 /** 32 * @constructor 33 * @implements {WebInspector.SourceMapping} 34 * @param {!WebInspector.CSSStyleModel} cssModel 35 * @param {!WebInspector.Workspace} workspace 36 * @param {!WebInspector.SimpleWorkspaceProvider} networkWorkspaceProvider 37 */ 38 WebInspector.SASSSourceMapping = function(cssModel, workspace, networkWorkspaceProvider) 39 { 40 this.pollPeriodMs = 5000; 41 this.pollIntervalMs = 200; 42 43 this._cssModel = cssModel; 44 this._workspace = workspace; 45 this._networkWorkspaceProvider = networkWorkspaceProvider; 46 this._addingRevisionCounter = 0; 47 this._reset(); 48 WebInspector.fileManager.addEventListener(WebInspector.FileManager.EventTypes.SavedURL, this._fileSaveFinished, this); 49 WebInspector.settings.cssSourceMapsEnabled.addChangeListener(this._toggleSourceMapSupport, this) 50 this._cssModel.addEventListener(WebInspector.CSSStyleModel.Events.StyleSheetChanged, this._styleSheetChanged, this); 51 this._workspace.addEventListener(WebInspector.Workspace.Events.UISourceCodeAdded, this._uiSourceCodeAdded, this); 52 this._workspace.addEventListener(WebInspector.Workspace.Events.UISourceCodeContentCommitted, this._uiSourceCodeContentCommitted, this); 53 this._workspace.addEventListener(WebInspector.Workspace.Events.ProjectWillReset, this._reset, this); 54 } 55 56 WebInspector.SASSSourceMapping.prototype = { 57 /** 58 * @param {!WebInspector.Event} event 59 */ 60 _styleSheetChanged: function(event) 61 { 62 var id = /** @type {!CSSAgent.StyleSheetId} */ (event.data.styleSheetId); 63 if (this._addingRevisionCounter) { 64 --this._addingRevisionCounter; 65 return; 66 } 67 var header = this._cssModel.styleSheetHeaderForId(id); 68 if (!header) 69 return; 70 71 this.removeHeader(header); 72 }, 73 74 /** 75 * @param {!WebInspector.Event} event 76 */ 77 _toggleSourceMapSupport: function(event) 78 { 79 var enabled = /** @type {boolean} */ (event.data); 80 var headers = this._cssModel.styleSheetHeaders(); 81 for (var i = 0; i < headers.length; ++i) { 82 if (enabled) 83 this.addHeader(headers[i]); 84 else 85 this.removeHeader(headers[i]); 86 } 87 }, 88 89 /** 90 * @param {!WebInspector.Event} event 91 */ 92 _fileSaveFinished: function(event) 93 { 94 var sassURL = /** @type {string} */ (event.data); 95 this._sassFileSaved(sassURL, false); 96 }, 97 98 /** 99 * @param {string} headerName 100 * @param {!NetworkAgent.Headers} headers 101 * @return {?string} 102 */ 103 _headerValue: function(headerName, headers) 104 { 105 headerName = headerName.toLowerCase(); 106 var value = null; 107 for (var name in headers) { 108 if (name.toLowerCase() === headerName) { 109 value = headers[name]; 110 break; 111 } 112 } 113 return value; 114 }, 115 116 /** 117 * @param {!NetworkAgent.Headers} headers 118 * @return {?Date} 119 */ 120 _lastModified: function(headers) 121 { 122 var lastModifiedHeader = this._headerValue("last-modified", headers); 123 if (!lastModifiedHeader) 124 return null; 125 var lastModified = new Date(lastModifiedHeader); 126 if (isNaN(lastModified.getTime())) 127 return null; 128 return lastModified; 129 }, 130 131 /** 132 * @param {!NetworkAgent.Headers} headers 133 * @param {string} url 134 * @return {?Date} 135 */ 136 _checkLastModified: function(headers, url) 137 { 138 var lastModified = this._lastModified(headers); 139 if (lastModified) 140 return lastModified; 141 142 var etagMessage = this._headerValue("etag", headers) ? ", \"ETag\" response header found instead" : ""; 143 var message = String.sprintf("The \"Last-Modified\" response header is missing or invalid for %s%s. The CSS auto-reload functionality will not work correctly.", url, etagMessage); 144 WebInspector.log(message); 145 return null; 146 }, 147 148 /** 149 * @param {string} sassURL 150 * @param {boolean} wasLoadedFromFileSystem 151 */ 152 _sassFileSaved: function(sassURL, wasLoadedFromFileSystem) 153 { 154 var cssURLs = this._cssURLsForSASSURL[sassURL]; 155 if (!cssURLs) 156 return; 157 if (!WebInspector.settings.cssReloadEnabled.get()) 158 return; 159 160 var sassFile = this._workspace.uiSourceCodeForURL(sassURL); 161 console.assert(sassFile); 162 if (wasLoadedFromFileSystem) 163 sassFile.requestMetadata(metadataReceived.bind(this)); 164 else 165 NetworkAgent.loadResourceForFrontend(WebInspector.resourceTreeModel.mainFrame.id, sassURL, undefined, sassLoadedViaNetwork.bind(this)); 166 167 /** 168 * @param {?Protocol.Error} error 169 * @param {number} statusCode 170 * @param {!NetworkAgent.Headers} headers 171 * @param {string} content 172 * @this {WebInspector.SASSSourceMapping} 173 */ 174 function sassLoadedViaNetwork(error, statusCode, headers, content) 175 { 176 if (error || statusCode >= 400) { 177 console.error("Could not load content for " + sassURL + " : " + (error || ("HTTP status code: " + statusCode))); 178 return; 179 } 180 var lastModified = this._checkLastModified(headers, sassURL); 181 if (!lastModified) 182 return; 183 metadataReceived.call(this, lastModified); 184 } 185 186 /** 187 * @param {?Date} timestamp 188 * @this {WebInspector.SASSSourceMapping} 189 */ 190 function metadataReceived(timestamp) 191 { 192 if (!timestamp) 193 return; 194 195 var now = Date.now(); 196 var deadlineMs = now + this.pollPeriodMs; 197 var pollData = this._pollDataForSASSURL[sassURL]; 198 if (pollData) { 199 var dataByURL = pollData.dataByURL; 200 for (var url in dataByURL) 201 clearTimeout(dataByURL[url].timer); 202 } 203 pollData = { dataByURL: {}, deadlineMs: deadlineMs, sassTimestamp: timestamp }; 204 this._pollDataForSASSURL[sassURL] = pollData; 205 for (var i = 0; i < cssURLs.length; ++i) { 206 pollData.dataByURL[cssURLs[i]] = { previousPoll: now }; 207 this._pollCallback(cssURLs[i], sassURL, false); 208 } 209 } 210 }, 211 212 /** 213 * @param {string} cssURL 214 * @param {string} sassURL 215 * @param {boolean} stopPolling 216 */ 217 _pollCallback: function(cssURL, sassURL, stopPolling) 218 { 219 var now; 220 var pollData = this._pollDataForSASSURL[sassURL]; 221 if (!pollData) 222 return; 223 224 if (stopPolling || (now = new Date().getTime()) > pollData.deadlineMs) { 225 delete pollData.dataByURL[cssURL]; 226 if (!Object.keys(pollData.dataByURL).length) 227 delete this._pollDataForSASSURL[sassURL]; 228 return; 229 } 230 var nextPoll = this.pollIntervalMs + pollData.dataByURL[cssURL].previousPoll; 231 var remainingTimeoutMs = Math.max(0, nextPoll - now); 232 pollData.dataByURL[cssURL].previousPoll = now + remainingTimeoutMs; 233 pollData.dataByURL[cssURL].timer = setTimeout(this._reloadCSS.bind(this, cssURL, sassURL, this._pollCallback.bind(this)), remainingTimeoutMs); 234 }, 235 236 /** 237 * @param {string} cssURL 238 * @param {string} sassURL 239 * @param {function(string, string, boolean)} callback 240 */ 241 _reloadCSS: function(cssURL, sassURL, callback) 242 { 243 var cssUISourceCode = this._workspace.uiSourceCodeForURL(cssURL); 244 if (!cssUISourceCode) { 245 WebInspector.log(cssURL + " resource missing. Please reload the page."); 246 callback(cssURL, sassURL, true); 247 return; 248 } 249 250 if (this._workspace.hasMappingForURL(sassURL)) 251 this._reloadCSSFromFileSystem(cssUISourceCode, sassURL, callback); 252 else 253 this._reloadCSSFromNetwork(cssUISourceCode, sassURL, callback); 254 }, 255 256 /** 257 * @param {!WebInspector.UISourceCode} cssUISourceCode 258 * @param {string} sassURL 259 * @param {function(string, string, boolean)} callback 260 */ 261 _reloadCSSFromNetwork: function(cssUISourceCode, sassURL, callback) 262 { 263 var cssURL = cssUISourceCode.url; 264 var data = this._pollDataForSASSURL[sassURL]; 265 if (!data) { 266 callback(cssURL, sassURL, true); 267 return; 268 } 269 var headers = { "if-modified-since": new Date(data.sassTimestamp.getTime() - 1000).toUTCString() }; 270 NetworkAgent.loadResourceForFrontend(WebInspector.resourceTreeModel.mainFrame.id, cssURL, headers, contentLoaded.bind(this)); 271 272 /** 273 * @param {?Protocol.Error} error 274 * @param {number} statusCode 275 * @param {!NetworkAgent.Headers} headers 276 * @param {string} content 277 * @this {WebInspector.SASSSourceMapping} 278 */ 279 function contentLoaded(error, statusCode, headers, content) 280 { 281 if (error || statusCode >= 400) { 282 console.error("Could not load content for " + cssURL + " : " + (error || ("HTTP status code: " + statusCode))); 283 callback(cssURL, sassURL, true); 284 return; 285 } 286 if (!this._pollDataForSASSURL[sassURL]) { 287 callback(cssURL, sassURL, true); 288 return; 289 } 290 if (statusCode === 304) { 291 callback(cssURL, sassURL, false); 292 return; 293 } 294 var lastModified = this._checkLastModified(headers, cssURL); 295 if (!lastModified) { 296 callback(cssURL, sassURL, true); 297 return; 298 } 299 if (lastModified.getTime() < data.sassTimestamp.getTime()) { 300 callback(cssURL, sassURL, false); 301 return; 302 } 303 this._updateCSSRevision(cssUISourceCode, content, sassURL, callback); 304 } 305 }, 306 307 /** 308 * @param {!WebInspector.UISourceCode} cssUISourceCode 309 * @param {string} content 310 * @param {string} sassURL 311 * @param {function(string, string, boolean)} callback 312 */ 313 _updateCSSRevision: function(cssUISourceCode, content, sassURL, callback) 314 { 315 ++this._addingRevisionCounter; 316 cssUISourceCode.addRevision(content); 317 this._cssUISourceCodeUpdated(cssUISourceCode.url, sassURL, callback); 318 }, 319 320 /** 321 * @param {!WebInspector.UISourceCode} cssUISourceCode 322 * @param {string} sassURL 323 * @param {function(string, string, boolean)} callback 324 */ 325 _reloadCSSFromFileSystem: function(cssUISourceCode, sassURL, callback) 326 { 327 cssUISourceCode.requestMetadata(metadataCallback.bind(this)); 328 329 /** 330 * @param {?Date} timestamp 331 * @this {WebInspector.SASSSourceMapping} 332 */ 333 function metadataCallback(timestamp) 334 { 335 var cssURL = cssUISourceCode.url; 336 if (!timestamp) { 337 callback(cssURL, sassURL, false); 338 return; 339 } 340 var cssTimestamp = timestamp.getTime(); 341 var pollData = this._pollDataForSASSURL[sassURL]; 342 if (!pollData) { 343 callback(cssURL, sassURL, true); 344 return; 345 } 346 347 if (cssTimestamp < pollData.sassTimestamp.getTime()) { 348 callback(cssURL, sassURL, false); 349 return; 350 } 351 352 cssUISourceCode.requestOriginalContent(contentCallback.bind(this)); 353 354 /** 355 * @param {?string} content 356 * @this {WebInspector.SASSSourceMapping} 357 */ 358 function contentCallback(content) 359 { 360 // Empty string is a valid value, null means error. 361 if (content === null) 362 return; 363 this._updateCSSRevision(cssUISourceCode, content, sassURL, callback); 364 } 365 } 366 }, 367 368 /** 369 * @param {string} cssURL 370 * @param {string} sassURL 371 * @param {function(string, string, boolean)} callback 372 */ 373 _cssUISourceCodeUpdated: function(cssURL, sassURL, callback) 374 { 375 var completeSourceMapURL = this._completeSourceMapURLForCSSURL[cssURL]; 376 if (!completeSourceMapURL) 377 return; 378 var ids = this._cssModel.styleSheetIdsForURL(cssURL); 379 if (!ids) 380 return; 381 var headers = []; 382 for (var i = 0; i < ids.length; ++i) 383 headers.push(this._cssModel.styleSheetHeaderForId(ids[i])); 384 for (var i = 0; i < ids.length; ++i) 385 this._loadSourceMapAndBindUISourceCode(headers, true, completeSourceMapURL); 386 callback(cssURL, sassURL, true); 387 }, 388 389 /** 390 * @param {!WebInspector.CSSStyleSheetHeader} header 391 */ 392 addHeader: function(header) 393 { 394 if (!header.sourceMapURL || !header.sourceURL || header.isInline || !WebInspector.settings.cssSourceMapsEnabled.get()) 395 return; 396 var completeSourceMapURL = WebInspector.ParsedURL.completeURL(header.sourceURL, header.sourceMapURL); 397 if (!completeSourceMapURL) 398 return; 399 this._completeSourceMapURLForCSSURL[header.sourceURL] = completeSourceMapURL; 400 this._loadSourceMapAndBindUISourceCode([header], false, completeSourceMapURL); 401 }, 402 403 /** 404 * @param {!WebInspector.CSSStyleSheetHeader} header 405 */ 406 removeHeader: function(header) 407 { 408 var sourceURL = header.sourceURL; 409 if (!sourceURL || !header.sourceMapURL || header.isInline || !this._completeSourceMapURLForCSSURL[sourceURL]) 410 return; 411 delete this._sourceMapByStyleSheetURL[sourceURL]; 412 delete this._completeSourceMapURLForCSSURL[sourceURL]; 413 for (var sassURL in this._cssURLsForSASSURL) { 414 var urls = this._cssURLsForSASSURL[sassURL]; 415 urls.remove(sourceURL); 416 if (!urls.length) 417 delete this._cssURLsForSASSURL[sassURL]; 418 } 419 var completeSourceMapURL = WebInspector.ParsedURL.completeURL(sourceURL, header.sourceMapURL); 420 if (completeSourceMapURL) 421 delete this._sourceMapByURL[completeSourceMapURL]; 422 header.updateLocations(); 423 }, 424 425 /** 426 * @param {!Array.<!WebInspector.CSSStyleSheetHeader>} headersWithSameSourceURL 427 * @param {boolean} forceRebind 428 * @param {string} completeSourceMapURL 429 */ 430 _loadSourceMapAndBindUISourceCode: function(headersWithSameSourceURL, forceRebind, completeSourceMapURL) 431 { 432 console.assert(headersWithSameSourceURL.length); 433 var sourceURL = headersWithSameSourceURL[0].sourceURL; 434 this._loadSourceMapForStyleSheet(completeSourceMapURL, sourceURL, forceRebind, sourceMapLoaded.bind(this)); 435 436 /** 437 * @param {?WebInspector.SourceMap} sourceMap 438 * @this {WebInspector.SASSSourceMapping} 439 */ 440 function sourceMapLoaded(sourceMap) 441 { 442 if (!sourceMap) 443 return; 444 445 this._sourceMapByStyleSheetURL[sourceURL] = sourceMap; 446 for (var i = 0; i < headersWithSameSourceURL.length; ++i) { 447 if (forceRebind) 448 headersWithSameSourceURL[i].updateLocations(); 449 else 450 this._bindUISourceCode(headersWithSameSourceURL[i], sourceMap); 451 } 452 } 453 }, 454 455 /** 456 * @param {string} cssURL 457 * @param {string} sassURL 458 */ 459 _addCSSURLforSASSURL: function(cssURL, sassURL) 460 { 461 var cssURLs; 462 if (this._cssURLsForSASSURL.hasOwnProperty(sassURL)) 463 cssURLs = this._cssURLsForSASSURL[sassURL]; 464 else { 465 cssURLs = []; 466 this._cssURLsForSASSURL[sassURL] = cssURLs; 467 } 468 if (cssURLs.indexOf(cssURL) === -1) 469 cssURLs.push(cssURL); 470 }, 471 472 /** 473 * @param {string} completeSourceMapURL 474 * @param {string} completeStyleSheetURL 475 * @param {boolean} forceReload 476 * @param {function(?WebInspector.SourceMap)} callback 477 */ 478 _loadSourceMapForStyleSheet: function(completeSourceMapURL, completeStyleSheetURL, forceReload, callback) 479 { 480 var sourceMap = this._sourceMapByURL[completeSourceMapURL]; 481 if (sourceMap && !forceReload) { 482 callback(sourceMap); 483 return; 484 } 485 486 var pendingCallbacks = this._pendingSourceMapLoadingCallbacks[completeSourceMapURL]; 487 if (pendingCallbacks) { 488 pendingCallbacks.push(callback); 489 return; 490 } 491 492 pendingCallbacks = [callback]; 493 this._pendingSourceMapLoadingCallbacks[completeSourceMapURL] = pendingCallbacks; 494 495 WebInspector.SourceMap.load(completeSourceMapURL, completeStyleSheetURL, sourceMapLoaded.bind(this)); 496 497 /** 498 * @param {?WebInspector.SourceMap} sourceMap 499 * @this {WebInspector.SASSSourceMapping} 500 */ 501 function sourceMapLoaded(sourceMap) 502 { 503 var callbacks = this._pendingSourceMapLoadingCallbacks[completeSourceMapURL]; 504 delete this._pendingSourceMapLoadingCallbacks[completeSourceMapURL]; 505 if (!callbacks) 506 return; 507 if (sourceMap) 508 this._sourceMapByURL[completeSourceMapURL] = sourceMap; 509 else 510 delete this._sourceMapByURL[completeSourceMapURL]; 511 for (var i = 0; i < callbacks.length; ++i) 512 callbacks[i](sourceMap); 513 } 514 }, 515 516 /** 517 * @param {!WebInspector.CSSStyleSheetHeader} header 518 * @param {!WebInspector.SourceMap} sourceMap 519 */ 520 _bindUISourceCode: function(header, sourceMap) 521 { 522 header.pushSourceMapping(this); 523 var rawURL = header.sourceURL; 524 var sources = sourceMap.sources(); 525 for (var i = 0; i < sources.length; ++i) { 526 var url = sources[i]; 527 this._addCSSURLforSASSURL(rawURL, url); 528 if (!this._workspace.hasMappingForURL(url) && !this._workspace.uiSourceCodeForURL(url)) { 529 var contentProvider = sourceMap.sourceContentProvider(url, WebInspector.resourceTypes.Stylesheet); 530 this._networkWorkspaceProvider.addFileForURL(url, contentProvider, true); 531 } 532 } 533 }, 534 535 /** 536 * @param {!WebInspector.RawLocation} rawLocation 537 * @return {?WebInspector.UILocation} 538 */ 539 rawLocationToUILocation: function(rawLocation) 540 { 541 var location = /** @type WebInspector.CSSLocation */ (rawLocation); 542 var entry; 543 var sourceMap = this._sourceMapByStyleSheetURL[location.url]; 544 if (!sourceMap) 545 return null; 546 entry = sourceMap.findEntry(location.lineNumber, location.columnNumber); 547 if (!entry || entry.length === 2) 548 return null; 549 var uiSourceCode = this._workspace.uiSourceCodeForURL(entry[2]); 550 if (!uiSourceCode) 551 return null; 552 return new WebInspector.UILocation(uiSourceCode, entry[3], entry[4]); 553 }, 554 555 /** 556 * @param {!WebInspector.UISourceCode} uiSourceCode 557 * @param {number} lineNumber 558 * @param {number} columnNumber 559 * @return {!WebInspector.RawLocation} 560 */ 561 uiLocationToRawLocation: function(uiSourceCode, lineNumber, columnNumber) 562 { 563 // FIXME: Implement this when ui -> raw mapping has clients. 564 return new WebInspector.CSSLocation(uiSourceCode.url || "", lineNumber, columnNumber); 565 }, 566 567 /** 568 * @param {!WebInspector.Event} event 569 */ 570 _uiSourceCodeAdded: function(event) 571 { 572 var uiSourceCode = /** @type {!WebInspector.UISourceCode} */ (event.data); 573 var cssURLs = this._cssURLsForSASSURL[uiSourceCode.url]; 574 if (!cssURLs) 575 return; 576 for (var i = 0; i < cssURLs.length; ++i) { 577 var ids = this._cssModel.styleSheetIdsForURL(cssURLs[i]); 578 for (var j = 0; j < ids.length; ++j) { 579 var header = this._cssModel.styleSheetHeaderForId(ids[j]); 580 console.assert(header); 581 header.updateLocations(); 582 } 583 } 584 }, 585 586 /** 587 * @param {!WebInspector.Event} event 588 */ 589 _uiSourceCodeContentCommitted: function(event) 590 { 591 var uiSourceCode = /** @type {!WebInspector.UISourceCode} */ (event.data.uiSourceCode); 592 if (uiSourceCode.project().type() === WebInspector.projectTypes.FileSystem) 593 this._sassFileSaved(uiSourceCode.url, true); 594 }, 595 596 _reset: function() 597 { 598 this._addingRevisionCounter = 0; 599 this._completeSourceMapURLForCSSURL = {}; 600 this._cssURLsForSASSURL = {}; 601 /** @type {!Object.<string, !Array.<function(?WebInspector.SourceMap)>>} */ 602 this._pendingSourceMapLoadingCallbacks = {}; 603 /** @type {!Object.<string, {deadlineMs: number, dataByURL: !Object.<string, !{timer: number, previousPoll: number}>}>} */ 604 this._pollDataForSASSURL = {}; 605 /** @type {!Object.<string, !WebInspector.SourceMap>} */ 606 this._sourceMapByURL = {}; 607 this._sourceMapByStyleSheetURL = {}; 608 } 609 } 610