Bug 1476366 - (Part 2) Track and serialize style changes; Setup Redux store + React-based changes panel. draft
authorRazvan Caliman <rcaliman@mozilla.com>
Thu, 19 Jul 2018 17:41:12 +0200
changeset 820440 819da2685fd175f34bbb3fad56a582d7ba628055
parent 820439 b668f51fbfc6b391a84438634ef932e55a2c8bdc
push id116828
push userbmo:rcaliman@mozilla.com
push dateThu, 19 Jul 2018 15:42:36 +0000
bugs1476366
milestone63.0a1
Bug 1476366 - (Part 2) Track and serialize style changes; Setup Redux store + React-based changes panel. MozReview-Commit-ID: E7i04wHCogr
devtools/client/inspector/changes/actions/changes.js
devtools/client/inspector/changes/actions/index.js
devtools/client/inspector/changes/actions/moz.build
devtools/client/inspector/changes/change-manager.js
devtools/client/inspector/changes/changes.js
devtools/client/inspector/changes/components/ChangesApp.js
devtools/client/inspector/changes/components/moz.build
devtools/client/inspector/changes/moz.build
devtools/client/inspector/changes/reducers/changes.js
devtools/client/inspector/changes/reducers/moz.build
devtools/client/inspector/index.xhtml
devtools/client/inspector/inspector.js
devtools/client/inspector/moz.build
devtools/client/inspector/reducers.js
devtools/client/inspector/rules/views/rule-editor.js
devtools/client/jar.mn
devtools/client/themes/changes.css
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/actions/changes.js
@@ -0,0 +1,27 @@
+/* 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 {
+  RESET_CHANGES,
+  TRACK_CHANGE,
+} = require("./index");
+
+module.exports = {
+
+  resetChanges() {
+    return {
+      type: RESET_CHANGES,
+    };
+  },
+
+  trackChange(data) {
+    return {
+      type: TRACK_CHANGE,
+      data,
+    };
+  },
+
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/actions/index.js
@@ -0,0 +1,17 @@
+/* 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 { createEnum } = require("devtools/client/shared/enum");
+
+createEnum([
+
+  // Remove all changes
+  "RESET_CHANGES",
+
+  // Track a style change
+  "TRACK_CHANGE",
+
+], module.exports);
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/actions/moz.build
@@ -0,0 +1,10 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+    'changes.js',
+    'index.js',
+)
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/change-manager.js
@@ -0,0 +1,72 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 {
+  trackChange,
+} = require("./actions/changes");
+
+class ChangeManager {
+  constructor(inspector) {
+    this.inspector = inspector;
+    this.pageStyle = this.inspector.pageStyle;
+    this.store = this.inspector.store;
+  }
+
+  async track(change) {
+    const defaults = { rule: null, add: null, remove: null };
+    change = { ...defaults, ...change };
+    const { rule, add, remove } = change;
+
+    if (!rule) {
+      console.warn(`Missing rule for change: ${change}`);
+      return;
+    }
+
+    // Do not track partial property additions.
+    if (add && add.property && !add.value) {
+      return;
+    }
+
+    // Do not track partial property removals.
+    // TODO: figure out whole property removal; Property removal happens in two actions.
+    if (remove && remove.property && !remove.value) {
+      return;
+    }
+
+    // Source is the stylesheet URL if defined. Otherwise, assume it's an inline <style>
+    let href = (rule.sheet && rule.sheet.href) ? rule.sheet.href : "inline stylesheet";
+    let selector = rule.selectorText;
+    const tag = this.inspector.selection.nodeFront.tagName;
+
+    // If the rule is actually and inline style, correct previous assumptions.
+    if (rule.isInlineStyle) {
+      href = "inline";
+      selector = await this.inspector.selection.nodeFront.getUniqueSelector();
+    }
+
+    const data = {
+      href,
+      selector,
+      add,
+      remove,
+      tag,
+    };
+
+    this.store.dispatch(trackChange(data));
+  }
+
+  undo() {
+
+  }
+
+  redo() {
+
+  }
+}
+
+module.exports = ChangeManager;
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/changes.js
@@ -0,0 +1,69 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 { createFactory, createElement } = require("devtools/client/shared/vendor/react");
+const { Provider } = require("devtools/client/shared/vendor/react-redux");
+
+const ChangesApp = createFactory(require("./components/ChangesApp"));
+
+const {
+  resetChanges,
+} = require("./actions/changes");
+
+class ChangesView {
+  constructor(inspector) {
+    this.inspector = inspector;
+    this.store = this.inspector.store;
+
+    this.update = this.update.bind(this);
+    this.destroy = this.destroy.bind(this);
+
+    this.init();
+  }
+
+  init() {
+    const changeApp = ChangesApp({});
+
+    // Expose the provider to let inspector.js use it in setupSidebar.
+    this.provider = createElement(Provider, {
+      id: "changesview",
+      key: "changesview",
+      store: this.store,
+      title: "Changes",
+    }, changeApp);
+
+    this.inspector.sidebar.on("changesview-selected", this.update);
+    // TODO: save store and restore/replay on refresh.
+    this.inspector.target.once("will-navigate", this.destroy);
+  }
+
+  /**
+   * Destruction function called when the inspector is destroyed. Removes event listeners
+   * and cleans up references.
+   */
+  destroy() {
+    this.store.dispatch(resetChanges());
+    this.inspector.sidebar.off("changesview-selected", this.update);
+    this.inspector = null;
+    this.store = null;
+  }
+  /**
+   * @return {Boolean}
+   */
+  isPanelVisible() {
+    return this.inspector &&
+           this.inspector.sidebar &&
+           this.inspector.sidebar.getCurrentTabID() === "changesview";
+  }
+
+  update() {
+
+  }
+}
+
+module.exports = ChangesView;
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/components/ChangesApp.js
@@ -0,0 +1,76 @@
+/* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+
+class ChangesApp extends PureComponent {
+  static get propTypes() {
+    return {
+      // TODO define object shape in types.js
+      changes: PropTypes.object.isRequired
+    };
+  }
+
+  renderMutations(remove = {}, add = {}) {
+    const removals = Object.keys(remove).map(key => {
+      return dom.div(
+        { className: "line diff-remove"},
+        `${key}: ${remove[key]};`
+      );
+    });
+
+    const additions = Object.keys(add).map(key => {
+      return dom.div(
+        { className: "line diff-add"},
+        `${key}: ${add[key]};`
+      );
+    });
+
+    return [removals, additions];
+  }
+
+  renderSelectors(selectors = {}) {
+    return Object.keys(selectors).map(sel => {
+      return dom.details(
+        { className: "selector", open: true },
+        dom.summary({}, sel),
+        this.renderMutations(selectors[sel].remove, selectors[sel].add)
+      );
+    });
+  }
+
+  renderDiff(diff = {}) {
+    // Render groups of style sources: stylesheets, embedded styles and inline styles
+    return Object.keys(diff).map(href => {
+      return dom.details(
+        { className: "source", open: true },
+        dom.summary({}, href),
+        // Render groups of selectors
+        this.renderSelectors(diff[href])
+      );
+    });
+  }
+
+  render() {
+    return dom.div(
+      {
+        className: "theme-sidebar inspector-tabpanel",
+        id: "sidebar-panel-changes"
+      },
+      dom.div(
+        {
+          id: "diff-container"
+        },
+        this.renderDiff(this.props.changes.diff)
+      )
+    );
+  }
+}
+
+module.exports = connect(state => state)(ChangesApp);
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/components/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+    'ChangesApp.js',
+)
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/moz.build
@@ -0,0 +1,16 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+    'actions',
+    'components',
+    'reducers',
+]
+
+DevToolsModules(
+    'change-manager.js',
+    'changes.js',
+)
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/reducers/changes.js
@@ -0,0 +1,102 @@
+/* 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 {
+  RESET_CHANGES,
+  TRACK_CHANGE,
+} = require("../actions/index");
+
+const INITIAL_STATE = {
+  /**
+   * Diff of changes groupped by stylesheet href, then by selector, then into add/remove
+   * objects with CSS property names and values for corresponding changes.
+   *
+   * Structure:
+   *
+   *   diff = {
+   *    "href": {
+   *      "selector": {
+   *        add: {
+   *          "property": value
+   *          ... // more properties
+   *        },
+   *        remove: {
+   *          "property": value
+   *          ...
+   *        }
+   *      },
+   *      ... // more selectors
+   *    }
+   *    ... // more stylesheet hrefs
+   *   }
+   */
+  diff: {},
+  // Operations for undo/redo stack
+  operations: [],
+  // Current index in the undo/redo stack
+  operationIndex: 0
+};
+
+/**
+ * Mutate the given diff object with data about a new change.
+ *
+ * @param  {Object} d
+ *         Diff object from the store.
+ * @param  {Object} c
+ *         Data about the change: which property was added or removed, on which selector,
+ *         in which stylesheet or whether the source is an element's inline style.
+ * @return {Object}
+ *         Mutated diff object.
+ */
+function updateDiff(d = {}, c = {}) {
+  // Ensure expected diff structure exists
+  d[c.href] = d[c.href] || {};
+  d[c.href][c.selector] = d[c.href][c.selector] || {};
+  d[c.href][c.selector].add = d[c.href][c.selector].add || {};
+  d[c.href][c.selector].remove = d[c.href][c.selector].remove || {};
+
+  // If the property to remove was previously added by a change, skip removing it.
+  // Doing so makes repeated changes of the same property coalesce into a single change
+  // when shwon in the UI.
+  if (c.remove && c.remove.property && !d[c.href][c.selector].add[c.remove.property]) {
+    d[c.href][c.selector].remove[c.remove.property] = c.remove.value;
+  }
+
+  // Set or overwrite any existing property value
+  if (c.add && c.add.property) {
+    d[c.href][c.selector].add[c.add.property] = c.add.value;
+  }
+
+  d[c.href][c.selector].tag = c.tag;
+
+  return d;
+}
+
+const reducers = {
+
+  [TRACK_CHANGE](state, { data }) {
+    // Shallow clone of state.
+    const newState = { ...state };
+    // Deep clone of previous diff object.
+    const newDiff = JSON.parse(JSON.stringify(state.diff));
+    // Update the diff with information about this change
+    newState.diff = updateDiff(newDiff, data);
+    return newState;
+  },
+
+  [RESET_CHANGES](state) {
+    return { ...INITIAL_STATE };
+  },
+
+};
+
+module.exports = function(state = INITIAL_STATE, action) {
+  const reducer = reducers[action.type];
+  if (!reducer) {
+    return state;
+  }
+  return reducer(state, action);
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/reducers/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+    'changes.js',
+)
--- a/devtools/client/inspector/index.xhtml
+++ b/devtools/client/inspector/index.xhtml
@@ -7,16 +7,17 @@
 <html xmlns="http://www.w3.org/1999/xhtml" dir="">
 <head>
   <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
 
   <link rel="stylesheet" href="chrome://devtools/skin/breadcrumbs.css"/>
   <link rel="stylesheet" href="chrome://devtools/skin/inspector.css"/>
   <link rel="stylesheet" href="chrome://devtools/skin/rules.css"/>
   <link rel="stylesheet" href="chrome://devtools/skin/computed.css"/>
