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 */ 173 function sassLoadedViaNetwork(error, statusCode, headers, content) 174 { 175 if (error || statusCode >= 400) { 176 console.error("Could not load content for " + sassURL + " : " + (error || ("HTTP status code: " + statusCode))); 177 return; 178 } 179 var lastModified = this._checkLastModified(headers, sassURL); 180 if (!lastModified) 181 return; 182 metadataReceived.call(this, lastModified); 183 } 184 185 /** 186 * @param {?Date} timestamp 187 */ 188 function metadataReceived(timestamp) 189 { 190 if (!timestamp) 191 return; 192 193 var now = Date.now(); 194 var deadlineMs = now + this.pollPeriodMs; 195 var pollData = this._pollDataForSASSURL[sassURL]; 196 if (pollData) { 197 var dataByURL = pollData.dataByURL; 198 for (var url in dataByURL) 199 clearTimeout(dataByURL[url].timer); 200 } 201 pollData = { dataByURL: {}, deadlineMs: deadlineMs, sassTimestamp: timestamp }; 202 this._pollDataForSASSURL[sassURL] = pollData; 203 for (var i = 0; i < cssURLs.length; ++i) { 204 pollData.dataByURL[cssURLs[i]] = { previousPoll: now }; 205 this._pollCallback(cssURLs[i], sassURL, false); 206 } 207 } 208 }, 209 210 /** 211 * @param {string} cssURL 212 * @param {string} sassURL 213 * @param {boolean} stopPolling 214 */ 215 _pollCallback: function(cssURL, sassURL, stopPolling) 216 { 217 var now; 218 var pollData = this._pollDataForSASSURL[sassURL]; 219 if (!pollData) 220 return; 221 222 if (stopPolling || (now = new Date().getTime()) > pollData.deadlineMs) { 223 delete pollData.dataByURL[cssURL]; 224 if (!Object.keys(pollData.dataByURL).length) 225 delete this._pollDataForSASSURL[sassURL]; 226 return; 227 } 228 var nextPoll = this.pollIntervalMs + pollData.dataByURL[cssURL].previousPoll; 229 var remainingTimeoutMs = Math.max(0, nextPoll - now); 230 pollData.dataByURL[cssURL].previousPoll = now + remainingTimeoutMs; 231 pollData.dataByURL[cssURL].timer = setTimeout(this._reloadCSS.bind(this, cssURL, sassURL, this._pollCallback.bind(this)), remainingTimeoutMs); 232 }, 233 234 /** 235 * @param {string} cssURL 236 * @param {string} sassURL 237 * @param {function(string, string, boolean)} callback 238 */ 239 _reloadCSS: function(cssURL, sassURL, callback) 240 { 241 var cssUISourceCode = this._workspace.uiSourceCodeForURL(cssURL); 242 if (!cssUISourceCode) { 243 WebInspector.log(cssURL + " resource missing. Please reload the page."); 244 callback(cssURL, sassURL, true); 245 return; 246 } 247 248 if (this._workspace.hasMappingForURL(sassURL)) 249 this._reloadCSSFromFileSystem(cssUISourceCode, sassURL, callback); 250 else 251 this._reloadCSSFromNetwork(cssUISourceCode, sassURL, callback); 252 }, 253 254 /** 255 * @param {WebInspector.UISourceCode} cssUISourceCode 256 * @param {string} sassURL 257 * @param {function(string, string, boolean)} callback 258 */ 259 _reloadCSSFromNetwork: function(cssUISourceCode, sassURL, callback) 260 { 261 var cssURL = cssUISourceCode.url; 262 var data = this._pollDataForSASSURL[sassURL]; 263 if (!data) { 264 callback(cssURL, sassURL, true); 265 return; 266 } 267 var headers = { "if-modified-since": new Date(data.sassTimestamp.getTime() - 1000).toUTCString() }; 268 NetworkAgent.loadResourceForFrontend(WebInspector.resourceTreeModel.mainFrame.id, cssURL, headers, contentLoaded.bind(this)); 269 270 /** 271 * @param {?Protocol.Error} error 272 * @param {number} statusCode 273 * @param {NetworkAgent.Headers} headers 274 * @param {string} content 275 */ 276 function contentLoaded(error, statusCode, headers, content) 277 { 278 if (error || statusCode >= 400) { 279 console.error("Could not load content for " + cssURL + " : " + (error || ("HTTP status code: " + statusCode))); 280 callback(cssURL, sassURL, true); 281 return; 282 } 283 if (!this._pollDataForSASSURL[sassURL]) { 284 callback(cssURL, sassURL, true); 285 return; 286 } 287 if (statusCode === 304) { 288 callback(cssURL, sassURL, false); 289 return; 290 } 291 var lastModified = this._checkLastModified(headers, cssURL); 292 if (!lastModified) { 293 callback(cssURL, sassURL, true); 294 return; 295 } 296 if (lastModified.getTime() < data.sassTimestamp.getTime()) { 297 callback(cssURL, sassURL, false); 298 return; 299 } 300 this._updateCSSRevision(cssUISourceCode, content, sassURL, callback); 301 } 302 }, 303 304 /** 305 * @param {WebInspector.UISourceCode} cssUISourceCode 306 * @param {string} content 307 * @param {string} sassURL 308 * @param {function(string, string, boolean)} callback 309 */ 310 _updateCSSRevision: function(cssUISourceCode, content, sassURL, callback) 311 { 312 ++this._addingRevisionCounter; 313 cssUISourceCode.addRevision(content); 314 this._cssUISourceCodeUpdated(cssUISourceCode.url, sassURL, callback); 315 }, 316 317 /** 318 * @param {WebInspector.UISourceCode} cssUISourceCode 319 * @param {string} sassURL 320 * @param {function(string, string, boolean)} callback 321 */ 322 _reloadCSSFromFileSystem: function(cssUISourceCode, sassURL, callback) 323 { 324 cssUISourceCode.requestMetadata(metadataCallback.bind(this)); 325 326 /** 327 * @param {?Date} timestamp 328 */ 329 function metadataCallback(timestamp) 330 { 331 var cssURL = cssUISourceCode.url; 332 if (!timestamp) { 333 callback(cssURL, sassURL, false); 334 return; 335 } 336 var cssTimestamp = timestamp.getTime(); 337 var pollData = this._pollDataForSASSURL[sassURL]; 338 if (!pollData) { 339 callback(cssURL, sassURL, true); 340 return; 341 } 342 343 if (cssTimestamp < pollData.sassTimestamp.getTime()) { 344 callback(cssURL, sassURL, false); 345 return; 346 } 347 348 cssUISourceCode.requestOriginalContent(contentCallback.bind(this)); 349 function contentCallback(content) 350 { 351 // Empty string is a valid value, null means error. 352 if (content === null) 353 return; 354 this._updateCSSRevision(cssUISourceCode, content, sassURL, callback); 355 } 356 } 357 }, 358 359 /** 360 * @param {string} cssURL 361 * @param {string} sassURL 362 * @param {function(string, string, boolean)} callback 363 */ 364 _cssUISourceCodeUpdated: function(cssURL, sassURL, callback) 365 { 366 var completeSourceMapURL = this._completeSourceMapURLForCSSURL[cssURL]; 367 if (!completeSourceMapURL) 368 return; 369 var ids = this._cssModel.styleSheetIdsForURL(cssURL); 370 if (!ids) 371 return; 372 var headers = []; 373 for (var i = 0; i < ids.length; ++i) 374 headers.push(this._cssModel.styleSheetHeaderForId(ids[i])); 375 for (var i = 0; i < ids.length; ++i) 376 this._loadSourceMapAndBindUISourceCode(headers, true, completeSourceMapURL); 377 callback(cssURL, sassURL, true); 378 }, 379 380 /** 381 * @param {WebInspector.CSSStyleSheetHeader} header 382 */ 383 addHeader: function(header) 384 { 385 if (!header.sourceMapURL || !header.sourceURL || header.isInline || !WebInspector.settings.cssSourceMapsEnabled.get()) 386 return; 387 var completeSourceMapURL = WebInspector.ParsedURL.completeURL(header.sourceURL, header.sourceMapURL); 388 if (!completeSourceMapURL) 389 return; 390 this._completeSourceMapURLForCSSURL[header.sourceURL] = completeSourceMapURL; 391 this._loadSourceMapAndBindUISourceCode([header], false, completeSourceMapURL); 392 }, 393 394 /** 395 * @param {WebInspector.CSSStyleSheetHeader} header 396 */ 397 removeHeader: function(header) 398 { 399 var sourceURL = header.sourceURL; 400 if (!sourceURL || !header.sourceMapURL || header.isInline || !this._completeSourceMapURLForCSSURL[sourceURL]) 401 return; 402 delete this._sourceMapByStyleSheetURL[sourceURL]; 403 delete this._completeSourceMapURLForCSSURL[sourceURL]; 404 for (var sassURL in this._cssURLsForSASSURL) { 405 var urls = this._cssURLsForSASSURL[sassURL]; 406 urls.remove(sourceURL); 407 if (!urls.length) 408 delete this._cssURLsForSASSURL[sassURL]; 409 } 410 var completeSourceMapURL = WebInspector.ParsedURL.completeURL(sourceURL, header.sourceMapURL); 411 if (completeSourceMapURL) 412 delete this._sourceMapByURL[completeSourceMapURL]; 413 header.updateLocations(); 414 }, 415 416 /** 417 * @param {Array.<WebInspector.CSSStyleSheetHeader>} headersWithSameSourceURL 418 * @param {boolean} forceRebind 419 * @param {string} completeSourceMapURL 420 */ 421 _loadSourceMapAndBindUISourceCode: function(headersWithSameSourceURL, forceRebind, completeSourceMapURL) 422 { 423 console.assert(headersWithSameSourceURL.length); 424 var sourceURL = headersWithSameSourceURL[0].sourceURL; 425 this._loadSourceMapForStyleSheet(completeSourceMapURL, sourceURL, forceRebind, sourceMapLoaded.bind(this)); 426 427 /** 428 * @param {?WebInspector.SourceMap} sourceMap 429 */ 430 function sourceMapLoaded(sourceMap) 431 { 432 if (!sourceMap) 433 return; 434 435 this._sourceMapByStyleSheetURL[sourceURL] = sourceMap; 436 for (var i = 0; i < headersWithSameSourceURL.length; ++i) { 437 if (forceRebind) 438 headersWithSameSourceURL[i].updateLocations(); 439 else 440 this._bindUISourceCode(headersWithSameSourceURL[i], sourceMap); 441 } 442 } 443 }, 444 445 /** 446 * @param {string} cssURL 447 * @param {string} sassURL 448 */ 449 _addCSSURLforSASSURL: function(cssURL, sassURL) 450 { 451 var cssURLs; 452 if (this._cssURLsForSASSURL.hasOwnProperty(sassURL)) 453 cssURLs = this._cssURLsForSASSURL[sassURL]; 454 else { 455 cssURLs = []; 456 this._cssURLsForSASSURL[sassURL] = cssURLs; 457 } 458 if (cssURLs.indexOf(cssURL) === -1) 459 cssURLs.push(cssURL); 460 }, 461 462 /** 463 * @param {string} completeSourceMapURL 464 * @param {string} completeStyleSheetURL 465 * @param {boolean} forceReload 466 * @param {function(?WebInspector.SourceMap)} callback 467 */ 468 _loadSourceMapForStyleSheet: function(completeSourceMapURL, completeStyleSheetURL, forceReload, callback) 469 { 470 var sourceMap = this._sourceMapByURL[completeSourceMapURL]; 471 if (sourceMap && !forceReload) { 472 callback(sourceMap); 473 return; 474 } 475 476 var pendingCallbacks = this._pendingSourceMapLoadingCallbacks[completeSourceMapURL]; 477 if (pendingCallbacks) { 478 pendingCallbacks.push(callback); 479 return; 480 } 481 482 pendingCallbacks = [callback]; 483 this._pendingSourceMapLoadingCallbacks[completeSourceMapURL] = pendingCallbacks; 484 485 WebInspector.SourceMap.load(completeSourceMapURL, completeStyleSheetURL, sourceMapLoaded.bind(this)); 486 487 /** 488 * @param {?WebInspector.SourceMap} sourceMap 489 */ 490 function sourceMapLoaded(sourceMap) 491 { 492 var callbacks = this._pendingSourceMapLoadingCallbacks[completeSourceMapURL]; 493 delete this._pendingSourceMapLoadingCallbacks[completeSourceMapURL]; 494 if (!callbacks) 495 return; 496 if (sourceMap) 497 this._sourceMapByURL[completeSourceMapURL] = sourceMap; 498 else 499 delete this._sourceMapByURL[completeSourceMapURL]; 500 for (var i = 0; i < callbacks.length; ++i) 501 callbacks[i](sourceMap); 502 } 503 }, 504 505 /** 506 * @param {WebInspector.CSSStyleSheetHeader} header 507 * @param {WebInspector.SourceMap} sourceMap 508 */ 509 _bindUISourceCode: function(header, sourceMap) 510 { 511 header.pushSourceMapping(this); 512 var rawURL = header.sourceURL; 513 var sources = sourceMap.sources(); 514 for (var i = 0; i < sources.length; ++i) { 515 var url = sources[i]; 516 this._addCSSURLforSASSURL(rawURL, url); 517 if (!this._workspace.hasMappingForURL(url) && !this._workspace.uiSourceCodeForURL(url)) { 518 var contentProvider = sourceMap.sourceContentProvider(url, WebInspector.resourceTypes.Stylesheet); 519 var uiSourceCode = this._networkWorkspaceProvider.addFileForURL(url, contentProvider, true); 520 uiSourceCode.setSourceMapping(this); 521 } 522 } 523 }, 524 525 /** 526 * @param {WebInspector.RawLocation} rawLocation 527 * @return {WebInspector.UILocation} 528 */ 529 rawLocationToUILocation: function(rawLocation) 530 { 531 var location = /** @type WebInspector.CSSLocation */ (rawLocation); 532 var entry; 533 var sourceMap = this._sourceMapByStyleSheetURL[location.url]; 534 if (!sourceMap) 535 return null; 536 entry = sourceMap.findEntry(location.lineNumber, location.columnNumber); 537 if (!entry || entry.length === 2) 538 return null; 539 var uiSourceCode = this._workspace.uiSourceCodeForURL(entry[2]); 540 if (!uiSourceCode) 541 return null; 542 return new WebInspector.UILocation(uiSourceCode, entry[3], entry[4]); 543 }, 544 545 /** 546 * @param {WebInspector.UISourceCode} uiSourceCode 547 * @param {number} lineNumber 548 * @param {number} columnNumber 549 * @return {WebInspector.RawLocation} 550 */ 551 uiLocationToRawLocation: function(uiSourceCode, lineNumber, columnNumber) 552 { 553 // FIXME: Implement this when ui -> raw mapping has clients. 554 return new WebInspector.CSSLocation(uiSourceCode.url || "", lineNumber, columnNumber); 555 }, 556 557 /** 558 * @return {boolean} 559 */ 560 isIdentity: function() 561 { 562 return false; 563 }, 564 565 /** 566 * @param {WebInspector.Event} event 567 */ 568 _uiSourceCodeAdded: function(event) 569 { 570 var uiSourceCode = /** @type {WebInspector.UISourceCode} */ (event.data); 571 var cssURLs = this._cssURLsForSASSURL[uiSourceCode.url]; 572 if (!cssURLs) 573 return; 574 uiSourceCode.setSourceMapping(this); 575 for (var i = 0; i < cssURLs.length; ++i) { 576 var ids = this._cssModel.styleSheetIdsForURL(cssURLs[i]); 577 for (var j = 0; j < ids.length; ++j) { 578 var header = this._cssModel.styleSheetHeaderForId(ids[j]); 579 console.assert(header); 580 header.updateLocations(); 581 } 582 } 583 }, 584 585 /** 586 * @param {WebInspector.Event} event 587 */ 588 _uiSourceCodeContentCommitted: function(event) 589 { 590 var uiSourceCode = /** @type {WebInspector.UISourceCode} */ (event.data.uiSourceCode); 591 if (uiSourceCode.project().type() === WebInspector.projectTypes.FileSystem) 592 this._sassFileSaved(uiSourceCode.url, true); 593 }, 594 595 _reset: function() 596 { 597 this._addingRevisionCounter = 0; 598 this._completeSourceMapURLForCSSURL = {}; 599 this._cssURLsForSASSURL = {}; 600 /** @type {Object.<string, Array.<function(?WebInspector.SourceMap)>>} */ 601 this._pendingSourceMapLoadingCallbacks = {}; 602 /** @type {Object.<string, {deadlineMs: number, dataByURL: Object.<string, {timer: number, previousPoll: number}>}>} */ 603 this._pollDataForSASSURL = {}; 604 /** @type {Object.<string, WebInspector.SourceMap>} */ 605 this._sourceMapByURL = {}; 606 this._sourceMapByStyleSheetURL = {}; 607 } 608 } 609