Bug 950915 - Watch for changes to CSS files on disk for source mapped files; r=dcamp

This commit is contained in:
Heather Arthur 2014-02-01 12:26:53 -08:00
parent fb7f12c413
commit cf4c251429
9 changed files with 468 additions and 44 deletions

View File

@ -14,6 +14,7 @@ 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");
@ -227,6 +228,7 @@ StyleEditorUI.prototype = {
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);
});
@ -250,6 +252,8 @@ StyleEditorUI.prototype = {
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);
@ -557,9 +561,13 @@ StyleEditorUI.prototype = {
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 = [];
@ -569,15 +577,22 @@ StyleEditorUI.prototype = {
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() {

View File

@ -34,6 +34,13 @@ 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.
@ -59,28 +66,18 @@ function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
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;
@ -91,11 +88,20 @@ function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
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 = {
@ -114,6 +120,16 @@ StyleSheetEditor.prototype = {
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.
*
@ -145,6 +161,48 @@ StyleSheetEditor.prototype = {
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.
*/
@ -196,7 +254,7 @@ StyleSheetEditor.prototype = {
value: this._state.text,
lineNumbers: true,
mode: Editor.modes.css,
readOnly: this._state.readOnly,
readOnly: false,
autoCloseBrackets: "{}()[]",
extraKeys: this._getKeyBindings(),
contextMenu: "sourceEditorContextMenu"
@ -212,9 +270,11 @@ StyleSheetEditor.prototype = {
this.saveToFile();
});
sourceEditor.on("change", () => {
this.updateStyleSheet();
});
if (this.styleSheet.update) {
sourceEditor.on("change", () => {
this.updateStyleSheet();
});
}
this.sourceEditor = sourceEditor;
@ -255,8 +315,8 @@ StyleSheetEditor.prototype = {
* Focus the Style Editor input.
*/
focus: function() {
if (this._sourceEditor) {
this._sourceEditor.focus();
if (this.sourceEditor) {
this.sourceEditor.focus();
} else {
this._focusOnSourceEditorReady = true;
}
@ -266,8 +326,8 @@ StyleSheetEditor.prototype = {
* 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();
},
@ -343,8 +403,8 @@ StyleSheetEditor.prototype = {
return;
}
if (this._sourceEditor) {
this._state.text = this._sourceEditor.getText();
if (this.sourceEditor) {
this._state.text = this.sourceEditor.getText();
}
let ostream = FileUtils.openSafeFileOutputStream(returnFile);
@ -362,16 +422,12 @@ StyleSheetEditor.prototype = {
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));
};
@ -381,7 +437,81 @@ StyleSheetEditor.prototype = {
}
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.
@ -482,3 +612,73 @@ function prettifyCSS(text)
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;
}

View File

@ -136,7 +136,6 @@ StyleEditorPanel.prototype = {
this._toolbox = null;
this._panelDoc = null;
this._debuggee.destroy();
this.UI.destroy();
}

View File

@ -48,6 +48,22 @@ 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;

View File

@ -111,8 +111,8 @@
<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;"

View File

@ -49,3 +49,4 @@ skip-if = true
[browser_styleeditor_sv_resize.js]
[browser_styleeditor_selectstylesheet.js]
[browser_styleeditor_sourcemaps.js]
[browser_styleeditor_sourcemap_watching.js]

View File

@ -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;
}

View File

@ -18,6 +18,7 @@
}
.theme-dark .stylesheet-rule-count,
.theme-dark .stylesheet-linked-file,
.theme-dark .stylesheet-saveButton {
color: #b6babf;
}
@ -28,6 +29,7 @@
}
.theme-light .stylesheet-rule-count,
.theme-light .stylesheet-linked-file,
.theme-light .stylesheet-saveButton {
color: #18191a;
}
@ -40,6 +42,7 @@
.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;
}
@ -85,8 +88,16 @@
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 {

View File

@ -547,7 +547,7 @@ let StyleSheetActor = protocol.ActorClass({
*/
_setSourceMapRoot: function(aSourceMap, aAbsSourceMapURL, aScriptURL) {
const base = dirname(
aAbsSourceMapURL.indexOf("data:") === 0
aAbsSourceMapURL.startsWith("data:")
? aScriptURL
: aAbsSourceMapURL);
aSourceMap.sourceRoot = aSourceMap.sourceRoot
@ -700,9 +700,9 @@ let StyleSheetActor = protocol.ActorClass({
},
/**
* 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) {
@ -718,8 +718,8 @@ let StyleSheetActor = protocol.ActorClass({
* 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);
@ -775,7 +775,7 @@ let OriginalSourceActor = protocol.ActorClass({
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()
};
},