+  <link rel="stylesheet" href="chrome://devtools/skin/changes.css"/>
   <link rel="stylesheet" href="chrome://devtools/skin/fonts.css"/>
   <link rel="stylesheet" href="chrome://devtools/skin/boxmodel.css"/>
   <link rel="stylesheet" href="chrome://devtools/skin/layout.css"/>
   <link rel="stylesheet" href="chrome://devtools/skin/animation.css"/>
   <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/Tabs.css"/>
   <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/TabBar.css"/>
   <link rel="stylesheet" href="resource://devtools/client/shared/components/SidebarToggle.css"/>
   <link rel="stylesheet" href="resource://devtools/client/inspector/components/InspectorTabPanel.css"/>
--- a/devtools/client/inspector/inspector.js
+++ b/devtools/client/inspector/inspector.js
@@ -11,16 +11,17 @@
 const Services = require("Services");
 const promise = require("promise");
 const EventEmitter = require("devtools/shared/event-emitter");
 const {executeSoon} = require("devtools/shared/DevToolsUtils");
 const {Toolbox} = require("devtools/client/framework/toolbox");
 const ReflowTracker = require("devtools/client/inspector/shared/reflow-tracker");
 const Store = require("devtools/client/inspector/store");
 const InspectorStyleChangeTracker = require("devtools/client/inspector/shared/style-change-tracker");
