Bug 950915 - Watch for changes to CSS files on disk for source mapped files; r=dcamp
authorHeather Arthur <fayearthur@gmail.com>
Sat, 01 Feb 2014 12:26:53 -0800
changeset 182471 70c4ddda20ec4ef91b40c5365e30518d60d0b08c
parent 182470 ed28aeea074ab74f1f0b8ad55c18a37284cf22e7
child 182472 1eb1165f9a5801ec072a3facedfd8c7c8286ce1f
push id3343
push userffxbld
push dateMon, 17 Mar 2014 21:55:32 +0000
treeherdermozilla-beta@2f7d3415f79f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdcamp
bugs950915
milestone29.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 950915 - Watch for changes to CSS files on disk for source mapped files; r=dcamp
browser/devtools/styleeditor/StyleEditorUI.jsm
browser/devtools/styleeditor/StyleSheetEditor.jsm
browser/devtools/styleeditor/styleeditor-panel.js
browser/devtools/styleeditor/styleeditor.css
browser/devtools/styleeditor/styleeditor.xul
browser/devtools/styleeditor/test/browser.ini
browser/devtools/styleeditor/test/browser_styleeditor_sourcemap_watching.js
browser/themes/shared/devtools/styleeditor.css
toolkit/devtools/server/actors/stylesheets.js
--- a/browser/devtools/styleeditor/StyleEditorUI.jsm
+++ b/browser/devtools/styleeditor/StyleEditorUI.jsm
@@ -9,16 +9,17 @@ this.EXPORTED_SYMBOLS = ["StyleEditorUI"
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/PluralForm.jsm");
 Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
 let promise = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js").Promise;
 Cu.import("resource:///modules/devtools/shared/event-emitter.js");
 Cu.import("resource:///modules/devtools/gDevTools.jsm");
 Cu.import("resource:///modules/devtools/StyleEditorUtil.jsm");
 Cu.import("resource:///modules/devtools/SplitView.jsm");
 Cu.import("resource:///modules/devtools/StyleSheetEditor.jsm");
 
 const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
@@ -222,16 +223,17 @@ StyleEditorUI.prototype = {
     }
 
     styleSheet.getOriginalSources().then((sources) => {
       if (sources && sources.length) {
         this._removeStyleSheetEditor(editor);
         sources.forEach((source) => {
           // set so the first sheet will be selected, even if it's a source
           source.styleSheetIndex = styleSheet.styleSheetIndex;
+          source.relatedStyleSheet = styleSheet;
 
           this._addStyleSheetEditor(source);
         });
       }
     });
   },
 
   /**
@@ -245,16 +247,18 @@ StyleEditorUI.prototype = {
    *         Optional if stylesheet is a new sheet created by user
    */
   _addStyleSheetEditor: function(styleSheet, file, isNew) {
     let editor =
       new StyleSheetEditor(styleSheet, this._window, file, isNew, this._walker);
 
     editor.on("property-change", this._summaryChange.bind(this, editor));
     editor.on("style-applied", this._summaryChange.bind(this, editor));
+    editor.on("linked-css-file", this._summaryChange.bind(this, editor));
+    editor.on("linked-css-file-error", this._summaryChange.bind(this, editor));
     editor.on("error", this._onError);
 
     this.editors.push(editor);
 
     editor.fetchSource(this._sourceLoaded.bind(this, editor));
     return editor;
   },
 
