From 32bc04cb3c5b873afcf29cefa0c182885866ad6b Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 26 Feb 2016 14:40:38 -0500 Subject: [PATCH] Bug 1243406 - enable CSS/SVG/PNG hot reloading for all devtools panels r=bgrins --- devtools/client/debugger/debugger.xul | 2 +- devtools/client/shared/browser-loader.js | 31 +--- devtools/client/shared/css-reload.js | 142 ++++++++++++++++++ devtools/client/shared/file-watcher-worker.js | 44 +++++- devtools/client/shared/file-watcher.js | 39 ++--- devtools/client/shared/moz.build | 1 + devtools/client/shared/theme-switching.js | 7 +- 7 files changed, 212 insertions(+), 54 deletions(-) create mode 100644 devtools/client/shared/css-reload.js diff --git a/devtools/client/debugger/debugger.xul b/devtools/client/debugger/debugger.xul index ce0b0ca67dd2..06b964184a7c 100644 --- a/devtools/client/debugger/debugger.xul +++ b/devtools/client/debugger/debugger.xul @@ -4,7 +4,7 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> - + { - this.hotReloadFile(window, componentProxies, fileURI); + const onFileChanged = (_, relativePath) => { + this.hotReloadFile(window, componentProxies, + "resource://devtools/" + relativePath); }; watcher.on("file-changed", onFileChanged); @@ -185,13 +190,7 @@ BrowserLoaderBuilder.prototype = { }); }, - 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); @@ -199,23 +198,9 @@ BrowserLoaderBuilder.prototype = { // 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); - } - }); } } }; diff --git a/devtools/client/shared/css-reload.js b/devtools/client/shared/css-reload.js new file mode 100644 index 000000000000..919e416efb04 --- /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 }; diff --git a/devtools/client/shared/file-watcher-worker.js b/devtools/client/shared/file-watcher-worker.js index 2f1c4575432c..8ec5504899cd 100644 --- a/devtools/client/shared/file-watcher-worker.js +++ b/devtools/client/shared/file-watcher-worker.js @@ -9,6 +9,17 @@ 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); @@ -60,19 +71,44 @@ function scanFiles(files, onChangedFile) { 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); }; + diff --git a/devtools/client/shared/file-watcher.js b/devtools/client/shared/file-watcher.js index f423a64fe6de..d74ca6e4be34 100644 --- a/devtools/client/shared/file-watcher.js +++ b/devtools/client/shared/file-watcher.js @@ -3,16 +3,17 @@ * 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) { @@ -20,16 +21,6 @@ function watchFiles(path, onFileChanged) { 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" ); @@ -39,15 +30,16 @@ function watchFiles(path, onFileChanged) { // 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; } @@ -56,11 +48,10 @@ 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; } diff --git a/devtools/client/shared/moz.build b/devtools/client/shared/moz.build index 9679b644bb71..d6cd3f87bc50 100644 --- a/devtools/client/shared/moz.build +++ b/devtools/client/shared/moz.build @@ -19,6 +19,7 @@ DevToolsModules( 'autocomplete-popup.js', 'browser-loader.js', 'css-parsing-utils.js', + 'css-reload.js', 'Curl.jsm', 'demangle.js', 'developer-toolbar.js', diff --git a/devtools/client/shared/theme-switching.js b/devtools/client/shared/theme-switching.js index c076f8734929..2cd3d05f0d11 100644 --- a/devtools/client/shared/theme-switching.js +++ b/devtools/client/shared/theme-switching.js @@ -159,9 +159,10 @@ 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")); @@ -173,4 +174,6 @@ gDevTools.off("pref-changed", handlePrefChange); }); } + + watchCSS(window); })();