Bug 1243406 - enable CSS/SVG/PNG hot reloading for all devtools panels r=bgrins
authorJames Long <longster@gmail.com>
Fri, 26 Feb 2016 14:40:38 -0500
changeset 322097 88df606b81dadf05046728d14428214dfc00e0af
parent 322096 3693e541f0ee18223dd00a1745a4cd09416722a9
child 322098 36d6bc68fe0f21d87b306c7712e939d8ae537b88
push id5913
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 16:57:49 +0000
treeherdermozilla-beta@dcaf0a6fa115 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins
bugs1243406
milestone47.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1243406 - enable CSS/SVG/PNG hot reloading for all devtools panels r=bgrins
devtools/client/debugger/debugger.xul
devtools/client/shared/browser-loader.js
devtools/client/shared/css-reload.js
devtools/client/shared/file-watcher-worker.js
devtools/client/shared/file-watcher.js
devtools/client/shared/moz.build
devtools/client/shared/theme-switching.js
--- a/devtools/client/debugger/debugger.xul
+++ b/devtools/client/debugger/debugger.xul
@@ -1,15 +1,15 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- This Source Code Form is subject to the terms of the Mozilla Public
    - License, v. 2.0. If a copy of the MPL was not distributed with this
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 <?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
 <?xml-stylesheet href="chrome://devtools/content/shared/widgets/widgets.css" type="text/css"?>
-<?xml-stylesheet href="debugger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/content/debugger/debugger.css" type="text/css"?>
 <?xml-stylesheet href="chrome://devtools/skin/widgets.css" type="text/css"?>
 <?xml-stylesheet href="chrome://devtools/skin/debugger.css" type="text/css"?>
 <!DOCTYPE window [
   <!ENTITY % debuggerDTD SYSTEM "chrome://devtools/locale/debugger.dtd">
   %debuggerDTD;
 ]>
 <?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?>
 