@@ -552,37 +556,48 @@ StyleEditorUI.prototype = {
    *        Optional item's summary element to update. If none, item corresponding
    *        to passed editor is used.
    */
   _updateSummaryForEditor: function(editor, summary) {
     summary = summary || editor.summary;
     if (!summary) {
       return;
     }
-    let ruleCount = "-";
-    if (editor.styleSheet.ruleCount !== undefined) {
-      ruleCount = editor.styleSheet.ruleCount;
+
+    let ruleCount = editor.styleSheet.ruleCount;
+    if (editor.styleSheet.relatedStyleSheet) {
+      ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount;
+    }
+    if (ruleCount === undefined) {
+      ruleCount = "-";
     }
 
     var flags = [];
     if (editor.styleSheet.disabled) {
       flags.push("disabled");
     }
     if (editor.unsaved) {
       flags.push("unsaved");
     }
+    if (editor.linkedCSSFileError) {
+      flags.push("linked-file-error");
+    }
     this._view.setItemClassName(summary, flags.join(" "));
 
     let label = summary.querySelector(".stylesheet-name > label");
     label.setAttribute("value", editor.friendlyName);
 
+    let linkedCSSFile = "";
+    if (editor.linkedCSSFile) {
+      linkedCSSFile = OS.Path.basename(editor.linkedCSSFile);
+    }
+    text(summary, ".stylesheet-linked-file", linkedCSSFile);
     text(summary, ".stylesheet-title", editor.styleSheet.title || "");
     text(summary, ".stylesheet-rule-count",
       PluralForm.get(ruleCount, _("ruleCount.label")).replace("#1", ruleCount));
-    text(summary, ".stylesheet-error-message", editor.errorMessage);
   },
 
   destroy: function() {
     this._clearStyleSheetEditors();
 
     this._prefObserver.off(PREF_ORIG_SOURCES, this._onNewDocument);
     this._prefObserver.destroy();
   }
--- a/browser/devtools/styleeditor/StyleSheetEditor.jsm
+++ b/browser/devtools/styleeditor/StyleSheetEditor.jsm
@@ -29,16 +29,23 @@ const SAVE_ERROR = "error-save";
 
 // max update frequency in ms (avoid potential typing lag and/or flicker)
 // @see StyleEditor.updateStylesheet
 const UPDATE_STYLESHEET_THROTTLE_DELAY = 500;
 
 // Pref which decides if CSS autocompletion is enabled in Style Editor or not.
 const AUTOCOMPLETION_PREF = "devtools.styleeditor.autocompletion-enabled";
 
+// How long to wait to update linked CSS file after original source was saved
+// to disk. Time in ms.
+const CHECK_LINKED_SHEET_DELAY=500;
+
+// How many times to check for linked file changes
+const MAX_CHECK_COUNT=10;
+
 /**
  * StyleSheetEditor controls the editor linked to a particular StyleSheet
  * object.
  *
  * Emits events:
  *   'property-change': A property on the underlying stylesheet has changed
  *   'source-editor-load': The source editor for this editor has been loaded
  *   'error': An error has occured
@@ -54,53 +61,52 @@ const AUTOCOMPLETION_PREF = "devtools.st
  * @param {Walker} walker
  *        Optional walker used for selectors autocompletion
  */
 function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
   EventEmitter.decorate(this);
 
   this.styleSheet = styleSheet;
   this._inputElement = null;
-  this._sourceEditor = null;
+  this.sourceEditor = null;
   this._window = win;
   this._isNew = isNew;
-  this.savedFile = file;
   this.walker = walker;
 
-  this.errorMessage = null;
-
-  let readOnly = false;
-  if (styleSheet.isOriginalSource) {
-    // live-preview won't work with sources that need compilation
-    readOnly = true;
-  }
-
   this._state = {   // state to use when inputElement attaches
     text: "",
     selection: {
       start: {line: 0, ch: 0},
       end: {line: 0, ch: 0}
     },
-    readOnly: readOnly,
-    topIndex: 0,              // the first visible line
+    topIndex: 0              // the first visible line
   };
 
   this._styleSheetFilePath = null;
   if (styleSheet.href &&
       Services.io.extractScheme(this.styleSheet.href) == "file") {
     this._styleSheetFilePath = this.styleSheet.href;
   }
 
   this._onPropertyChange = this._onPropertyChange.bind(this);
   this._onError = this._onError.bind(this);
+  this.checkLinkedFileForChanges = this.checkLinkedFileForChanges.bind(this);
+  this.markLinkedFileBroken = this.markLinkedFileBroken.bind(this);
 
   this._focusOnSourceEditorReady = false;
 
+  let relatedSheet = this.styleSheet.relatedStyleSheet;
+  if (relatedSheet) {
+    relatedSheet.on("property-change", this._onPropertyChange);
+  }
   this.styleSheet.on("property-change", this._onPropertyChange);
   this.styleSheet.on("error", this._onError);
+
+  this.savedFile = file;
+  this.linkCSSFile();
 }
 
 StyleSheetEditor.prototype = {
   /**
    * Whether there are unsaved changes in the editor
    */
   get unsaved() {
     return this.sourceEditor && !this.sourceEditor.isClean();
@@ -109,16 +115,26 @@ StyleSheetEditor.prototype = {
   /**
    * Whether the editor is for a stylesheet created by the user
    * through the style editor UI.
    */
   get isNew() {
     return this._isNew;
   },
 
+  get savedFile() {
+    return this._savedFile;
+  },
+
+  set savedFile(name) {
+    this._savedFile = name;
+
+    this.linkCSSFile();
+  },
+
   /**
    * Get a user-friendly name for the style sheet.
    *
    * @return string
    */
   get friendlyName() {
     if (this.savedFile) {
       return this.savedFile.leafName;
@@ -141,16 +157,58 @@ StyleSheetEditor.prototype = {
         this._friendlyName = decodeURI(this._friendlyName);
       } catch (ex) {
       }
     }
     return this._friendlyName;
   },
 
   /**
+   * If this is an original source, get the path of the CSS file it generated.
+   */
+  linkCSSFile: function() {
+    if (!this.styleSheet.isOriginalSource) {
+      return;
+    }
+
+    let relatedSheet = this.styleSheet.relatedStyleSheet;
+
+    let path;
+    var uri = NetUtil.newURI(relatedSheet.href);
+
+    if (uri.scheme == "file") {
+      var file = uri.QueryInterface(Ci.nsIFileURL).file;
+      path = file.path;
+    }
+    else if (this.savedFile) {
+      let origUri = NetUtil.newURI(this.styleSheet.href);
+      path = findLinkedFilePath(uri, origUri, this.savedFile);
+    }
+    else {
+      // we can't determine path to generated file on disk
+      return;
+    }
+
+    if (this.linkedCSSFile == path) {
+      return;
+    }
+
+    this.linkedCSSFile = path;
+
+    this.linkedCSSFileError = null;
+
+    // save last file change time so we can compare when we check for changes.
+    OS.File.stat(path).then((info) => {
+      this._fileModDate = info.lastModificationDate.getTime();
+    }, this.markLinkedFileBroken);
+
+    this.emit("linked-css-file");
+  },
+
+  /**
    * Start fetching the full text source for this editor's sheet.
    */
   fetchSource: function(callback) {
     this.styleSheet.getText().then((longStr) => {
       longStr.string().then((source) => {
         this._state.text = prettifyCSS(source);
         this.sourceLoaded = true;
 
@@ -191,35 +249,37 @@ StyleSheetEditor.prototype = {
    */
   load: function(inputElement) {
     this._inputElement = inputElement;
 
     let config = {
       value: this._state.text,
       lineNumbers: true,
       mode: Editor.modes.css,
-      readOnly: this._state.readOnly,
+      readOnly: false,
       autoCloseBrackets: "{}()[]",
       extraKeys: this._getKeyBindings(),
       contextMenu: "sourceEditorContextMenu"
     };
     let sourceEditor = new Editor(config);
 
     sourceEditor.appendTo(inputElement).then(() => {
       if (Services.prefs.getBoolPref(AUTOCOMPLETION_PREF)) {
         sourceEditor.extend(AutoCompleter);
         sourceEditor.setupAutoCompletion(this.walker);
       }
       sourceEditor.on("save", () => {
         this.saveToFile();
       });
 
-      sourceEditor.on("change", () => {
-        this.updateStyleSheet();
-      });
+      if (this.styleSheet.update) {
+        sourceEditor.on("change", () => {
+          this.updateStyleSheet();
+        });
+      }
 
       this.sourceEditor = sourceEditor;
 
       if (this._focusOnSourceEditorReady) {
         this._focusOnSourceEditorReady = false;
         sourceEditor.focus();
       }
 
@@ -250,29 +310,29 @@ StyleSheetEditor.prototype = {
     });
     return deferred.promise;
   },
 
   /**
    * Focus the Style Editor input.
    */
   focus: function() {
-    if (this._sourceEditor) {
-      this._sourceEditor.focus();
+    if (this.sourceEditor) {
+      this.sourceEditor.focus();
     } else {
       this._focusOnSourceEditorReady = true;
     }
   },
 
   /**
    * Event handler for when the editor is shown.
    */
   onShow: function() {
-    if (this._sourceEditor) {
-      this._sourceEditor.setFirstVisibleLine(this._state.topIndex);
+    if (this.sourceEditor) {
+      this.sourceEditor.setFirstVisibleLine(this._state.topIndex);
     }
     this.focus();
   },
 
   /**
    * Toggled the disabled state of the underlying stylesheet.
    */
   toggleDisabled: function() {
@@ -338,18 +398,18 @@ StyleSheetEditor.prototype = {
     let onFile = (returnFile) => {
       if (!returnFile) {
         if (callback) {
           callback(null);
         }
         return;
       }
 
-      if (this._sourceEditor) {
-        this._state.text = this._sourceEditor.getText();
+      if (this.sourceEditor) {
+        this._state.text = this.sourceEditor.getText();
       }
 
       let ostream = FileUtils.openSafeFileOutputStream(returnFile);
       let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                         .createInstance(Ci.nsIScriptableUnicodeConverter);
       converter.charset = "UTF-8";
       let istream = converter.convertToInputStream(this._state.text);
 
@@ -357,36 +417,106 @@ StyleSheetEditor.prototype = {
         if (!Components.isSuccessCode(status)) {
           if (callback) {
             callback(null);
           }
           this.emit("error", SAVE_ERROR);
           return;
         }
         FileUtils.closeSafeFileOutputStream(ostream);
-        // remember filename for next save if any
-        this._friendlyName = null;
-        this.savedFile = returnFile;
+
+        this.onFileSaved(returnFile);
 
         if (callback) {
           callback(returnFile);
         }
-        this.sourceEditor.setClean();
-
-        this.emit("property-change");
       }.bind(this));
     };
 
     let defaultName;
     if (this._friendlyName) {
       defaultName = OS.Path.basename(this._friendlyName);
     }
     showFilePicker(file || this._styleSheetFilePath, true, this._window,
                    onFile, defaultName);
- },
+  },
+
+  /**
+   * Called when this source has been successfully saved to disk.
+   */
+  onFileSaved: function(returnFile) {
+    this._friendlyName = null;
+    this.savedFile = returnFile;
+
+    this.sourceEditor.setClean();
+
+    this.emit("property-change");
+
+    // TODO: replace with file watching
+    this._modCheckCount = 0;
+    this._window.clearTimeout(this._timeout);
+
+    if (this.linkedCSSFile && !this.linkedCSSFileError) {
+      this._timeout = this._window.setTimeout(this.checkLinkedFileForChanges,
+                                              CHECK_LINKED_SHEET_DELAY);
+    }
+  },
+
+  /**
+   * Check to see if our linked CSS file has changed on disk, and
+   * if so, update the live style sheet.
+   */
+  checkLinkedFileForChanges: function() {
+    OS.File.stat(this.linkedCSSFile).then((info) => {
+      let lastChange = info.lastModificationDate.getTime();
+
+      if (this._fileModDate && lastChange != this._fileModDate) {
+        this._fileModDate = lastChange;
+        this._modCheckCount = 0;
+
+        this.updateLinkedStyleSheet();
+        return;
+      }
+
+      if (++this._modCheckCount > MAX_CHECK_COUNT) {
+        this.updateLinkedStyleSheet();
+        return;
+      }
+
+      // try again in a bit
+      this._timeout = this._window.setTimeout(this.checkLinkedFileForChanges,
+                                              CHECK_LINKED_SHEET_DELAY);
+    }, this.markLinkedFileBroken);
+  },
+
+  /**
+   * Notify that the linked CSS file (if this is an original source)
+   * doesn't exist on disk in the place we think it does.
+   *
+   * @param string error
+   *        The error we got when trying to access the file.
+   */
+  markLinkedFileBroken: function(error) {
+    this.linkedCSSFileError = error || true;
+    this.emit("linked-css-file-error");
+  },
+
+  /**
+   * For original sources (e.g. Sass files). Fetch contents of linked CSS
+   * file from disk and live update the stylesheet object with the contents.
+   */
+  updateLinkedStyleSheet: function() {
+    OS.File.read(this.linkedCSSFile).then((array) => {
+      let decoder = new TextDecoder();
+      let text = decoder.decode(array);
+
+      let relatedSheet = this.styleSheet.relatedStyleSheet;
+      relatedSheet.update(text, true);
+    }, this.markLinkedFileBroken);
+  },
 
   /**
     * Retrieve custom key bindings objects as expected by Editor.
     * Editor action names are not displayed to the user.
     *
     * @return {array} key binding objects for the source editor
     */
   _getKeyBindings: function() {
@@ -477,8 +607,78 @@ function prettifyCSS(text)
 
     if (c == "{") {
       indent = TAB_CHARS.repeat(++indentLevel);
     }
   }
   return parts.join(LINE_SEPARATOR);
 }
 
+/**
+ * Find a path on disk for a file given it's hosted uri, the uri of the
+ * original resource that generated it (e.g. Sass file), and the location of the
+ * local file for that source.
+ */
+function findLinkedFilePath(uri, origUri, file) {
+  let project = findProjectPath(origUri, file);
+  let branch = findUnsharedBranch(origUri, uri);
+
+  let parts = project.concat(branch);
+  let path = OS.Path.join.apply(this, parts);
+
+  return path;
+}
+
+/**
+ * Find the path of a project given a file in the project and the uri
+ * of that resource. e.g.:
+ * "http://localhost/src/a.css" and "/Users/moz/proj/src/a.css"
+ * would yeild ["Users", "moz", "proj"]
+ *
+ * @param {nsIURI} uri
+ *        uri of hosted resource
+ * @param {nsIFile} file
+ *        file for that resource on disk
+ * @return {array}
+ *        array of path parts
+ */
+function findProjectPath(uri, file) {
+  let uri = OS.Path.split(uri.path).components;
+  let path = OS.Path.split(file.path).components;
+
+  // don't care about differing leaf names
+  uri.pop();
+  path.pop();
+
+  let dir = path.pop();
+  while(dir) {
+    let serverDir = uri.pop();
+    if (serverDir != dir) {
+      return path.concat([dir]);
+    }
+    dir = path.pop();
+  }
+  return [];
+}
+
+/**
+ * Find the part of a uri past the root it shares with another uri. e.g:
+ * "http://localhost/built/a.scss" and "http://localhost/src/a.css"
+ * would yeild ["built", "a.scss"];
+ *
+ * @param {nsIURI} origUri
+ *        uri to find unshared branch of
+ * @param {nsIURI} origUri
+ *        uri to compare against to get a shared root
+ * @return {array}
+ *         array of path parts for branch
+ */
+function findUnsharedBranch(origUri, uri) {
+  origUri = OS.Path.split(origUri.path).components;
+  uri = OS.Path.split(uri.path).components;
+
+  for (var i = 0; i < uri.length - 1; i++) {
+    if (uri[i] != origUri[i]) {
+      return uri.slice(i);
+    }
+  }
+  return uri;
+}
--- a/browser/devtools/styleeditor/styleeditor-panel.js
+++ b/browser/devtools/styleeditor/styleeditor-panel.js
@@ -131,17 +131,16 @@ StyleEditorPanel.prototype = {
     if (!this._destroyed) {
       this._destroyed = true;
 
       this._target.off("close", this.destroy);
       this._target = null;
       this._toolbox = null;
       this._panelDoc = null;
 
-      this._debuggee.destroy();
       this.UI.destroy();
     }
 
     return promise.resolve(null);
   },
 }
 
 XPCOMUtils.defineLazyGetter(StyleEditorPanel.prototype, "strings",
--- a/browser/devtools/styleeditor/styleeditor.css
+++ b/browser/devtools/styleeditor/styleeditor.css
@@ -43,16 +43,32 @@ li.error > .stylesheet-info > .styleshee
 .stylesheet-name {
   white-space: nowrap;
 }
 
 li.unsaved > hgroup > h1 > .stylesheet-name:before {
   content: "*";
 }
 
+li.linked-file-error .stylesheet-linked-file {
+  text-decoration: line-through;
+}
+
+li.linked-file-error .stylesheet-linked-file:after {
+  content: " ✘";
+}
+
+li.linked-file-error .stylesheet-rule-count {
+  visibility: hidden;
+}
+
+.stylesheet-linked-file:not(:empty):before {
+  content: " ↳ ";
+}
+
 .stylesheet-enabled {
   display: -moz-box;
   cursor: pointer;
 }
 
 .stylesheet-saveButton {
   display: none;
   margin-top: 0px;
--- a/browser/devtools/styleeditor/styleeditor.xul
+++ b/browser/devtools/styleeditor/styleeditor.xul
@@ -106,18 +106,18 @@
       <li id="splitview-tpl-summary-stylesheet" tabindex="0">
         <xul:label class="stylesheet-enabled" tabindex="0"
           tooltiptext="&visibilityToggle.tooltip;"
           accesskey="&saveButton.accesskey;"></xul:label>
         <hgroup class="stylesheet-info">
           <h1><a class="stylesheet-name" tabindex="0"><xul:label crop="start"/></a></h1>
           <div class="stylesheet-more">
             <h3 class="stylesheet-title"></h3>
+            <h3 class="stylesheet-linked-file"></h3>
             <h3 class="stylesheet-rule-count"></h3>
-            <h3 class="stylesheet-error-message"></h3>
             <xul:spacer/>
             <h3><xul:label class="stylesheet-saveButton"
                   tooltiptext="&saveButton.tooltip;"
                   accesskey="&saveButton.accesskey;">&saveButton.label;</xul:label></h3>
           </div>
         </hgroup>
       </li>
 
--- a/browser/devtools/styleeditor/test/browser.ini
+++ b/browser/devtools/styleeditor/test/browser.ini
@@ -44,8 +44,9 @@ support-files =
 # Disabled because of intermittent failures - See Bug 942473
 skip-if = true
 [browser_styleeditor_private_perwindowpb.js]
 [browser_styleeditor_reload.js]
 [browser_styleeditor_sv_keynav.js]
 [browser_styleeditor_sv_resize.js]
 [browser_styleeditor_selectstylesheet.js]
 [browser_styleeditor_sourcemaps.js]
+[browser_styleeditor_sourcemap_watching.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/styleeditor/test/browser_styleeditor_sourcemap_watching.js
@@ -0,0 +1,182 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Components.utils.import("resource://gre/modules/Task.jsm");
+let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let promise = devtools.require("sdk/core/promise");
+
+const TESTCASE_URI_HTML = TEST_BASE + "sourcemaps.html";
+const TESTCASE_URI_CSS = TEST_BASE + "sourcemaps.css";
+const TESTCASE_URI_REG_CSS = TEST_BASE + "simple.css";
+const TESTCASE_URI_SCSS = TEST_BASE + "sourcemaps.scss";
+const TESTCASE_URI_MAP = TEST_BASE + "sourcemaps.css.map";
+
+const PREF = "devtools.styleeditor.source-maps-enabled";
+
+const CSS_TEXT = "* { color: blue }";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+let tempScope = {};
+Components.utils.import("resource://gre/modules/FileUtils.jsm", tempScope);
+Components.utils.import("resource://gre/modules/NetUtil.jsm", tempScope);
+let FileUtils = tempScope.FileUtils;
+let NetUtil = tempScope.NetUtil;
+
+function test()
+{
+  waitForExplicitFinish();
+
+  Services.prefs.setBoolPref(PREF, true);
+
+  Task.spawn(function() {
+    // copy all our files over so we don't screw them up for other tests
+    let HTMLFile = yield copy(TESTCASE_URI_HTML, "sourcemaps.html");
+    let CSSFile = yield copy(TESTCASE_URI_CSS, "sourcemaps.css");
+    yield copy(TESTCASE_URI_SCSS, "sourcemaps.scss");
+    yield copy(TESTCASE_URI_MAP, "sourcemaps.css.map");
+    yield copy(TESTCASE_URI_REG_CSS, "simple.css");
+
+    let uri = Services.io.newFileURI(HTMLFile);
+    let testcaseURI = uri.resolve("");
+
+    let editor = yield openEditor(testcaseURI);
+
+    let element = content.document.querySelector("div");
+    let style = content.getComputedStyle(element, null);
+
+    is(style.color, "rgb(255, 0, 102)", "div is red before saving file");
+
+    editor.styleSheet.relatedStyleSheet.once("style-applied", function() {
+      is(style.color, "rgb(0, 0, 255)", "div is blue after saving file");
+      finishUp();
+    });
+
+    yield pauseForTimeChange();
+
+    // Edit and save Sass in the editor. This will start off a file-watching
+    // process waiting for the CSS file to change.
+    yield editSCSS(editor);
+
+    // We can't run Sass or another compiler, so we fake it by just
+    // directly changing the CSS file.
+    yield editCSSFile(CSSFile);
+
+    info("wrote to CSS file");
+  })
+}
+
+function openEditor(testcaseURI) {
+  let deferred = promise.defer();
+
+  addTabAndOpenStyleEditor((panel) => {
+    info("style editor panel opened");
+
+    let UI = panel.UI;
+    let count = 0;
+
+    UI.on("editor-added", (event, editor) => {
+      if (++count == 3) {
+        // wait for 3 editors - 1 for first style sheet, 1 for the
+        // generated style sheet, and 1 for original source after it
+        // loads and replaces the generated style sheet.
+        let editor = UI.editors[1];
+
+        let link = getStylesheetNameLinkFor(editor);
+        link.click();
+
+        editor.getSourceEditor().then(deferred.resolve);
+      }
+    });
+  })
+  content.location = testcaseURI;
+
+  return deferred.promise;
+}
+
+function editSCSS(editor) {
+  let deferred = promise.defer();
+
+  let pos = {line: 0, ch: 0};
+  editor.sourceEditor.replaceText(CSS_TEXT, pos, pos);
+
+  editor.saveToFile(null, function (file) {
+    ok(file, "Scss file should be saved");
+    deferred.resolve();
+  });
+
+  return deferred.promise;
+}
+
+function editCSSFile(CSSFile) {
+  return write(CSS_TEXT, CSSFile);
+}
+
+function pauseForTimeChange() {
+  let deferred = promise.defer();
+
+  // We have to wait for the system time to turn over > 1000 ms so that
+  // our file's last change time will show a change. This reflects what
+  // would happen in real life with a user manually saving the file.
+  setTimeout(deferred.resolve, 2000);
+
+  return deferred.promise;
+}
+
+function finishUp() {
+  Services.prefs.clearUserPref(PREF);
+  finish();
+}
+
+/* Helpers */
+
+function getStylesheetNameLinkFor(editor) {
+  return editor.summary.querySelector(".stylesheet-name");
+}
+
+function copy(aSrcChromeURL, aDestFileName)
+{
+  let destFile = FileUtils.getFile("ProfD", [aDestFileName]);
+  return write(read(aSrcChromeURL), destFile);
+}
+
+function read(aSrcChromeURL)
+{
+  let scriptableStream = Cc["@mozilla.org/scriptableinputstream;1"]
+    .getService(Ci.nsIScriptableInputStream);
+
+  let channel = Services.io.newChannel(aSrcChromeURL, null, null);
+  let input = channel.open();
+  scriptableStream.init(input);
+
+  let data = scriptableStream.read(input.available());
+  scriptableStream.close();
+  input.close();
+
+  return data;
+}
+
+function write(aData, aFile)
+{
+  let deferred = promise.defer();
+
+  let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+    .createInstance(Ci.nsIScriptableUnicodeConverter);
+
+  converter.charset = "UTF-8";
+
+  let istream = converter.convertToInputStream(aData);
+  let ostream = FileUtils.openSafeFileOutputStream(aFile);
+
+  NetUtil.asyncCopy(istream, ostream, function(status) {
+    if (!Components.isSuccessCode(status)) {
+      info("Coudln't write to " + aFile.path);
+      return;
+    }
+    deferred.resolve(aFile);
+  });
+
+  return deferred.promise;
+}
--- a/browser/themes/shared/devtools/styleeditor.css
+++ b/browser/themes/shared/devtools/styleeditor.css
@@ -13,38 +13,41 @@
 }
 
 .theme-dark .stylesheet-title,
 .theme-dark .stylesheet-name {
   color: #f5f7fa;
 }
 
 .theme-dark .stylesheet-rule-count,
+.theme-dark .stylesheet-linked-file,
 .theme-dark .stylesheet-saveButton {
   color: #b6babf;
 }
 
 .theme-light .stylesheet-title,
 .theme-light .stylesheet-name {
   color: #585959;
 }
 
 .theme-light .stylesheet-rule-count,
+.theme-light .stylesheet-linked-file,
 .theme-light .stylesheet-saveButton {
   color: #18191a;
 }
 
 .stylesheet-saveButton {
   text-decoration: underline;
   cursor: pointer;
 }
 
 .splitview-active .stylesheet-title,
 .splitview-active .stylesheet-name,
 .theme-light .splitview-active .stylesheet-rule-count,
+.theme-light .splitview-active .stylesheet-linked-file,
 .theme-light .splitview-active .stylesheet-saveButton {
   color: #f5f7fa;
 }
 
 .splitview-nav:focus {
   outline: 0; /* focus ring is on the stylesheet name */
 }
 
@@ -80,18 +83,26 @@
   background-position: -24px 8px;
 }
 
 .splitview-nav > li > .stylesheet-enabled:focus,
 .splitview-nav > li:hover > .stylesheet-enabled {
   outline: 0;
 }
 
-.stylesheet-error-message {
-  color: red;
+.stylesheet-linked-file:not(:empty){
+  -moz-margin-end: 0.4em;
+}
+
+.stylesheet-linked-file:not(:empty):before {
+  -moz-margin-start: 0.4em;
+}
+
+li.linked-file-error .stylesheet-linked-file:after {
+  font-size: 110%;
 }
 
 .stylesheet-more > h3 {
   font-size: 11px;
   -moz-margin-end: 2px;
 }
 
 .devtools-searchinput {
--- a/toolkit/devtools/server/actors/stylesheets.js
+++ b/toolkit/devtools/server/actors/stylesheets.js
@@ -542,17 +542,17 @@ let StyleSheetActor = protocol.ActorClas
     this._originalSources = null;
   },
 
   /**
    * Sets the source map's sourceRoot to be relative to the source map url.
    */
   _setSourceMapRoot: function(aSourceMap, aAbsSourceMapURL, aScriptURL) {
     const base = dirname(
-      aAbsSourceMapURL.indexOf("data:") === 0
+      aAbsSourceMapURL.startsWith("data:")
         ? aScriptURL
         : aAbsSourceMapURL);
     aSourceMap.sourceRoot = aSourceMap.sourceRoot
       ? normalize(aSourceMap.sourceRoot, base)
       : base;
   },
 
   /**
@@ -695,36 +695,36 @@ let StyleSheetActor = protocol.ActorClas
 
     // Set up clean up and commit after transition duration (+10% buffer)
     // @see _onTransitionEnd
     this.window.setTimeout(this._onTransitionEnd.bind(this),
                            Math.floor(TRANSITION_DURATION_MS * 1.1));
   },
 
   /**
-    * This cleans up class and rule added for transition effect and then
-    * notifies that the style has been applied.
-    */
+   * This cleans up class and rule added for transition effect and then
+   * notifies that the style has been applied.
+   */
   _onTransitionEnd: function()
   {
     if (--this._transitionRefCount == 0) {
       this.document.documentElement.classList.remove(TRANSITION_CLASS);
       this.rawSheet.deleteRule(this.rawSheet.cssRules.length - 1);
     }
 
     events.emit(this, "style-applied");
   }
 })
 
 /**
  * StyleSheetFront is the client-side counterpart to a StyleSheetActor.
  */
 var StyleSheetFront = protocol.FrontClass(StyleSheetActor, {
-  initialize: function(conn, form, ctx, detail) {
-    protocol.Front.prototype.initialize.call(this, conn, form, ctx, detail);
+  initialize: function(conn, form) {
+    protocol.Front.prototype.initialize.call(this, conn, form);
 
     this._onPropertyChange = this._onPropertyChange.bind(this);
     events.on(this, "property-change", this._onPropertyChange);
   },
 
   destroy: function() {
     events.off(this, "property-change", this._onPropertyChange);
 
@@ -770,17 +770,17 @@ let OriginalSourceActor = protocol.Actor
 
     this.text = null;
   },
 
   form: function() {
     return {
       actor: this.actorID, // actorID is set when it's added to a pool
       url: this.url,
-      parentSource: this.parentActor.actorID
+      relatedStyleSheet: this.parentActor.form()
     };
   },
 
   _getText: function() {
     if (this.text) {
       return promise.resolve(this.text);
     }
     return fetch(this.url, { window: this.window }).then(({content}) => {