+const ChangeManager = require("devtools/client/inspector/changes/change-manager");
 
 // Use privileged promise in panel documents to prevent having them to freeze
 // during toolbox destruction. See bug 1402779.
 const Promise = require("Promise");
 
 loader.lazyRequireGetter(this, "initCssProperties", "devtools/shared/fronts/css-properties", true);
 loader.lazyRequireGetter(this, "HTMLBreadcrumbs", "devtools/client/inspector/breadcrumbs", true);
 loader.lazyRequireGetter(this, "ThreePaneOnboardingTooltip", "devtools/client/inspector/shared/three-pane-onboarding-tooltip");
@@ -116,16 +117,17 @@ function Inspector(toolbox) {
   this.store = Store();
 
   // Map [panel id => panel instance]
   // Stores all the instances of sidebar panels like rule view, computed view, ...
   this._panels = new Map();
 
   this.reflowTracker = new ReflowTracker(this._target);
   this.styleChangeTracker = new InspectorStyleChangeTracker(this);
+  this.changeManager = new ChangeManager(this);
 
   // Store the URL of the target page prior to navigation in order to ensure
   // telemetry counts in the Grid Inspector are not double counted on reload.
   this.previousURL = this.target.url;
 
   this.is3PaneModeFirstRun = Services.prefs.getBoolPref(THREE_PANE_FIRST_RUN_PREF);
   this.show3PaneTooltip = Services.prefs.getBoolPref(SHOW_THREE_PANE_ONBOARDING_PREF);
 
@@ -976,16 +978,39 @@ Inspector.prototype = {
             this.fontinspector = new FontInspector(this, this.panelWin);
           }
 
           return this.fontinspector.provider;
         }
       },
       defaultTab == fontId);
 