--- a/devtools/client/shared/browser-loader.js
+++ b/devtools/client/shared/browser-loader.js
@@ -13,16 +13,20 @@ Cu.import("resource://gre/modules/AppCon
 
 const BROWSER_BASED_DIRS = [
   "resource://devtools/client/jsonview",
   "resource://devtools/client/shared/vendor",
   "resource://devtools/client/shared/components",
   "resource://devtools/client/shared/redux"
 ];
 
+function clearCache() {
+  Services.obs.notifyObservers(null, "startupcache-invalidate", null);
+}
+
 /*
  * Create a loader to be used in a browser environment. This evaluates
  * modules in their own environment, but sets window (the normal
  * global object) as the sandbox prototype, so when a variable is not
  * defined it checks `window` before throwing an error. This makes all
  * browser APIs available to modules by default, like a normal browser
  * environment, but modules are still evaluated in their own scope.
  *
@@ -146,18 +150,19 @@ function BrowserLoaderBuilder(baseURI, w
   }
 
   const mainModule = loaders.Module(baseURI, joinURI(baseURI, "main.js"));
   this.loader = loaders.Loader(opts);
   this.require = loaders.Require(this.loader, mainModule);
 
   if (hotReloadEnabled) {
     const watcher = devtools.require("devtools/client/shared/file-watcher");
-    const onFileChanged = (_, fileURI) => {
-      this.hotReloadFile(window, componentProxies, fileURI);
+    const onFileChanged = (_, relativePath) => {
+      this.hotReloadFile(window, componentProxies,
+                         "resource://devtools/" + relativePath);
     };
     watcher.on("file-changed", onFileChanged);
 
     window.addEventListener("unload", () => {
       watcher.off("file-changed", onFileChanged);
     });
   }
 }
@@ -180,46 +185,26 @@ BrowserLoaderBuilder.prototype = {
   lazyRequireGetter: function(obj, property, module, destructure) {
     devtools.lazyGetter(obj, property, () => {
       return destructure
           ? this.require(module)[property]
           : this.require(module || property);
     });
   },
 
-  clearCache: function() {
-    Services.obs.notifyObservers(null, "startupcache-invalidate", null);
-  },
-
   hotReloadFile: function(window, componentProxies, fileURI) {
-    dump("Hot reloading: " + fileURI + "\n");
-
     if (fileURI.match(/\.js$/)) {
       // Test for React proxy components
       const proxy = componentProxies.get(fileURI);
       if (proxy) {
         // Remove the old module and re-require the new one; the require
         // hook in the loader will take care of the rest
         delete this.loader.modules[fileURI];
-        this.clearCache();
+        clearCache();
         this.require(fileURI);
       }
-    } else if (fileURI.match(/\.css$/)) {
-      const links = [...window.document.getElementsByTagNameNS("http://www.w3.org/1999/xhtml", "link")];
-      links.forEach(link => {
-        if (link.href.indexOf(fileURI) === 0) {
-          const parentNode = link.parentNode;
-          const newLink = window.document.createElementNS("http://www.w3.org/1999/xhtml", "link");
-          newLink.rel = "stylesheet";
-          newLink.type = "text/css";
-          newLink.href = fileURI + "?s=" + Math.random();
-
-          parentNode.insertBefore(newLink, link);
-          parentNode.removeChild(link);
-        }
-      });
     }
   }
 };
 
 this.BrowserLoader = BrowserLoader;
 
 this.EXPORTED_SYMBOLS = ["BrowserLoader"];
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/css-reload.js
@@ -0,0 +1,142 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { Services } = require("resource://gre/modules/Services.jsm");
+const { getTheme } = require("devtools/client/shared/theme");
+
+function iterStyleNodes(window, func) {
+  for (let node of window.document.childNodes) {
+    // Look for ProcessingInstruction nodes.
+    if (node.nodeType === 7) {
+      func(node);
+    }
+  }
+
+  const links = window.document.getElementsByTagNameNS(
+    "http://www.w3.org/1999/xhtml", "link"
+  );
+  for (let node of links) {
+    func(node);
+  }
+}
+
+function replaceCSS(window, fileURI) {
+  const document = window.document;
+  const randomKey = Math.random();
+  Services.obs.notifyObservers(null, "startupcache-invalidate", null);
+
+  // Scan every CSS tag and reload ones that match the file we are
+  // looking for.
+  iterStyleNodes(window, node => {
+    if (node.nodeType === 7) {
+      // xml-stylesheet declaration
+      if (node.data.includes(fileURI)) {
+        const newNode = window.document.createProcessingInstruction(
+          "xml-stylesheet",
+          `href="${fileURI}?s=${randomKey}" type="text/css"`
+        );
+        document.insertBefore(newNode, node);
+        document.removeChild(node);
+      }
+    } else if (node.href.includes(fileURI)) {
+      const parentNode = node.parentNode;
+      const newNode = window.document.createElementNS(
+        "http://www.w3.org/1999/xhtml",
+        "link"
+      );
+      newNode.rel = "stylesheet";
+      newNode.type = "text/css";
+      newNode.href = fileURI + "?s=" + randomKey;
+
+      parentNode.insertBefore(newNode, node);
+      parentNode.removeChild(node);
+    }
+  });
+}
+
+function _replaceResourceInSheet(sheet, filename, randomKey) {
+  for (let i = 0; i < sheet.cssRules.length; i++) {
+    const rule = sheet.cssRules[i];
+    if (rule.type === rule.IMPORT_RULE) {
+      _replaceResourceInSheet(rule.styleSheet, filename);
+    } else if (rule.cssText.includes(filename)) {
+      // Strip off any existing query strings. This might lose
+      // updates for files if there are multiple resources
+      // referenced in the same rule, but the chances of someone hot
+      // reloading multiple resources in the same rule is very low.
+      const text = rule.cssText.replace(/\?s=0.\d+/g, "");
+      const newRule = (
+        text.replace(filename, filename + "?s=" + randomKey)
+      );
+
+      sheet.deleteRule(i);
+      sheet.insertRule(newRule, i);
+    }
+  }
+}
+
+function replaceCSSResource(window, fileURI) {
+  const document = window.document;
+  const randomKey = Math.random();
+
+  // Only match the filename. False positives are much better than
+  // missing updates, as all that would happen is we reload more
+  // resources than we need. We do this because many resources only
+  // use relative paths.
+  const parts = fileURI.split("/");
+  const file = parts[parts.length - 1];
+
+  // Scan every single rule in the entire page for any reference to
+  // this resource, and re-insert the rule to force it to update.
+  for (let sheet of document.styleSheets) {
+    _replaceResourceInSheet(sheet, file, randomKey);
+  }
+
+  for (let node of document.querySelectorAll("img,image")) {
+    if (node.src.startsWith(fileURI)) {
+      node.src = fileURI + "?s=" + randomKey;
+    }
+  }
+}
+
+function watchCSS(window) {
+  if (Services.prefs.getBoolPref("devtools.loader.hotreload")) {
+    const watcher = require("devtools/client/shared/file-watcher");
+
+    function onFileChanged(_, relativePath) {
+      if (relativePath.match(/\.css$/)) {
+        if (relativePath.startsWith("client/themes")) {
+          let path = relativePath.replace(/^client\/themes\//, "");
+
+          // Special-case a few files that get imported from other CSS
+          // files. We just manually hot reload the parent CSS file.
+          if (path === "variables.css" || path === "toolbars.css" ||
+              path === "common.css" || path === "splitters.css") {
+            replaceCSS(window, "chrome://devtools/skin/" + getTheme() + "-theme.css");
+          } else {
+            replaceCSS(window, "chrome://devtools/skin/" + path);
+          }
+          return;
+        }
+
+        replaceCSS(
+          window,
+          "chrome://devtools/content/" +  relativePath.replace(/^client\//, "")
+        );
+        replaceCSS(window, "resource://devtools/" + relativePath);
+      } else if (relativePath.match(/\.(svg|png)$/)) {
+        relativePath = relativePath.replace(/^client\/themes\//, "");
+        replaceCSSResource(window, "chrome://devtools/skin/" + relativePath);
+      }
+    }
+    watcher.on("file-changed", onFileChanged);
+
+    window.addEventListener("unload", () => {
+      watcher.off("file-changed", onFileChanged);
+    });
+  }
+}
+
+module.exports = { watchCSS };
--- a/devtools/client/shared/file-watcher-worker.js
+++ b/devtools/client/shared/file-watcher-worker.js
@@ -4,16 +4,27 @@
 "use strict";
 
 /* eslint-env worker */
 /* global OS */
 importScripts("resource://gre/modules/osfile.jsm");
 
 const modifiedTimes = new Map();
 
+function findSourceDir(path) {
+  if (path === "" || path === "/") {
+    return null;
+  } else if (OS.File.exists(
+    OS.Path.join(path, "devtools/client/shared/file-watcher.js")
+  )) {
+    return path;
+  }
+  return findSourceDir(OS.Path.dirname(path));
+}
+
 function gatherFiles(path, fileRegex) {
   let files = [];
   const iterator = new OS.File.DirectoryIterator(path);
 
   try {
     for (let child in iterator) {
       // Don't descend into test directories. Saves us some time and
       // there's no reason to.
@@ -55,24 +66,49 @@ function scanFiles(files, onChangedFile)
       modifiedTimes.set(file, info.lastModificationDate.getTime());
       onChangedFile(file);
     }
   });
 }
 
 onmessage = function(event) {
   const { path, fileRegex } = event.data;
-  let info = OS.File.stat(path);
+  const devtoolsPath = event.data.devtoolsPath.replace(/\/$/, "");
+
+  // We need to figure out a src dir to watch. These are the actual
+  // files the user is working with, not the files in the obj dir. We
+  // do this by walking up the filesystem and looking for the devtools
+  // directories, and falling back to the raw path. This means none of
+  // this will work for users who store their obj dirs outside of the
+  // src dir.
+  //
+  // We take care not to mess with the `devtoolsPath` if that's what
+  // we end up using, because it might be intentionally mapped to a
+  // specific place on the filesystem for loading devtools externally.
+  //
+  // `devtoolsPath` is currently the devtools directory inside of the
+  // obj dir, and we search for `devtools/client`, so go up 2 levels
+  // to skip that devtools dir and start searching for the src dir.
+  const searchPoint = OS.Path.dirname(OS.Path.dirname(devtoolsPath));
+  const srcPath = findSourceDir(searchPoint);
+  const rootPath = srcPath ? OS.Path.join(srcPath, "devtools") : devtoolsPath;
+  const watchPath = OS.Path.join(rootPath, path.replace(/^devtools\//, ""));
+
+  const info = OS.File.stat(watchPath);
   if (!info.isDir) {
-    throw new Error("watcher expects a directory as root path");
+    throw new Error("Watcher expects a directory as root path");
   }
 
   // We get a list of all the files upfront, which means we don't
   // support adding new files. But you need to rebuild Firefox when
   // adding a new file anyway.
-  const files = gatherFiles(path, fileRegex || /.*/);
+  const files = gatherFiles(watchPath, fileRegex || /.*/);
 
   // Every second, scan for file changes by stat-ing each of them and
   // comparing modification time.
   setInterval(() => {
-    scanFiles(files, changedFile => postMessage(changedFile));
+    scanFiles(files, changedFile => {
+      postMessage({ fullPath: changedFile,
+                    relativePath: changedFile.replace(rootPath + "/", "") });
+    });
   }, 1000);
 };
+
--- a/devtools/client/shared/file-watcher.js
+++ b/devtools/client/shared/file-watcher.js
@@ -1,71 +1,62 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
-const { Ci, ChromeWorker } = require("chrome");
+const { Ci, Cu, ChromeWorker } = require("chrome");
 const { Services } = require("resource://gre/modules/Services.jsm");
 const EventEmitter = require("devtools/shared/event-emitter");
 
 const HOTRELOAD_PREF = "devtools.loader.hotreload";
 
-function resolveResourceURI(uri) {
+function resolveResourcePath(uri) {
   const handler = Services.io.getProtocolHandler("resource")
         .QueryInterface(Ci.nsIResProtocolHandler);
-  return handler.resolveURI(Services.io.newURI(uri, null, null));
+  const resolved = handler.resolveURI(Services.io.newURI(uri, null, null));
+  return resolved.replace(/file:\/\//, "");
 }
 
 function watchFiles(path, onFileChanged) {
   if (!path.startsWith("devtools/")) {
     throw new Error("`watchFiles` expects a devtools path");
   }
 
-  // We need to figure out a local path to watch. We start with
-  // whatever devtools points to.
-  let resolvedRootURI = resolveResourceURI("resource://devtools");
-  if (resolvedRootURI.match(/\/obj\-.*/)) {
-    // Move from the built directory to the user's local files
-    resolvedRootURI = resolvedRootURI.replace(/\/obj\-.*/, "") + "/devtools";
-  }
-  resolvedRootURI = resolvedRootURI.replace(/^file:\/\//, "");
-  const localURI = resolvedRootURI + "/" + path.replace(/^devtools\//, "");
-
   const watchWorker = new ChromeWorker(
     "resource://devtools/client/shared/file-watcher-worker.js"
   );
 
   watchWorker.onmessage = event => {
     // We need to turn a local path back into a resource URI (or
     // chrome). This means that this system will only work when built
     // files are symlinked, so that these URIs actually read from
     // local sources. There might be a better way to do this.
-    const relativePath = event.data.replace(resolvedRootURI + "/", "");
-    if (relativePath.startsWith("client/themes")) {
-      onFileChanged(relativePath.replace("client/themes",
-                                         "chrome://devtools/skin"));
-    }
-    onFileChanged("resource://devtools/" + relativePath);
+    const { relativePath, fullPath } = event.data;
+    onFileChanged(relativePath, fullPath);
   };
 
-  watchWorker.postMessage({ path: localURI, fileRegex: /\.(js|css)$/ });
+  watchWorker.postMessage({
+    path: path,
+    // We must do this here because we can't access the needed APIs in
+    // a worker.
+    devtoolsPath: resolveResourcePath("resource://devtools"),
+    fileRegex: /\.(js|css|svg|png)$/ });
   return watchWorker;
 }
 
 EventEmitter.decorate(module.exports);
 
 let watchWorker;
 function onPrefChange() {
   if (Services.prefs.getBoolPref(HOTRELOAD_PREF) && !watchWorker) {
-    watchWorker = watchFiles("devtools/client", changedFile => {
-      module.exports.emit("file-changed", changedFile);
+    watchWorker = watchFiles("devtools/client", (relativePath, fullPath) => {
+      module.exports.emit("file-changed", relativePath, fullPath);
     });
-  }
-  else if(watchWorker) {
+  } else if (watchWorker) {
     watchWorker.terminate();
     watchWorker = null;
   }
 }
 
 Services.prefs.addObserver(HOTRELOAD_PREF, {
   observe: onPrefChange
 }, false);
--- a/devtools/client/shared/moz.build
+++ b/devtools/client/shared/moz.build
@@ -14,16 +14,17 @@ DIRS += [
     'widgets',
 ]
 
 DevToolsModules(
     'AppCacheUtils.jsm',
     'autocomplete-popup.js',
     'browser-loader.js',
     'css-parsing-utils.js',
+    'css-reload.js',
     'Curl.jsm',
     'demangle.js',
     'developer-toolbar.js',
     'devices.js',
     'DOMHelpers.jsm',
     'doorhanger.js',
     'file-watcher-worker.js',
     'file-watcher.js',
--- a/devtools/client/shared/theme-switching.js
+++ b/devtools/client/shared/theme-switching.js
@@ -154,23 +154,26 @@
   function handlePrefChange(event, data) {
     if (data.pref == "devtools.theme") {
       switchTheme(data.newValue, data.oldValue);
     }
   }
 
   const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
   Cu.import("resource://gre/modules/Services.jsm");
-  const {require} = Components.utils.import("resource://devtools/shared/Loader.jsm", {});
-  const {gDevTools} = require("devtools/client/framework/devtools");
+  const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+  const { gDevTools } = require("devtools/client/framework/devtools");
   const StylesheetUtils = require("sdk/stylesheet/utils");
+  const { watchCSS } = require("devtools/client/shared/css-reload");
 
   if (documentElement.hasAttribute("force-theme")) {
     switchTheme(documentElement.getAttribute("force-theme"));
   } else {
     switchTheme(Services.prefs.getCharPref("devtools.theme"));
 
     gDevTools.on("pref-changed", handlePrefChange);
     window.addEventListener("unload", function() {
       gDevTools.off("pref-changed", handlePrefChange);
     });
   }
+
+  watchCSS(window);
 })();