tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

StyleSheetEditor.sys.mjs (30351B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import {
      6  require,
      7  loader,
      8 } from "resource://devtools/shared/loader/Loader.sys.mjs";
      9 
     10 const Editor = require("resource://devtools/client/shared/sourceeditor/editor.js");
     11 const {
     12  shortSource,
     13  prettifyCSS,
     14 } = require("resource://devtools/shared/inspector/css-logic.js");
     15 const { throttle } = require("resource://devtools/shared/throttle.js");
     16 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
     17 
     18 const lazy = {};
     19 
     20 loader.lazyGetter(lazy, "BufferStream", () => {
     21  return Components.Constructor(
     22    "@mozilla.org/io/arraybuffer-input-stream;1",
     23    "nsIArrayBufferInputStream",
     24    "setData"
     25  );
     26 });
     27 loader.lazyRequireGetter(
     28  lazy,
     29  "CSSCompleter",
     30  "resource://devtools/client/shared/sourceeditor/css-autocompleter.js"
     31 );
     32 
     33 ChromeUtils.defineESModuleGetters(lazy, {
     34  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
     35  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
     36 });
     37 
     38 import {
     39  getString,
     40  showFilePicker,
     41 } from "resource://devtools/client/styleeditor/StyleEditorUtil.sys.mjs";
     42 
     43 import { TYPES as HIGHLIGHTER_TYPES } from "resource://devtools/shared/highlighters.mjs";
     44 
     45 const LOAD_ERROR = "error-load";
     46 const SAVE_ERROR = "error-save";
     47 
     48 // max update frequency in ms (avoid potential typing lag and/or flicker)
     49 // @see StyleEditor.updateStylesheet
     50 const UPDATE_STYLESHEET_DELAY = 500;
     51 
     52 // Pref which decides if CSS autocompletion is enabled in Style Editor or not.
     53 const AUTOCOMPLETION_PREF = "devtools.styleeditor.autocompletion-enabled";
     54 
     55 // Pref which decides whether updates to the stylesheet use transitions
     56 const TRANSITION_PREF = "devtools.styleeditor.transitions";
     57 
     58 // How long to wait to update linked CSS file after original source was saved
     59 // to disk. Time in ms.
     60 const CHECK_LINKED_SHEET_DELAY = 500;
     61 
     62 // How many times to check for linked file changes
     63 const MAX_CHECK_COUNT = 10;
     64 
     65 // How much time should the mouse be still before the selector at that position
     66 // gets highlighted?
     67 const SELECTOR_HIGHLIGHT_TIMEOUT = 500;
     68 
     69 // Minimum delay between firing two at-rules-changed events.
     70 const EMIT_AT_RULES_THROTTLING = 500;
     71 
     72 const STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR = "styleeditor";
     73 
     74 /**
     75 * StyleSheetEditor controls the editor linked to a particular StyleSheet
     76 * object.
     77 *
     78 * Emits events:
     79 *   'property-change': A property on the underlying stylesheet has changed
     80 *   'source-editor-load': The source editor for this editor has been loaded
     81 *   'error': An error has occured
     82 */
     83 export class StyleSheetEditor extends EventEmitter {
     84  /**
     85   * @param  {Resource} resource
     86   *         The STYLESHEET resource which is received from resource command.
     87   * @param {DOMWindow}  win
     88   *        panel window for style editor
     89   * @param {number} styleSheetFriendlyIndex
     90   *        Optional Integer representing the index of the current stylesheet
     91   *        among all stylesheets of its type (inline, constructed or user-created)
     92   */
     93  constructor(resource, win, styleSheetFriendlyIndex) {
     94    super();
     95 
     96    this._resource = resource;
     97    this._inputElement = null;
     98    this.sourceEditor = null;
     99    this._window = win;
    100    this._isNew = this.styleSheet.isNew;
    101    this.styleSheetFriendlyIndex = styleSheetFriendlyIndex;
    102 
    103    // True when we've just set the editor text based on a style-applied
    104    // event from the StyleSheetActor.
    105    this._justSetText = false;
    106 
    107    // state to use when inputElement attaches
    108    this._state = {
    109      text: "",
    110      selection: {
    111        start: { line: 0, ch: 0 },
    112        end: { line: 0, ch: 0 },
    113      },
    114    };
    115 
    116    this._styleSheetFilePath = null;
    117    if (
    118      this.styleSheet.href &&
    119      Services.io.extractScheme(this.styleSheet.href) == "file"
    120    ) {
    121      this._styleSheetFilePath = this.styleSheet.href;
    122    }
    123 
    124    this.onPropertyChange = this.onPropertyChange.bind(this);
    125    this.onAtRulesChanged = this.onAtRulesChanged.bind(this);
    126    this.checkLinkedFileForChanges = this.checkLinkedFileForChanges.bind(this);
    127    this.markLinkedFileBroken = this.markLinkedFileBroken.bind(this);
    128    this.saveToFile = this.saveToFile.bind(this);
    129    this.updateStyleSheet = this.updateStyleSheet.bind(this);
    130    this._updateStyleSheet = this._updateStyleSheet.bind(this);
    131    this._onMouseMove = this._onMouseMove.bind(this);
    132 
    133    this._focusOnSourceEditorReady = false;
    134    this.savedFile = this.styleSheet.file;
    135    this.linkCSSFile();
    136 
    137    this.emitAtRulesChanged = throttle(
    138      this.emitAtRulesChanged,
    139      EMIT_AT_RULES_THROTTLING,
    140      this
    141    );
    142 
    143    this.atRules = [];
    144    this._isPrettyPrinted = false;
    145  }
    146 
    147  get isPrettyPrinted() {
    148    return this._isPrettyPrinted;
    149  }
    150 
    151  get resourceId() {
    152    return this._resource.resourceId;
    153  }
    154 
    155  get styleSheet() {
    156    return this._resource;
    157  }
    158 
    159  /**
    160   * Whether there are unsaved changes in the editor
    161   */
    162  get unsaved() {
    163    return this.sourceEditor && !this.sourceEditor.isClean();
    164  }
    165 
    166  /**
    167   * Whether the editor is for a stylesheet created by the user
    168   * through the style editor UI.
    169   */
    170  get isNew() {
    171    return this._isNew;
    172  }
    173 
    174  /**
    175   * The style sheet or the generated style sheet for this source if it's an
    176   * original source.
    177   */
    178  get cssSheet() {
    179    if (this.styleSheet.isOriginalSource) {
    180      return this.styleSheet.relatedStyleSheet;
    181    }
    182    return this.styleSheet;
    183  }
    184 
    185  get savedFile() {
    186    return this._savedFile;
    187  }
    188 
    189  set savedFile(name) {
    190    this._savedFile = name;
    191 
    192    this.linkCSSFile();
    193  }
    194 
    195  /**
    196   * Get a user-friendly name for the style sheet.
    197   *
    198   * @return string
    199   */
    200  get friendlyName() {
    201    if (this.savedFile) {
    202      return this.savedFile.leafName;
    203    }
    204 
    205    const index = this.styleSheetFriendlyIndex;
    206    if (this._isNew) {
    207      return getString("newStyleSheet", index);
    208    }
    209 
    210    if (this.styleSheet.constructed) {
    211      return getString("constructedStyleSheet", index);
    212    }
    213 
    214    if (!this.styleSheet.href) {
    215      return getString("inlineStyleSheet", index);
    216    }
    217 
    218    if (!this._friendlyName) {
    219      this._friendlyName = shortSource(this.styleSheet);
    220      try {
    221        this._friendlyName = decodeURI(this._friendlyName);
    222      } catch (ex) {
    223        // Ignore.
    224      }
    225    }
    226    return this._friendlyName;
    227  }
    228 
    229  /**
    230   * Check if transitions are enabled for style changes.
    231   *
    232   * @return Boolean
    233   */
    234  get transitionsEnabled() {
    235    return Services.prefs.getBoolPref(TRANSITION_PREF);
    236  }
    237 
    238  /**
    239   * If this is an original source, get the path of the CSS file it generated.
    240   */
    241  linkCSSFile() {
    242    if (!this.styleSheet.isOriginalSource) {
    243      return;
    244    }
    245 
    246    const relatedSheet = this.styleSheet.relatedStyleSheet;
    247    if (!relatedSheet || !relatedSheet.href) {
    248      return;
    249    }
    250 
    251    let path;
    252    const href = removeQuery(relatedSheet.href);
    253    const uri = lazy.NetUtil.newURI(href);
    254 
    255    if (uri.scheme == "file") {
    256      const file = uri.QueryInterface(Ci.nsIFileURL).file;
    257      path = file.path;
    258    } else if (this.savedFile) {
    259      const origHref = removeQuery(this.styleSheet.href);
    260      const origUri = lazy.NetUtil.newURI(origHref);
    261      path = findLinkedFilePath(uri, origUri, this.savedFile);
    262    } else {
    263      // we can't determine path to generated file on disk
    264      return;
    265    }
    266 
    267    if (this.linkedCSSFile == path) {
    268      return;
    269    }
    270 
    271    this.linkedCSSFile = path;
    272 
    273    this.linkedCSSFileError = null;
    274 
    275    // save last file change time so we can compare when we check for changes.
    276    IOUtils.stat(path).then(info => {
    277      this._fileModDate = info.lastModified;
    278    }, this.markLinkedFileBroken);
    279 
    280    this.emit("linked-css-file");
    281  }
    282 
    283  /**
    284   * A helper function that fetches the source text from the style
    285   * sheet.
    286   *
    287   * This will set |this._state.text| to the new text.
    288   */
    289  async _fetchSourceText() {
    290    const styleSheetsFront = await this._getStyleSheetsFront();
    291 
    292    let longStr = null;
    293    if (this.styleSheet.isOriginalSource) {
    294      // If the stylesheet is OriginalSource, we should get the texts from SourceMapLoader.
    295      // So, for now, we use OriginalSource.getText() as it is.
    296      longStr = await this.styleSheet.getText();
    297    } else {
    298      longStr = await styleSheetsFront.getText(this.resourceId);
    299    }
    300 
    301    this._state.text = await longStr.string();
    302  }
    303 
    304  prettifySourceText() {
    305    this._prettifySourceTextIfNeeded(/* force */ true);
    306  }
    307 
    308  /**
    309   * Attempt to prettify the current text if the corresponding stylesheet is not
    310   * an original source. The text will be read from |this._state.text|.
    311   *
    312   * This will set |this._state.text| to the prettified text if needed.
    313   *
    314   * @param {boolean} force: Set to true to prettify the stylesheet, no matter if it's
    315   *                         minified or not.
    316   */
    317  _prettifySourceTextIfNeeded(force = false) {
    318    if (this.styleSheet.isOriginalSource) {
    319      return;
    320    }
    321 
    322    const { result, mappings } = prettifyCSS(
    323      this._state.text,
    324      // prettifyCSS will always prettify the passed text if we pass a `null` ruleCount.
    325      force ? null : this.styleSheet.ruleCount
    326    );
    327 
    328    // Store the list of objects with mappings between CSS token positions from the
    329    // original source to the prettified source. These will be used when requested to
    330    // jump to a specific position within the editor.
    331    this._mappings = mappings;
    332    this._state.text = result;
    333 
    334    if (force && this.sourceEditor) {
    335      this.sourceEditor.setText(result);
    336      this._isPrettyPrinted = true;
    337    }
    338  }
    339 
    340  /**
    341   * Start fetching the full text source for this editor's sheet.
    342   */
    343  async fetchSource() {
    344    try {
    345      await this._fetchSourceText();
    346      this.sourceLoaded = true;
    347    } catch (e) {
    348      if (this._isDestroyed) {
    349        console.warn(
    350          `Could not fetch the source for ${this.styleSheet.href}, the editor was destroyed`
    351        );
    352        console.error(e);
    353      } else {
    354        console.error(e);
    355        this.emit("error", {
    356          key: LOAD_ERROR,
    357          append: this.styleSheet.href,
    358          level: "warning",
    359        });
    360        throw e;
    361      }
    362    }
    363  }
    364 
    365  /**
    366   * Set the cursor at the given line and column location within the code editor.
    367   *
    368   * @param {number} line
    369   * @param {number} column
    370   */
    371  setCursor(line, column) {
    372    line = line || 0;
    373    column = column || 0;
    374 
    375    const position = this.translateCursorPosition(line, column);
    376    this.sourceEditor.setCursor({ line: position.line, ch: position.column });
    377  }
    378 
    379  /**
    380   * If the stylesheet was automatically prettified, there should be a list of line
    381   * and column mappings from the original to the generated source that can be used
    382   * to translate the cursor position to the correct location in the prettified source.
    383   * If no mappings exist, return the original cursor position unchanged.
    384   *
    385   * @param  {number} line
    386   * @param  {Numer} column
    387   *
    388   * @return {object}
    389   */
    390  translateCursorPosition(line, column) {
    391    if (Array.isArray(this._mappings)) {
    392      for (const mapping of this._mappings) {
    393        if (
    394          mapping.original.line === line &&
    395          mapping.original.column === column
    396        ) {
    397          line = mapping.generated.line;
    398          column = mapping.generated.column;
    399          continue;
    400        }
    401      }
    402    }
    403 
    404    return { line, column };
    405  }
    406 
    407  /**
    408   * Forward property-change event from stylesheet.
    409   *
    410   * @param  {string} event
    411   *         Event type
    412   * @param  {string} property
    413   *         Property that has changed on sheet
    414   */
    415  onPropertyChange(property, value) {
    416    this.emit("property-change", property, value);
    417  }
    418 
    419  /**
    420   * Called when the stylesheet text changes.
    421   *
    422   * @param {object} update: The stylesheet resource update packet.
    423   */
    424  async onStyleApplied(update) {
    425    const updateIsFromSyleSheetEditor =
    426      update?.event?.cause === STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR;
    427 
    428    if (updateIsFromSyleSheetEditor) {
    429      // We just applied an edit in the editor, so we can drop this notification.
    430      this.emit("style-applied");
    431      return;
    432    }
    433 
    434    if (this.sourceEditor) {
    435      try {
    436        await this._fetchSourceText();
    437      } catch (e) {
    438        if (this._isDestroyed) {
    439          // Source editor was destroyed while trying to apply an update, bail.
    440          return;
    441        }
    442        throw e;
    443      }
    444 
    445      // sourceEditor is already loaded, so we can prettify immediately.
    446      this._prettifySourceTextIfNeeded();
    447 
    448      // The updated stylesheet text should have been set in this._state.text by _fetchSourceText.
    449      const sourceText = this._state.text;
    450 
    451      this._justSetText = true;
    452      const firstLine = this.sourceEditor.getFirstVisibleLine();
    453      const pos = this.sourceEditor.getCursor();
    454      this.sourceEditor.setText(sourceText);
    455      this.sourceEditor.setFirstVisibleLine(firstLine);
    456      this.sourceEditor.setCursor(pos);
    457      this.emit("style-applied");
    458    }
    459  }
    460 
    461  /**
    462   * Handles changes to the list of at-rules (@media, @layer, @container, …) in the stylesheet.
    463   * Emits 'at-rules-changed' if the list has changed.
    464   *
    465   * @param  {Array} rules
    466   *         Array of MediaRuleFronts for new media rules of sheet.
    467   */
    468  onAtRulesChanged(rules) {
    469    if (!rules.length && !this.atRules.length) {
    470      return;
    471    }
    472 
    473    this.atRules = rules;
    474    this.emitAtRulesChanged();
    475  }
    476 
    477  /**
    478   * Forward at-rules-changed event from stylesheet.
    479   */
    480  emitAtRulesChanged() {
    481    this.emit("at-rules-changed", this.atRules);
    482  }
    483 
    484  /**
    485   * Create source editor and load state into it.
    486   *
    487   * @param  {DOMElement} inputElement
    488   *         Element to load source editor in
    489   * @param  {CssProperties} cssProperties
    490   *         A css properties database.
    491   *
    492   * @return {Promise}
    493   *         Promise that will resolve when the style editor is loaded.
    494   */
    495  async load(inputElement, cssProperties) {
    496    if (this._isDestroyed) {
    497      throw new Error(
    498        "Won't load source editor as the style sheet has " +
    499          "already been removed from Style Editor."
    500      );
    501    }
    502 
    503    this._inputElement = inputElement;
    504 
    505    // Attempt to prettify the source before loading the source editor.
    506    this._prettifySourceTextIfNeeded();
    507 
    508    const walker = await this.getWalker();
    509    const config = {
    510      value: this._state.text,
    511      lineNumbers: true,
    512      mode: Editor.modes.css,
    513      // System stylesheets (eg user-agent html.css) cannot be edited.
    514      readOnly: !!this.styleSheet.system,
    515      autoCloseBrackets: "{}()",
    516      extraKeys: this._getKeyBindings(),
    517      contextMenu: "sourceEditorContextMenu",
    518      autocomplete: Services.prefs.getBoolPref(AUTOCOMPLETION_PREF),
    519      autocompleteOpts: { walker, cssProperties },
    520      cssProperties,
    521    };
    522    const sourceEditor = (this._sourceEditor = new Editor(config));
    523 
    524    sourceEditor.on("dirty-change", this.onPropertyChange);
    525 
    526    await sourceEditor.appendTo(inputElement);
    527 
    528    sourceEditor.on("saveRequested", this.saveToFile);
    529 
    530    if (!this.styleSheet.isOriginalSource) {
    531      sourceEditor.on("change", this.updateStyleSheet);
    532    }
    533 
    534    this.sourceEditor = sourceEditor;
    535 
    536    if (this._focusOnSourceEditorReady) {
    537      this._focusOnSourceEditorReady = false;
    538      sourceEditor.focus();
    539    }
    540 
    541    sourceEditor.setSelection(
    542      this._state.selection.start,
    543      this._state.selection.end
    544    );
    545 
    546    const highlighter = await this.getHighlighter();
    547    if (highlighter && walker && sourceEditor.container?.contentWindow) {
    548      sourceEditor.container.contentWindow.addEventListener(
    549        "mousemove",
    550        this._onMouseMove
    551      );
    552    }
    553 
    554    // Add the commands controller for the source-editor.
    555    sourceEditor.insertCommandsController();
    556 
    557    this.emit("source-editor-load");
    558  }
    559 
    560  /**
    561   * Get the source editor for this editor.
    562   *
    563   * @return {Promise}
    564   *         Promise that will resolve with the editor.
    565   */
    566  getSourceEditor() {
    567    const self = this;
    568 
    569    if (this.sourceEditor) {
    570      return Promise.resolve(this);
    571    }
    572 
    573    return new Promise(resolve => {
    574      this.on("source-editor-load", () => {
    575        resolve(self);
    576      });
    577    });
    578  }
    579 
    580  /**
    581   * Focus the Style Editor input.
    582   */
    583  focus() {
    584    if (this.sourceEditor) {
    585      this.sourceEditor.focus();
    586    } else {
    587      this._focusOnSourceEditorReady = true;
    588    }
    589  }
    590 
    591  /**
    592   * Event handler for when the editor is shown.
    593   *
    594   * @param {object} options
    595   * @param {string} options.reason: Indicates why the editor is shown
    596   */
    597  onShow(options = {}) {
    598    if (this.sourceEditor) {
    599      // CodeMirror needs refresh to restore scroll position after hiding and
    600      // showing the editor.
    601      this.sourceEditor.refresh();
    602    }
    603 
    604    // We don't want to focus the editor if it was shown because of the list being filtered,
    605    // as the user might still be typing in the filter input.
    606    if (options.reason !== "filter-auto") {
    607      this.focus();
    608    }
    609  }
    610 
    611  /**
    612   * Toggled the disabled state of the underlying stylesheet.
    613   */
    614  async toggleDisabled() {
    615    const styleSheetsFront = await this._getStyleSheetsFront();
    616    styleSheetsFront.toggleDisabled(this.resourceId).catch(console.error);
    617  }
    618 
    619  /**
    620   * Queue a throttled task to update the live style sheet.
    621   */
    622  updateStyleSheet() {
    623    if (this._updateTask) {
    624      // cancel previous queued task not executed within throttle delay
    625      this._window.clearTimeout(this._updateTask);
    626    }
    627 
    628    this._updateTask = this._window.setTimeout(
    629      this._updateStyleSheet,
    630      UPDATE_STYLESHEET_DELAY
    631    );
    632  }
    633 
    634  /**
    635   * Update live style sheet according to modifications.
    636   */
    637  async _updateStyleSheet() {
    638    if (this.styleSheet.disabled) {
    639      // TODO: do we want to do this?
    640      return;
    641    }
    642 
    643    if (this._justSetText) {
    644      this._justSetText = false;
    645      return;
    646    }
    647 
    648    // reset only if we actually perform an update
    649    // (stylesheet is enabled) so that 'missed' updates
    650    // while the stylesheet is disabled can be performed
    651    // when it is enabled back. @see enableStylesheet
    652    this._updateTask = null;
    653 
    654    if (this.sourceEditor) {
    655      this._state.text = this.sourceEditor.getText();
    656    }
    657 
    658    try {
    659      const styleSheetsFront = await this._getStyleSheetsFront();
    660      await styleSheetsFront.update(
    661        this.resourceId,
    662        this._state.text,
    663        this.transitionsEnabled,
    664        STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR
    665      );
    666 
    667      // Clear any existing mappings from automatic CSS prettification
    668      // because they were likely invalided by manually editing the stylesheet.
    669      this._mappings = null;
    670    } catch (e) {
    671      console.error(e);
    672    }
    673  }
    674 
    675  /**
    676   * Handle mousemove events, calling _highlightSelectorAt after a delay only
    677   * and reseting the delay everytime.
    678   */
    679  _onMouseMove(e) {
    680    // As we only want to hide an existing highlighter, we can use this.highlighter directly
    681    // (and not this.getHighlighter).
    682    if (this.highlighter) {
    683      this.highlighter.hide();
    684    }
    685 
    686    if (this.mouseMoveTimeout) {
    687      this._window.clearTimeout(this.mouseMoveTimeout);
    688      this.mouseMoveTimeout = null;
    689    }
    690 
    691    this.mouseMoveTimeout = this._window.setTimeout(() => {
    692      this._highlightSelectorAt(e.clientX, e.clientY);
    693    }, SELECTOR_HIGHLIGHT_TIMEOUT);
    694  }
    695 
    696  /**
    697   * Highlight nodes matching the selector found at coordinates x,y in the
    698   * editor, if any.
    699   *
    700   * @param {number} x
    701   * @param {number} y
    702   */
    703  async _highlightSelectorAt(x, y) {
    704    const pos = this.sourceEditor.getPositionFromCoords({ left: x, top: y });
    705    const info = this.sourceEditor.getInfoAt(pos);
    706    if (!info || info.state !== lazy.CSSCompleter.CSS_STATE_SELECTOR) {
    707      return;
    708    }
    709 
    710    const onGetHighlighter = this.getHighlighter();
    711    const walker = await this.getWalker();
    712    const node = await walker.getStyleSheetOwnerNode(this.resourceId);
    713 
    714    const highlighter = await onGetHighlighter;
    715    await highlighter.show(node, {
    716      selector: info.selector,
    717      hideInfoBar: true,
    718      showOnly: "border",
    719      region: "border",
    720    });
    721 
    722    this.emit("node-highlighted");
    723  }
    724 
    725  /**
    726   * Returns the walker front associated with this._resource target.
    727   *
    728   * @returns {Promise<WalkerFront>}
    729   */
    730  async getWalker() {
    731    if (this.walker) {
    732      return this.walker;
    733    }
    734 
    735    const { targetFront } = this._resource;
    736    const inspectorFront = await targetFront.getFront("inspector");
    737    this.walker = inspectorFront.walker;
    738    return this.walker;
    739  }
    740 
    741  /**
    742   * Returns or creates the selector highlighter associated with this._resource target.
    743   *
    744   * @returns {CustomHighlighterFront|null}
    745   */
    746  async getHighlighter() {
    747    if (this.highlighter) {
    748      return this.highlighter;
    749    }
    750 
    751    const walker = await this.getWalker();
    752    try {
    753      this.highlighter = await walker.parentFront.getHighlighterByType(
    754        HIGHLIGHTER_TYPES.SELECTOR
    755      );
    756      return this.highlighter;
    757    } catch (e) {
    758      // The selectorHighlighter can't always be instantiated, for example
    759      // it doesn't work with XUL windows (until bug 1094959 gets fixed);
    760      // or the selectorHighlighter doesn't exist on the backend.
    761      console.warn(
    762        "The selectorHighlighter couldn't be instantiated, " +
    763          "elements matching hovered selectors will not be highlighted"
    764      );
    765    }
    766    return null;
    767  }
    768 
    769  /**
    770   * Save the editor contents into a file and set savedFile property.
    771   * A file picker UI will open if file is not set and editor is not headless.
    772   *
    773   * @param mixed file
    774   *        Optional nsIFile or string representing the filename to save in the
    775   *        background, no UI will be displayed.
    776   *        If not specified, the original style sheet URI is used.
    777   *        To implement 'Save' instead of 'Save as', you can pass
    778   *        savedFile here.
    779   * @param function(nsIFile aFile) callback
    780   *        Optional callback called when the operation has finished.
    781   *        aFile has the nsIFile object for saved file or null if the operation
    782   *        has failed or has been canceled by the user.
    783   * @see savedFile
    784   */
    785  saveToFile(file, callback) {
    786    const onFile = returnFile => {
    787      if (!returnFile) {
    788        if (callback) {
    789          callback(null);
    790        }
    791        return;
    792      }
    793 
    794      if (this.sourceEditor) {
    795        this._state.text = this.sourceEditor.getText();
    796      }
    797 
    798      const ostream = lazy.FileUtils.openSafeFileOutputStream(returnFile);
    799      const buffer = new TextEncoder().encode(this._state.text).buffer;
    800      const istream = new lazy.BufferStream(buffer, 0, buffer.byteLength);
    801 
    802      lazy.NetUtil.asyncCopy(istream, ostream, status => {
    803        if (!Components.isSuccessCode(status)) {
    804          if (callback) {
    805            callback(null);
    806          }
    807          this.emit("error", { key: SAVE_ERROR });
    808          return;
    809        }
    810        lazy.FileUtils.closeSafeFileOutputStream(ostream);
    811 
    812        this.onFileSaved(returnFile);
    813 
    814        if (callback) {
    815          callback(returnFile);
    816        }
    817      });
    818    };
    819 
    820    let defaultName;
    821    if (this._friendlyName) {
    822      defaultName = PathUtils.isAbsolute(this._friendlyName)
    823        ? PathUtils.filename(this._friendlyName)
    824        : this._friendlyName;
    825    }
    826    showFilePicker(
    827      file || this._styleSheetFilePath,
    828      true,
    829      this._window,
    830      onFile,
    831      defaultName
    832    );
    833  }
    834 
    835  /**
    836   * Called when this source has been successfully saved to disk.
    837   */
    838  onFileSaved(returnFile) {
    839    this._friendlyName = null;
    840    this.savedFile = returnFile;
    841 
    842    if (this.sourceEditor) {
    843      this.sourceEditor.setClean();
    844    }
    845 
    846    this.emit("property-change");
    847 
    848    // TODO: replace with file watching
    849    this._modCheckCount = 0;
    850    this._window.clearTimeout(this._timeout);
    851 
    852    if (this.linkedCSSFile && !this.linkedCSSFileError) {
    853      this._timeout = this._window.setTimeout(
    854        this.checkLinkedFileForChanges,
    855        CHECK_LINKED_SHEET_DELAY
    856      );
    857    }
    858  }
    859 
    860  /**
    861   * Check to see if our linked CSS file has changed on disk, and
    862   * if so, update the live style sheet.
    863   */
    864  checkLinkedFileForChanges() {
    865    IOUtils.stat(this.linkedCSSFile).then(info => {
    866      const lastChange = info.lastModified;
    867 
    868      if (this._fileModDate && lastChange != this._fileModDate) {
    869        this._fileModDate = lastChange;
    870        this._modCheckCount = 0;
    871 
    872        this.updateLinkedStyleSheet();
    873        return;
    874      }
    875 
    876      if (++this._modCheckCount > MAX_CHECK_COUNT) {
    877        this.updateLinkedStyleSheet();
    878        return;
    879      }
    880 
    881      // try again in a bit
    882      this._timeout = this._window.setTimeout(
    883        this.checkLinkedFileForChanges,
    884        CHECK_LINKED_SHEET_DELAY
    885      );
    886    }, this.markLinkedFileBroken);
    887  }
    888 
    889  /**
    890   * Notify that the linked CSS file (if this is an original source)
    891   * doesn't exist on disk in the place we think it does.
    892   *
    893   * @param string error
    894   *        The error we got when trying to access the file.
    895   */
    896  markLinkedFileBroken(error) {
    897    this.linkedCSSFileError = error || true;
    898    this.emit("linked-css-file-error");
    899 
    900    error +=
    901      " querying " +
    902      this.linkedCSSFile +
    903      " original source location: " +
    904      this.savedFile.path;
    905    console.error(error);
    906  }
    907 
    908  /**
    909   * For original sources (e.g. Sass files). Fetch contents of linked CSS
    910   * file from disk and live update the stylesheet object with the contents.
    911   */
    912  updateLinkedStyleSheet() {
    913    IOUtils.read(this.linkedCSSFile).then(async array => {
    914      const decoder = new TextDecoder();
    915      const text = decoder.decode(array);
    916 
    917      // Ensure we don't re-fetch the text from the original source
    918      // actor when we're notified that the style sheet changed.
    919      const styleSheetsFront = await this._getStyleSheetsFront();
    920 
    921      await styleSheetsFront.update(
    922        this.resourceId,
    923        text,
    924        this.transitionsEnabled,
    925        STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR
    926      );
    927    }, this.markLinkedFileBroken);
    928  }
    929 
    930  /**
    931   * Retrieve custom key bindings objects as expected by Editor.
    932   * Editor action names are not displayed to the user.
    933   *
    934   * @return {Array} key binding objects for the source editor
    935   */
    936  _getKeyBindings() {
    937    const saveStyleSheetKeybind = Editor.accel(
    938      getString("saveStyleSheet.commandkey")
    939    );
    940    const focusFilterInputKeybind = Editor.accel(
    941      getString("focusFilterInput.commandkey")
    942    );
    943 
    944    return {
    945      Esc: false,
    946      [saveStyleSheetKeybind]: () => {
    947        this.saveToFile(this.savedFile);
    948      },
    949      ["Shift-" + saveStyleSheetKeybind]: () => {
    950        this.saveToFile();
    951      },
    952      // We can't simply ignore this (with `false`, or returning `CodeMirror.Pass`), as the
    953      // event isn't received by the event listener in StyleSheetUI.
    954      [focusFilterInputKeybind]: () => {
    955        this.emit("filter-input-keyboard-shortcut");
    956      },
    957    };
    958  }
    959 
    960  _getStyleSheetsFront() {
    961    return this._resource.targetFront.getFront("stylesheets");
    962  }
    963 
    964  /**
    965   * Clean up for this editor.
    966   */
    967  destroy() {
    968    if (this._sourceEditor) {
    969      this._sourceEditor.off("dirty-change", this.onPropertyChange);
    970      this._sourceEditor.off("saveRequested", this.saveToFile);
    971      this._sourceEditor.off("change", this.updateStyleSheet);
    972      if (this._sourceEditor.container?.contentWindow) {
    973        this._sourceEditor.container.contentWindow.removeEventListener(
    974          "mousemove",
    975          this._onMouseMove
    976        );
    977      }
    978      this._sourceEditor.destroy();
    979    }
    980    this._isDestroyed = true;
    981  }
    982 }
    983 
    984 /**
    985 * Find a path on disk for a file given it's hosted uri, the uri of the
    986 * original resource that generated it (e.g. Sass file), and the location of the
    987 * local file for that source.
    988 *
    989 * @param {nsIURI} uri
    990 *        The uri of the resource
    991 * @param {nsIURI} origUri
    992 *        The uri of the original source for the resource
    993 * @param {nsIFile} file
    994 *        The local file for the resource on disk
    995 *
    996 * @return {string}
    997 *         The path of original file on disk
    998 */
    999 function findLinkedFilePath(uri, origUri, file) {
   1000  const { origBranch, branch } = findUnsharedBranches(origUri, uri);
   1001  const project = findProjectPath(file, origBranch);
   1002 
   1003  const parts = project.concat(branch);
   1004  const path = PathUtils.join.apply(this, parts);
   1005 
   1006  return path;
   1007 }
   1008 
   1009 /**
   1010 * Find the path of a project given a file in the project and its branch
   1011 * off the root. e.g.:
   1012 * /Users/moz/proj/src/a.css" and "src/a.css"
   1013 * would yield ["Users", "moz", "proj"]
   1014 *
   1015 * @param {nsIFile} file
   1016 *        file for that resource on disk
   1017 * @param {Array} branch
   1018 *        path parts for branch to chop off file path.
   1019 * @return {Array}
   1020 *        array of path parts
   1021 */
   1022 function findProjectPath(file, branch) {
   1023  const path = PathUtils.split(file.path);
   1024 
   1025  for (let i = 2; i <= branch.length; i++) {
   1026    // work backwards until we find a differing directory name
   1027    if (path[path.length - i] != branch[branch.length - i]) {
   1028      return path.slice(0, path.length - i + 1);
   1029    }
   1030  }
   1031 
   1032  // if we don't find a differing directory, just chop off the branch
   1033  return path.slice(0, path.length - branch.length);
   1034 }
   1035 
   1036 /**
   1037 * Find the parts of a uri past the root it shares with another uri. e.g:
   1038 * "http://localhost/built/a.scss" and "http://localhost/src/a.css"
   1039 * would yield ["built", "a.scss"] and ["src", "a.css"]
   1040 *
   1041 * @param {nsIURI} origUri
   1042 *        uri to find unshared branch of. Usually is uri for original source.
   1043 * @param {nsIURI} uri
   1044 *        uri to compare against to get a shared root
   1045 * @return {object}
   1046 *         object with 'branch' and 'origBranch' array of path parts for branch
   1047 */
   1048 function findUnsharedBranches(origUri, uri) {
   1049  origUri = PathUtils.split(origUri.pathQueryRef);
   1050  uri = PathUtils.split(uri.pathQueryRef);
   1051 
   1052  for (let i = 0; i < uri.length - 1; i++) {
   1053    if (uri[i] != origUri[i]) {
   1054      return {
   1055        branch: uri.slice(i),
   1056        origBranch: origUri.slice(i),
   1057      };
   1058    }
   1059  }
   1060  return {
   1061    branch: uri,
   1062    origBranch: origUri,
   1063  };
   1064 }
   1065 
   1066 /**
   1067 * Remove the query string from a url.
   1068 *
   1069 * @param  {string} href
   1070 *         Url to remove query string from
   1071 * @return {string}
   1072 *         Url without query string
   1073 */
   1074 function removeQuery(href) {
   1075  return href.replace(/\?.*/, "");
   1076 }