+    // Inject a lazy loaded react tab by exposing a fake React object
+    // with a lazy defined Tab thanks to `panel` being a function
+    const changesId = "changesview";
+    this.sidebar.queueTab(
+      changesId,
+      "Changes (Prototype)",
+      {
+        props: {
+          id: changesId,
+          title: "Changes (Prototype)"
+        },
+        panel: () => {
+          if (!this.changesview) {
+            const ChangesView =
+              this.browserRequire("devtools/client/inspector/changes/changes");
+            this.changesview = new ChangesView(this, this.panelWin);
+          }
+
+          return this.changesview.provider;
+        }
+      },
+      defaultTab == changesId);
+
     this.sidebar.addAllQueuedTabs();
 
     // Persist splitter state in preferences.
     this.sidebar.on("show", this.onSidebarShown);
     this.sidebar.on("hide", this.onSidebarHidden);
     this.sidebar.on("destroy", this.onSidebarHidden);
 
     this.sidebar.show(defaultTab);
@@ -1414,16 +1439,20 @@ Inspector.prototype = {
       panel.destroy();
     }
     this._panels.clear();
 
     if (this.layoutview) {
       this.layoutview.destroy();
     }
 
+    if (this.changesview) {
+      this.changesview.destroy();
+    }
+
     if (this.fontinspector) {
       this.fontinspector.destroy();
     }
 
     if (this.animationinspector) {
       this.animationinspector.destroy();
     }
 
