Bug 700893 - API for tracking unsaved/saved state in source editor; r=rcampbell f=fayearthur
authorMihai Sucan <mihai.sucan@gmail.com>
Fri, 17 Feb 2012 19:11:17 +0200
changeset 87796 5b33f5c7e630231c8335b6aa817029d3da493c56
parent 87795 85e309ee6d341b38e5d670b3ecc2ef90fb720f7a
child 87797 0c37652c28aef965d84b98184dadad0052336f40
push id563
push usermihai.sucan@gmail.com
push dateMon, 27 Feb 2012 18:54:38 +0000
treeherderfx-team@0c37652c28ae [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrcampbell
bugs700893
milestone13.0a1
Bug 700893 - API for tracking unsaved/saved state in source editor; r=rcampbell f=fayearthur
browser/devtools/scratchpad/scratchpad.js
browser/devtools/scratchpad/test/browser_scratchpad_bug_653427_confirm_close.js
browser/devtools/scratchpad/test/browser_scratchpad_bug_669612_unsaved.js
browser/devtools/sourceeditor/source-editor-orion.jsm
browser/devtools/sourceeditor/source-editor.jsm
browser/devtools/sourceeditor/test/Makefile.in
browser/devtools/sourceeditor/test/browser_bug700893_dirty_state.js
--- a/browser/devtools/scratchpad/scratchpad.js
+++ b/browser/devtools/scratchpad/scratchpad.js
@@ -70,16 +70,18 @@ const DEVTOOLS_CHROME_ENABLED = "devtool
 const BUTTON_POSITION_SAVE = 0;
 const BUTTON_POSITION_CANCEL = 1;
 const BUTTON_POSITION_DONT_SAVE = 2;
 
 /**
  * The scratchpad object handles the Scratchpad window functionality.
  */
 var Scratchpad = {
+  _initialWindowTitle: document.title,
+
   /**
    * The script execution context. This tells Scratchpad in which context the
    * script shall execute.
    *
    * Possible values:
    *   - SCRATCHPAD_CONTEXT_CONTENT to execute code in the context of the current
    *   tab content window object.
    *   - SCRATCHPAD_CONTEXT_BROWSER to execute code in the context of the
@@ -146,50 +148,67 @@ var Scratchpad = {
   /**
    * Set the filename in the scratchpad UI and object
    *
    * @param string aFilename
    *        The new filename
    */
   setFilename: function SP_setFilename(aFilename)
   {
-    document.title = this.filename = aFilename;
+    this.filename = aFilename;
+    this._updateTitle();
+  },
+
+  /**
+   * Update the Scratchpad window title based on the current state.
+   * @private
+   */
+  _updateTitle: function SP__updateTitle()
+  {
+    if (this.filename) {
+      document.title = (this.editor && this.editor.dirty ? "*" : "") +
+                       this.filename;
+    } else {
+      document.title = this._initialWindowTitle;
+    }
   },
 
   /**
    * Get the current state of the scratchpad. Called by the
    * Scratchpad Manager for session storing.
    *
    * @return object
    *        An object with 3 properties: filename, text, and
    *        executionContext.
    */
   getState: function SP_getState()
   {
     return {
       filename: this.filename,
       text: this.getText(),
       executionContext: this.executionContext,
-      saved: this.saved
+      saved: !this.editor.dirty,
     };
   },
 
   /**
    * Set the filename and execution context using the given state. Called
    * when scratchpad is being restored from a previous session.
    *
    * @param object aState
    *        An object with filename and executionContext properties.
    */
   setState: function SP_getState(aState)
   {
     if (aState.filename) {
       this.setFilename(aState.filename);
     }
-    this.saved = aState.saved;
+    if (this.editor) {
+      this.editor.dirty = !aState.saved;
+    }
 
     if (aState.executionContext == SCRATCHPAD_CONTEXT_BROWSER) {
       this.setBrowserContext();
     }
     else {
       this.setContentContext();
     }
   },
@@ -633,17 +652,17 @@ var Scratchpad = {
   openFile: function SP_openFile()
   {
     let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
     fp.init(window, this.strings.GetStringFromName("openFile.title"),
             Ci.nsIFilePicker.modeOpen);
     fp.defaultString = "";
     if (fp.show() != Ci.nsIFilePicker.returnCancel) {
       this.setFilename(fp.file.path);
-      this.importFromFile(fp.file, false, this.onTextSaved.bind(this));
+      this.importFromFile(fp.file, false);
     }
   },
 
   /**
    * Save the textbox content to the currently open file.
    *
    * @param function aCallback
    *        Optional function you want to call when file is saved
@@ -653,17 +672,19 @@ var Scratchpad = {
     if (!this.filename) {
       return this.saveFileAs(aCallback);
     }
 
     let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
     file.initWithPath(this.filename);
 
     this.exportToFile(file, true, false, function(aStatus) {
-      this.onTextSaved();
+      if (Components.isSuccessCode(aStatus)) {
+        this.editor.dirty = false;
+      }
       if (aCallback) {
         aCallback(aStatus);
       }
     });
   },
 
   /**
    * Save the textbox content to a new file.
@@ -676,17 +697,19 @@ var Scratchpad = {
     let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
     fp.init(window, this.strings.GetStringFromName("saveFileAs"),
             Ci.nsIFilePicker.modeSave);
     fp.defaultString = "scratchpad.js";
     if (fp.show() != Ci.nsIFilePicker.returnCancel) {
       this.setFilename(fp.file.path);
 
       this.exportToFile(fp.file, true, false, function(aStatus) {
-        this.onTextSaved();
+        if (Components.isSuccessCode(aStatus)) {
+          this.editor.dirty = false;
+        }
         if (aCallback) {
           aCallback(aStatus);
         }
       });
     }
   },
 
   /**
@@ -778,67 +801,70 @@ var Scratchpad = {
    *
    * @param nsIDOMEvent aEvent
    */
   onLoad: function SP_onLoad(aEvent)
   {
     if (aEvent.target != document) {
       return;
     }
-
     let chrome = Services.prefs.getBoolPref(DEVTOOLS_CHROME_ENABLED);
     if (chrome) {
       let environmentMenu = document.getElementById("sp-environment-menu");
       let errorConsoleCommand = document.getElementById("sp-cmd-errorConsole");
       let chromeContextCommand = document.getElementById("sp-cmd-browserContext");
       environmentMenu.removeAttribute("hidden");
       chromeContextCommand.removeAttribute("disabled");
       errorConsoleCommand.removeAttribute("disabled");
     }
 
+    let state = null;
     let initialText = this.strings.GetStringFromName("scratchpadIntro");
     if ("arguments" in window &&
          window.arguments[0] instanceof Ci.nsIDialogParamBlock) {
-      let state = JSON.parse(window.arguments[0].GetString(0));
+      state = JSON.parse(window.arguments[0].GetString(0));
       this.setState(state);
       initialText = state.text;
     }
 
     this.editor = new SourceEditor();
 
     let config = {
       mode: SourceEditor.MODES.JAVASCRIPT,
       showLineNumbers: true,
       initialText: initialText,
     };
 
     let editorPlaceholder = document.getElementById("scratchpad-editor");
-    this.editor.init(editorPlaceholder, config, this.onEditorLoad.bind(this));
+    this.editor.init(editorPlaceholder, config,
+                     this._onEditorLoad.bind(this, state));
   },
 
   /**
    * The load event handler for the source editor. This method does post-load
    * editor initialization.
+   *
+   * @private
+   * @param object aState
+   *        The initial Scratchpad state object.
    */
-  onEditorLoad: function SP_onEditorLoad()
+  _onEditorLoad: function SP__onEditorLoad(aState)
   {
     this.editor.addEventListener(SourceEditor.EVENTS.CONTEXT_MENU,
                                  this.onContextMenu);
+    this.editor.addEventListener(SourceEditor.EVENTS.DIRTY_CHANGED,
+                                 this._onDirtyChanged);
     this.editor.focus();
     this.editor.setCaretOffset(this.editor.getCharCount());
+    if (aState) {
+      this.editor.dirty = !aState.saved;
+    }
 
     this.initialized = true;
 
-    if (this.filename && !this.saved) {
-      this.onTextChanged();
-    }
-    else if (this.filename && this.saved) {
-      this.onTextSaved();
-    }
-
     this._triggerObservers("Ready");
   },
 
   /**
    * Insert text at the current caret location.
    *
    * @param string aText
    *        The text you want to insert.
@@ -862,16 +888,30 @@ var Scratchpad = {
   {
     let menu = document.getElementById("scratchpad-text-popup");
     if (menu.state == "closed") {
       menu.openPopupAtScreen(aEvent.screenX, aEvent.screenY, true);
     }
   },
 
   /**
+   * The Source Editor DirtyChanged event handler. This function updates the
+   * Scratchpad window title to show an asterisk when there are unsaved changes.
+   *
+   * @private
+   * @see SourceEditor.EVENTS.DIRTY_CHANGED
+   * @param object aEvent
+   *        The DirtyChanged event object.
+   */
+  _onDirtyChanged: function SP__onDirtyChanged(aEvent)
+  {
+    Scratchpad._updateTitle();
+  },
+
+  /**
    * The popupshowing event handler for the Edit menu. This method updates the
    * enabled/disabled state of the Undo and Redo commands, based on the editor
    * state such that the menu items render correctly for the user when the menu
    * shows.
    */
   onEditPopupShowing: function SP_onEditPopupShowing()
   {
     goUpdateGlobalEditMenuItems();
@@ -895,123 +935,135 @@ var Scratchpad = {
    * Redo the previously undone action.
    */
   redo: function SP_redo()
   {
     this.editor.redo();
   },
 
   /**
-   * This method adds a listener to the editor for text changes. Called when
-   * a scratchpad is saved, opened from file, or restored from a saved file.
-   */
-  onTextSaved: function SP_onTextSaved(aStatus)
-  {
-    if (aStatus && !Components.isSuccessCode(aStatus)) {
-      return;
-    }
-    if (!document || !this.initialized) {
-      return;  // file saved to disk after window has closed
-    }
-    document.title = document.title.replace(/^\*/, "");
-    this.saved = true;
-    this.editor.addEventListener(SourceEditor.EVENTS.TEXT_CHANGED,
-                                 this.onTextChanged);
-  },
-
-  /**
-   * The scratchpad handler for editor text change events. This handler
-   * indicates that there are unsaved changes in the UI.
-   */
-  onTextChanged: function SP_onTextChanged()
-  {
-    document.title = "*" + document.title;
-    Scratchpad.saved = false;
-    Scratchpad.editor.removeEventListener(SourceEditor.EVENTS.TEXT_CHANGED,
-                                          Scratchpad.onTextChanged);
-  },
-
-  /**
    * The Scratchpad window unload event handler. This method unloads/destroys
    * the source editor.
    *
    * @param nsIDOMEvent aEvent
    */
   onUnload: function SP_onUnload(aEvent)
   {
     if (aEvent.target != document) {
       return;
     }
 
     this.resetContext();
+    this.editor.removeEventListener(SourceEditor.EVENTS.DIRTY_CHANGED,
+                                    this._onDirtyChanged);
     this.editor.removeEventListener(SourceEditor.EVENTS.CONTEXT_MENU,
                                     this.onContextMenu);
     this.editor.destroy();
     this.editor = null;
     this.initialized = false;
   },
 
   /**
    * Prompt to save scratchpad if it has unsaved changes.
    *
    * @param function aCallback
-   *        Optional function you want to call when file is saved
+   *        Optional function you want to call when file is saved. The callback
+   *        receives three arguments:
+   *          - toClose (boolean) - tells if the window should be closed.
+   *          - saved (boolen) - tells if the file has been saved.
+   *          - status (number) - the file save status result (if the file was
+   *          saved).
    * @return boolean
    *         Whether the window should be closed
    */
   promptSave: function SP_promptSave(aCallback)
   {
-    if (this.filename && !this.saved) {
+    if (this.filename && this.editor.dirty) {
       let ps = Services.prompt;
       let flags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_SAVE +
                   ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL +
                   ps.BUTTON_POS_2 * ps.BUTTON_TITLE_DONT_SAVE;
 
       let button = ps.confirmEx(window,
                           this.strings.GetStringFromName("confirmClose.title"),
                           this.strings.GetStringFromName("confirmClose"),
                           flags, null, null, null, null, {});
 
       if (button == BUTTON_POSITION_CANCEL) {
+        if (aCallback) {
+          aCallback(false, false);
+        }
         return false;
       }
+
       if (button == BUTTON_POSITION_SAVE) {
-        this.saveFile(aCallback);
+        this.saveFile(function(aStatus) {
+          if (aCallback) {
+            aCallback(true, true, aStatus);
+          }
+        });
+        return true;
       }
     }
+
+    if (aCallback) {
+      aCallback(true, false);
+    }
     return true;
   },
 
   /**
    * Handler for window close event. Prompts to save scratchpad if
    * there are unsaved changes.
    *
    * @param nsIDOMEvent aEvent
    */
   onClose: function SP_onClose(aEvent)
   {
-    let toClose = this.promptSave();
-    if (!toClose) {
-      aEvent.preventDefault();
+    if (this._skipClosePrompt) {
+      return;
     }
+
+    this.promptSave(function(aShouldClose, aSaved, aStatus) {
+      let shouldClose = aShouldClose;
+      if (aSaved && !Components.isSuccessCode(aStatus)) {
+        shouldClose = false;
+      }
+
+      if (shouldClose) {
+        this._skipClosePrompt = true;
+        window.close();
+      }
+    }.bind(this));
+    aEvent.preventDefault();
   },
 
   /**
    * Close the scratchpad window. Prompts before closing if the scratchpad
    * has unsaved changes.
    *
    * @param function aCallback
    *        Optional function you want to call when file is saved
    */
   close: function SP_close(aCallback)
   {
-    let toClose = this.promptSave(aCallback);
-    if (toClose) {
-      window.close();
-    }
+    this.promptSave(function(aShouldClose, aSaved, aStatus) {
+      let shouldClose = aShouldClose;
+      if (aSaved && !Components.isSuccessCode(aStatus)) {
+        shouldClose = false;
+      }
+
+      if (shouldClose) {
+        this._skipClosePrompt = true;
+        window.close();
+      }
+      if (aCallback) {
+        aCallback();
+      }
+    }.bind(this));
   },
 
   _observers: [],
 
   /**
    * Add an observer for Scratchpad events.
    *
    * The observer implements IScratchpadObserver := {
--- a/browser/devtools/scratchpad/test/browser_scratchpad_bug_653427_confirm_close.js
+++ b/browser/devtools/scratchpad/test/browser_scratchpad_bug_653427_confirm_close.js
@@ -41,94 +41,92 @@ function test()
   testSavedFile();
 
   content.location = "data:text/html,<p>test scratchpad save file prompt on closing";
 }
 
 function testNew()
 {
   openScratchpad(function(win) {
-    win.Scratchpad.close();
-    ok(win.closed, "new scratchpad window should close without prompting")
-    done();
+    win.Scratchpad.close(function() {
+      ok(win.closed, "new scratchpad window should close without prompting")
+      done();
+    });
   }, {noFocus: true});
 }
 
 function testSavedFile()
 {
   openScratchpad(function(win) {
     win.Scratchpad.filename = "test.js";
-    win.Scratchpad.saved = true;
-    win.Scratchpad.close();
-
-    ok(win.closed, "scratchpad from file with no changes should close")
-    done();
+    win.Scratchpad.editor.dirty = false;
+    win.Scratchpad.close(function() {
+      ok(win.closed, "scratchpad from file with no changes should close")
+      done();
+    });
   }, {noFocus: true});
 }
 
 function testUnsaved()
 {
   testUnsavedFileCancel();
   testUnsavedFileSave();
   testUnsavedFileDontSave();
 }
 
 function testUnsavedFileCancel()
 {
   openScratchpad(function(win) {
-    win.Scratchpad.filename = "test.js";
-    win.Scratchpad.saved = false;
+    win.Scratchpad.setFilename("test.js");
+    win.Scratchpad.editor.dirty = true;
 
     promptButton = win.BUTTON_POSITION_CANCEL;
 
-    win.Scratchpad.close();
-
-    ok(!win.closed, "cancelling dialog shouldn't close scratchpad");
-
-    win.close();
-    done();
+    win.Scratchpad.close(function() {
+      ok(!win.closed, "cancelling dialog shouldn't close scratchpad");
+      win.close();
+      done();
+    });
   }, {noFocus: true});
 }
 
 function testUnsavedFileSave()
 {
   openScratchpad(function(win) {
     win.Scratchpad.importFromFile(gFile, true, function(status, content) {
-      win.Scratchpad.filename = gFile.path;
-      win.Scratchpad.onTextSaved();
+      win.Scratchpad.setFilename(gFile.path);
 
       let text = "new text";
       win.Scratchpad.setText(text);
 
       promptButton = win.BUTTON_POSITION_SAVE;
 
       win.Scratchpad.close(function() {
+        ok(win.closed, 'pressing "Save" in dialog should close scratchpad');
         readFile(gFile, function(savedContent) {
           is(savedContent, text, 'prompted "Save" worked when closing scratchpad');
           done();
         });
       });
-
-      ok(win.closed, 'pressing "Save" in dialog should close scratchpad');
     });
   }, {noFocus: true});
 }
 
 function testUnsavedFileDontSave()
 {
   openScratchpad(function(win) {
-    win.Scratchpad.filename = gFile.path;
-    win.Scratchpad.saved = false;
+    win.Scratchpad.setFilename(gFile.path);
+    win.Scratchpad.editor.dirty = true;
 
     promptButton = win.BUTTON_POSITION_DONT_SAVE;
 
-    win.Scratchpad.close();
-
-    ok(win.closed, 'pressing "Don\'t Save" in dialog should close scratchpad');
-    done();
+    win.Scratchpad.close(function() {
+      ok(win.closed, 'pressing "Don\'t Save" in dialog should close scratchpad');
+      done();
+    });
   }, {noFocus: true});
 }
 
 function cleanup()
 {
   Services.prompt = oldPrompt;
   gFile.remove(false);
   gFile = null;
--- a/browser/devtools/scratchpad/test/browser_scratchpad_bug_669612_unsaved.js
+++ b/browser/devtools/scratchpad/test/browser_scratchpad_bug_669612_unsaved.js
@@ -1,74 +1,67 @@
 /* vim: set ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */  
 
 // only finish() when correct number of tests are done
-const expected = 5;
+const expected = 4;
 var count = 0;
 function done()
 {
   if (++count == expected) {
     finish();
   }
 }
 
 var ScratchpadManager = Scratchpad.ScratchpadManager;
 
 
 function test()
 {
   waitForExplicitFinish();
   
   testListeners();
-  testErrorStatus();
   testRestoreNotFromFile();
   testRestoreFromFileSaved();
   testRestoreFromFileUnsaved();
 
   content.location = "data:text/html,<p>test star* UI for unsaved file changes";
 }
 
 function testListeners()
 {
   openScratchpad(function(aWin, aScratchpad) {
     aScratchpad.setText("new text");
     ok(!isStar(aWin), "no star if scratchpad isn't from a file");
 
-    aScratchpad.onTextSaved();
+    aScratchpad.editor.dirty = false;
     ok(!isStar(aWin), "no star before changing text");
 
+    aScratchpad.setFilename("foo.js");
     aScratchpad.setText("new text2");
     ok(isStar(aWin), "shows star if scratchpad text changes");
 
-    aScratchpad.onTextSaved();
+    aScratchpad.editor.dirty = false;
     ok(!isStar(aWin), "no star if scratchpad was just saved");
 
+    aScratchpad.setText("new text3");
+    ok(isStar(aWin), "shows star if scratchpad has more changes");
+
     aScratchpad.undo();
-    ok(isStar(aWin), "star if scratchpad undo");
+    ok(!isStar(aWin), "no star if scratchpad undo to save point");
+
+    aScratchpad.undo();
+    ok(isStar(aWin), "star if scratchpad undo past save point");
 
     aWin.close();
     done();
   }, {noFocus: true});
 }
 
-function testErrorStatus()
-{
-  openScratchpad(function(aWin, aScratchpad) {
-    aScratchpad.onTextSaved(Components.results.NS_ERROR_FAILURE);
-    aScratchpad.setText("new text");
-    ok(!isStar(aWin), "no star if file save failed");
-
-    aWin.close();
-    done();
-  }, {noFocus: true});
-}
-
-
 function testRestoreNotFromFile()
 {
   let session = [{
     text: "test1",
     executionContext: 1
   }];
 
   let [win] = ScratchpadManager.restoreSession(session);
--- a/browser/devtools/sourceeditor/source-editor-orion.jsm
+++ b/browser/devtools/sourceeditor/source-editor-orion.jsm
@@ -141,16 +141,17 @@ function SourceEditor() {
   // Update the SourceEditor defaults from user preferences.
 
   SourceEditor.DEFAULTS.tabSize =
     Services.prefs.getIntPref(SourceEditor.PREFS.TAB_SIZE);
   SourceEditor.DEFAULTS.expandTab =
     Services.prefs.getBoolPref(SourceEditor.PREFS.EXPAND_TAB);
 
   this._onOrionSelection = this._onOrionSelection.bind(this);
+  this._onTextChanged = this._onTextChanged.bind(this);
 
   this._eventTarget = {};
   this._eventListenersQueue = [];
   this.ui = new SourceEditorUI(this);
 }
 
 SourceEditor.prototype = {
   _view: null,
@@ -167,16 +168,17 @@ SourceEditor.prototype = {
   _currentLineAnnotation: null,
   _primarySelectionTimeout: null,
   _mode: null,
   _expandTab: null,
   _tabSize: null,
   _iframeWindow: null,
   _eventTarget: null,
   _eventListenersQueue: null,
+  _dirty: false,
 
   /**
    * The Source Editor user interface manager.
    * @type object
    *       An instance of the SourceEditorUI.
    */
   ui: null,
 
@@ -274,18 +276,21 @@ SourceEditor.prototype = {
 
     let onOrionLoad = function() {
       this._view.removeEventListener("Load", onOrionLoad);
       this._onOrionLoad();
     }.bind(this);
 
     this._view.addEventListener("Load", onOrionLoad);
     if (config.highlightCurrentLine || Services.appinfo.OS == "Linux") {
-      this._view.addEventListener("Selection", this._onOrionSelection);
+      this.addEventListener(SourceEditor.EVENTS.SELECTION,
+                            this._onOrionSelection);
     }
+    this.addEventListener(SourceEditor.EVENTS.TEXT_CHANGED,
+                           this._onTextChanged);
 
     let KeyBinding = window.require("orion/textview/keyBinding").KeyBinding;
     let TextDND = window.require("orion/textview/textDND").TextDND;
     let Rulers = window.require("orion/textview/rulers");
     let LineNumberRuler = Rulers.LineNumberRuler;
     let AnnotationRuler = Rulers.AnnotationRuler;
     let OverviewRuler = Rulers.OverviewRuler;
     let UndoStack = window.require("orion/textview/undoStack").UndoStack;
@@ -583,16 +588,38 @@ SourceEditor.prototype = {
       }
       this._primarySelectionTimeout =
         window.setTimeout(this._updatePrimarySelection.bind(this),
                           PRIMARY_SELECTION_DELAY);
     }
   },
 
   /**
+   * The TextChanged event handler which tracks the dirty state of the editor.
+   *
+   * @see SourceEditor.EVENTS.TEXT_CHANGED
+   * @see SourceEditor.EVENTS.DIRTY_CHANGED
+   * @see SourceEditor.dirty
+   * @private
+   */
+  _onTextChanged: function SE__onTextChanged()
+  {
+    this._updateDirty();
+  },
+
+  /**
+   * Update the dirty state of the editor based on the undo stack.
+   * @private
+   */
+  _updateDirty: function SE__updateDirty()
+  {
+    this.dirty = !this._undoStack.isClean();
+  },
+
+  /**
    * Update the X11 PRIMARY buffer to hold the current selection.
    * @private
    */
   _updatePrimarySelection: function SE__updatePrimarySelection()
   {
     this._primarySelectionTimeout = null;
 
     let text = this.getSelectedText();
@@ -898,21 +925,63 @@ SourceEditor.prototype = {
    *         True if there are changes that can be repeated, false otherwise.
    */
   canRedo: function SE_canRedo()
   {
     return this._undoStack.canRedo();
   },
 
   /**
-   * Reset the Undo stack
+   * Reset the Undo stack.
    */
   resetUndo: function SE_resetUndo()
   {
     this._undoStack.reset();
+    this._updateDirty();
+  },
+
+  /**
+   * Set the "dirty" state of the editor. Set this to false when you save the
+   * text being edited. The dirty state will become true once the user makes
+   * changes to the text.
+   *
+   * @param boolean aNewValue
+   *        The new dirty state: true if the text is not saved, false if you
+   *        just saved the text.
+   */
+  set dirty(aNewValue)
+  {
+    if (aNewValue == this._dirty) {
+      return;
+    }
+
+    let event = {
+      type: SourceEditor.EVENTS.DIRTY_CHANGED,
+      oldValue: this._dirty,
+      newValue: aNewValue,
+    };
+
+    this._dirty = aNewValue;
+    if (!this._dirty && !this._undoStack.isClean()) {
+      this._undoStack.markClean();
+    }
+    this._dispatchEvent(event);
+  },
+
+  /**
+   * Get the editor "dirty" state. This tells if the text is considered saved or
+   * not.
+   *
+   * @see SourceEditor.EVENTS.DIRTY_CHANGED
+   * @return boolean
+   *         True if there are changes which are not saved, false otherwise.
+   */
+  get dirty()
+  {
+    return this._dirty;
   },
 
   /**
    * Start a compound change in the editor. Compound changes are grouped into
    * only one change that you can undo later, after you invoke
    * endCompoundChange().
    */
   startCompoundChange: function SE_startCompoundChange()
@@ -1321,20 +1390,25 @@ SourceEditor.prototype = {
   },
 
   /**
    * Destroy/uninitialize the editor.
    */
   destroy: function SE_destroy()
   {
     if (this._config.highlightCurrentLine || Services.appinfo.OS == "Linux") {
-      this._view.removeEventListener("Selection", this._onOrionSelection);
+      this.removeEventListener(SourceEditor.EVENTS.SELECTION,
+                               this._onOrionSelection);
     }
     this._onOrionSelection = null;
 
+    this.removeEventListener(SourceEditor.EVENTS.TEXT_CHANGED,
+                             this._onTextChanged);
+    this._onTextChanged = null;
+
     if (this._primarySelectionTimeout) {
       let window = this.parentElement.ownerDocument.defaultView;
       window.clearTimeout(this._primarySelectionTimeout);
       this._primarySelectionTimeout = null;
     }
 
     this._view.destroy();
     this.ui.destroy();
--- a/browser/devtools/sourceeditor/source-editor.jsm
+++ b/browser/devtools/sourceeditor/source-editor.jsm
@@ -277,16 +277,25 @@ SourceEditor.EVENTS = {
    * a breakpoint is removed - either through API use or through the editor UI.
    * Event object properties:
    *   - added - array that holds the new breakpoints.
    *   - removed - array that holds the breakpoints that have been removed.
    * Each object in the added/removed arrays holds two properties: line and
    * condition.
    */
   BREAKPOINT_CHANGE: "BreakpointChange",
+
+  /**
+   * The DirtyChanged event is fired when the dirty state of the editor is
+   * changed. The dirty state of the editor tells if the are text changes that
+   * have not been saved yet. Event object properties: oldValue and newValue.
+   * Both are booleans telling the old dirty state and the new state,
+   * respectively.
+   */
+  DIRTY_CHANGED: "DirtyChanged",
 };
 
 /**
  * Extend a destination object with properties from a source object.
  *
  * @param object aDestination
  * @param object aSource
  */
--- a/browser/devtools/sourceeditor/test/Makefile.in
+++ b/browser/devtools/sourceeditor/test/Makefile.in
@@ -53,12 +53,13 @@ include $(topsrcdir)/config/rules.mk
 		browser_bug684546_reset_undo.js \
 		browser_bug695035_middle_click_paste.js \
 		browser_bug687160_line_api.js \
 		browser_bug650345_find.js \
 		browser_bug703692_focus_blur.js \
 		browser_bug725388_mouse_events.js \
 		browser_bug707987_debugger_breakpoints.js \
 		browser_bug712982_line_ruler_click.js \
+		browser_bug700893_dirty_state.js \
 		head.js \
 
 libs:: $(_BROWSER_TEST_FILES)
 	$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/browser/$(relativesrcdir)
new file mode 100644
--- /dev/null
+++ b/browser/devtools/sourceeditor/test/browser_bug700893_dirty_state.js
@@ -0,0 +1,94 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function test() {
+
+  let temp = {};
+  Cu.import("resource:///modules/source-editor.jsm", temp);
+  let SourceEditor = temp.SourceEditor;
+
+  let component = Services.prefs.getCharPref(SourceEditor.PREFS.COMPONENT);
+  if (component == "textarea") {
+    ok(true, "skip test for bug 700893: only applicable for non-textarea components");
+    return;
+  }
+
+  waitForExplicitFinish();
+
+  let editor;
+
+  const windowUrl = "data:text/xml,<?xml version='1.0'?>" +
+    "<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'" +
+    " title='test for bug 700893' width='600' height='500'><hbox flex='1'/></window>";
+  const windowFeatures = "chrome,titlebar,toolbar,centerscreen,resizable,dialog=no";
+
+  let testWin = Services.ww.openWindow(null, windowUrl, "_blank", windowFeatures, null);
+  testWin.addEventListener("load", function onWindowLoad() {
+    testWin.removeEventListener("load", onWindowLoad, false);
+    waitForFocus(initEditor, testWin);
+  }, false);
+
+  function initEditor()
+  {
+    let hbox = testWin.document.querySelector("hbox");
+    editor = new SourceEditor();
+    editor.init(hbox, {initialText: "foobar"}, editorLoaded);
+  }
+
+  function editorLoaded()
+  {
+    editor.focus();
+
+    is(editor.dirty, false, "editory is not dirty");
+
+    let event = null;
+    let eventHandler = function(aEvent) {
+      event = aEvent;
+    };
+    editor.addEventListener(SourceEditor.EVENTS.DIRTY_CHANGED, eventHandler);
+
+    editor.setText("omg");
+
+    is(editor.dirty, true, "editor is dirty");
+    ok(event, "DirtyChanged event fired")
+    is(event.oldValue, false, "event.oldValue is correct");
+    is(event.newValue, true, "event.newValue is correct");
+
+    event = null;
+    editor.setText("foo 2");
+    ok(!event, "no DirtyChanged event fired");
+
+    editor.dirty = false;
+
+    is(editor.dirty, false, "editor marked as clean");
+    ok(event, "DirtyChanged event fired")
+    is(event.oldValue, true, "event.oldValue is correct");
+    is(event.newValue, false, "event.newValue is correct");
+
+    event = null;
+    editor.setText("foo 3");
+
+    is(editor.dirty, true, "editor is dirty after changes");
+    ok(event, "DirtyChanged event fired")
+    is(event.oldValue, false, "event.oldValue is correct");
+    is(event.newValue, true, "event.newValue is correct");
+
+    editor.undo();
+    is(editor.dirty, false, "editor is not dirty after undo");
+    ok(event, "DirtyChanged event fired")
+    is(event.oldValue, true, "event.oldValue is correct");
+    is(event.newValue, false, "event.newValue is correct");
+
+    editor.removeEventListener(SourceEditor.EVENTS.DIRTY_CHANGED, eventHandler);
+
+    editor.destroy();
+
+    testWin.close();
+    testWin = editor = null;
+
+    waitForFocus(finish, window);
+  }
+}