@@ -1448,16 +1477,18 @@ Inspector.prototype = {
     const markupDestroyer = this._destroyMarkup();
 
     this.teardownSplitter();
     this.teardownToolbar();
 
     this.breadcrumbs.destroy();
     this.reflowTracker.destroy();
     this.styleChangeTracker.destroy();
+    // TODO: Implement and call changeManager.destroy()
+    this.changeManager = null;
 
     this._is3PaneModeChromeEnabled = null;
     this._is3PaneModeEnabled = null;
     this._notificationBox = null;
     this._target = null;
     this._toolbox = null;
     this.breadcrumbs = null;
     this.is3PaneModeFirstRun = null;
--- a/devtools/client/inspector/moz.build
+++ b/devtools/client/inspector/moz.build
@@ -1,16 +1,17 @@
 # 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/.
 
 DIRS += [
     'animation',
     'animation-old',
     'boxmodel',
+    'changes',
     'components',
     'computed',
     'extensions',
     'flexbox',
     'fonts',
     'grids',
     'layout',
     'markup',
--- a/devtools/client/inspector/reducers.js
+++ b/devtools/client/inspector/reducers.js
@@ -4,15 +4,16 @@
 
 "use strict";
 
 // This file exposes the Redux reducers of the box model, grid and grid highlighter
 // settings.
 
 exports.animations = require("devtools/client/inspector/animation/reducers/animations");
 exports.boxModel = require("devtools/client/inspector/boxmodel/reducers/box-model");
+exports.changes = require("devtools/client/inspector/changes/reducers/changes");
 exports.extensionsSidebar = require("devtools/client/inspector/extensions/reducers/sidebar");
 exports.flexbox = require("devtools/client/inspector/flexbox/reducers/flexbox");
 exports.fontOptions = require("devtools/client/inspector/fonts/reducers/font-options");
 exports.fontData = require("devtools/client/inspector/fonts/reducers/fonts");
 exports.fontEditor = require("devtools/client/inspector/fonts/reducers/font-editor");
 exports.grids = require("devtools/client/inspector/grids/reducers/grids");
 exports.highlighterSettings = require("devtools/client/inspector/grids/reducers/highlighter-settings");
--- a/devtools/client/inspector/rules/views/rule-editor.js
+++ b/devtools/client/inspector/rules/views/rule-editor.js
@@ -65,27 +65,30 @@ function RuleEditor(ruleView, rule) {
   this._onNewProperty = this._onNewProperty.bind(this);
   this._newPropertyDestroy = this._newPropertyDestroy.bind(this);
   this._onSelectorDone = this._onSelectorDone.bind(this);
   this._locationChanged = this._locationChanged.bind(this);
   this.updateSourceLink = this.updateSourceLink.bind(this);
   this._onToolChanged = this._onToolChanged.bind(this);
   this._updateLocation = this._updateLocation.bind(this);
   this._onSourceClick = this._onSourceClick.bind(this);
+  this.onTrackChange = this.onTrackChange.bind(this);
 
   this.rule.domRule.on("location-changed", this._locationChanged);
+  this.rule.on("track-change", this.onTrackChange);
   this.toolbox.on("tool-registered", this._onToolChanged);
   this.toolbox.on("tool-unregistered", this._onToolChanged);
 
   this._create();
 }
 
 RuleEditor.prototype = {
   destroy: function() {
     this.rule.domRule.off("location-changed");
+    this.rule.off("track-change", this.onTrackChange);
     this.toolbox.off("tool-registered", this._onToolChanged);
     this.toolbox.off("tool-unregistered", this._onToolChanged);
 
     let url = null;
     if (this.rule.sheet) {
       url = this.rule.sheet.href || this.rule.sheet.nodeHref;
     }
     if (url && !this.rule.isSystem && this.rule.domRule.type !== ELEMENT_STYLE) {
@@ -96,16 +99,20 @@ RuleEditor.prototype = {
 
       if (this._sourceMapURLService) {
         this._sourceMapURLService.unsubscribe(url, sourceLine, sourceColumn,
           this._updateLocation);
       }
     }
   },
 
+  onTrackChange(change) {
+    this.ruleView.inspector.changeManager.track(change);
+  },
+
   get sourceMapURLService() {
     if (!this._sourceMapURLService) {
       // sourceMapURLService is a lazy getter in the toolbox.
       this._sourceMapURLService = this.toolbox.sourceMapURLService;
     }
 
     return this._sourceMapURLService;
   },
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -226,16 +226,17 @@ devtools.jar:
     skin/images/vview-lock@2x.png (themes/images/vview-lock@2x.png)
     skin/images/vview-open-inspector.png (themes/images/vview-open-inspector.png)
     skin/images/vview-open-inspector@2x.png (themes/images/vview-open-inspector@2x.png)
     skin/images/sort-ascending-arrow.svg (themes/images/sort-ascending-arrow.svg)
     skin/images/sort-descending-arrow.svg (themes/images/sort-descending-arrow.svg)
     skin/images/cubic-bezier-swatch.png (themes/images/cubic-bezier-swatch.png)
     skin/images/cubic-bezier-swatch@2x.png (themes/images/cubic-bezier-swatch@2x.png)
     skin/fonts.css (themes/fonts.css)
+    skin/changes.css (themes/changes.css)
     skin/computed.css (themes/computed.css)
     skin/layout.css (themes/layout.css)
     skin/images/arrow-e.png (themes/images/arrow-e.png)
     skin/images/arrow-e@2x.png (themes/images/arrow-e@2x.png)
     skin/images/search-clear-failed.svg (themes/images/search-clear-failed.svg)
     skin/images/search-clear-light.svg (themes/images/search-clear-light.svg)
     skin/images/search-clear-dark.svg (themes/images/search-clear-dark.svg)
     skin/tooltip/arrow-horizontal-dark.png (themes/tooltip/arrow-horizontal-dark.png)
new file mode 100644
--- /dev/null
+++ b/devtools/client/themes/changes.css
@@ -0,0 +1,93 @@
+/* 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/. */
+
+#sidebar-panel-changes {
+  margin: 0;
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+}
+
+#diff-container {
+}
+
+details {
+  font-family: monospace;
+  font-size: 12px;
+}
+
+summary {
+  user-select: none;
+  outline: none;
+  padding: 5px 0;
+  -moz-user-select: none;
+  cursor: pointer;
+}
+
+details.source[open] {
+  padding-bottom: 10px;
+}
+
+details.source > summary {
+  background: #f9f9fa;
+  border-top: 1px solid #dee1e4;
+  border-bottom: 1px solid #dee1e4;
+  padding-left: 5px;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
+details.selector > summary {
+  padding-left: 10px;
+}
+
+details.selector summary::after{
+  content: "{...}";
+  display: inline-block;
+  padding-left: 5px;
+}
+
+details.selector[open]{
+  margin-bottom: 5px;
+}
+
+details.selector[open] summary::after{
+  content: "{";
+}
+
+details.selector[open]::after{
+  content: "}";
+  display: block;
+  padding-left: 10px;
+}
+
+.line {
+  padding: 3px 5px 3px 15px;
+  position: relative;
+}
+
+.diff-add::before,
+.diff-remove::before{
+  position: absolute;
+  left: 5px;
+}
+
+.diff-add {
+  background-color: #f1feec;
+}
+
+.diff-add::before {
+  content: "+";
+  color: #54983f;
+}
+
+.diff-remove {
+  background-color: #fbf2f5;
+}
+
+.diff-remove::before{
+  content: "-";
+  color: #bf7173;
+}