tor-browser

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

StyleEditorUI.sys.mjs (58794B)


      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  loader,
      7  require,
      8 } from "resource://devtools/shared/loader/Loader.sys.mjs";
      9 
     10 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
     11 
     12 import {
     13  getString,
     14  text,
     15  showFilePicker,
     16  optionsPopupMenu,
     17 } from "resource://devtools/client/styleeditor/StyleEditorUtil.sys.mjs";
     18 import { StyleSheetEditor } from "resource://devtools/client/styleeditor/StyleSheetEditor.sys.mjs";
     19 
     20 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
     21 
     22 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
     23 const {
     24  shortSource,
     25 } = require("resource://devtools/shared/inspector/css-logic.js");
     26 
     27 const lazy = {};
     28 
     29 loader.lazyRequireGetter(
     30  lazy,
     31  "KeyCodes",
     32  "resource://devtools/client/shared/keycodes.js",
     33  true
     34 );
     35 
     36 loader.lazyRequireGetter(
     37  lazy,
     38  "OriginalSource",
     39  "resource://devtools/client/styleeditor/original-source.js",
     40  true
     41 );
     42 
     43 ChromeUtils.defineESModuleGetters(lazy, {
     44  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
     45  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
     46 });
     47 loader.lazyRequireGetter(
     48  lazy,
     49  "ResponsiveUIManager",
     50  "resource://devtools/client/responsive/manager.js"
     51 );
     52 loader.lazyRequireGetter(
     53  lazy,
     54  "openContentLink",
     55  "resource://devtools/client/shared/link.js",
     56  true
     57 );
     58 loader.lazyRequireGetter(
     59  lazy,
     60  "copyString",
     61  "resource://devtools/shared/platform/clipboard.js",
     62  true
     63 );
     64 
     65 const LOAD_ERROR = "error-load";
     66 const PREF_AT_RULES_SIDEBAR = "devtools.styleeditor.showAtRulesSidebar";
     67 const PREF_SIDEBAR_WIDTH = "devtools.styleeditor.atRulesSidebarWidth";
     68 const PREF_NAV_WIDTH = "devtools.styleeditor.navSidebarWidth";
     69 const PREF_ORIG_SOURCES = "devtools.source-map.client-service.enabled";
     70 
     71 const FILTERED_CLASSNAME = "splitview-filtered";
     72 const ALL_FILTERED_CLASSNAME = "splitview-all-filtered";
     73 
     74 const HTML_NS = "http://www.w3.org/1999/xhtml";
     75 
     76 /**
     77 * StyleEditorUI is controls and builds the UI of the Style Editor, including
     78 * maintaining a list of editors for each stylesheet on a debuggee.
     79 *
     80 * Emits events:
     81 *   'editor-added': A new editor was added to the UI
     82 *   'editor-selected': An editor was selected
     83 *   'error': An error occured
     84 *
     85 */
     86 export class StyleEditorUI extends EventEmitter {
     87  #activeSummary = null;
     88  #commands;
     89  #contextMenu;
     90  #contextMenuStyleSheet;
     91  #copyUrlItem;
     92  #cssProperties;
     93  #filter;
     94  #filterInput;
     95  #filterInputClearButton;
     96  #loadingStyleSheets;
     97  #nav;
     98  #openLinkNewTabItem;
     99  #optionsButton;
    100  #optionsMenu;
    101  #panelDoc;
    102  #prefObserver;
    103  #prettyPrintButton;
    104  #root;
    105  #seenSheets = new Map();
    106  #shortcuts;
    107  #side;
    108  #sourceMapPrefObserver;
    109  #styleSheetBoundToSelect;
    110  #styleSheetToSelect;
    111  /**
    112   * Maps keyed by summary element whose value is an object containing:
    113   * - {Element} details: The associated details element (i.e. container for CodeMirror)
    114   * - {StyleSheetEditor} editor: The associated editor, for easy retrieval
    115   */
    116  #summaryDataMap = new WeakMap();
    117  #toolbox;
    118  #tplDetails;
    119  #tplSummary;
    120  #uiAbortController = new AbortController();
    121  #window;
    122 
    123  /**
    124   * @param {Toolbox} toolbox
    125   * @param {object} commands Object defined from devtools/shared/commands to interact with the devtools backend
    126   * @param {Document} panelDoc
    127   *        Document of the toolbox panel to populate UI in.
    128   * @param {CssProperties} A css properties database.
    129   */
    130  constructor(toolbox, commands, panelDoc, cssProperties) {
    131    super();
    132 
    133    this.#toolbox = toolbox;
    134    this.#commands = commands;
    135    this.#panelDoc = panelDoc;
    136    this.#cssProperties = cssProperties;
    137    this.#window = this.#panelDoc.defaultView;
    138    this.#root = this.#panelDoc.getElementById("style-editor-chrome");
    139 
    140    this.editors = [];
    141    this.selectedEditor = null;
    142    this.savedLocations = {};
    143 
    144    this.#prefObserver = new PrefObserver("devtools.styleeditor.");
    145    this.#prefObserver.on(
    146      PREF_AT_RULES_SIDEBAR,
    147      this.#onAtRulesSidebarPrefChanged
    148    );
    149    this.#sourceMapPrefObserver = new PrefObserver(
    150      "devtools.source-map.client-service."
    151    );
    152    this.#sourceMapPrefObserver.on(
    153      PREF_ORIG_SOURCES,
    154      this.#onOrigSourcesPrefChanged
    155    );
    156  }
    157 
    158  get cssProperties() {
    159    return this.#cssProperties;
    160  }
    161 
    162  get currentTarget() {
    163    return this.#commands.targetCommand.targetFront;
    164  }
    165 
    166  /*
    167   * Index of selected stylesheet in document.styleSheets
    168   */
    169  get selectedStyleSheetIndex() {
    170    return this.selectedEditor
    171      ? this.selectedEditor.styleSheet.styleSheetIndex
    172      : -1;
    173  }
    174 
    175  /**
    176   * Initiates the style editor ui creation, and start to track TargetCommand updates.
    177   *
    178   * @param {object} options
    179   * @param {object} options.stylesheetToSelect
    180   * @param {StyleSheetResource} options.stylesheetToSelect.stylesheet
    181   * @param {Integer} options.stylesheetToSelect.line
    182   * @param {Integer} options.stylesheetToSelect.column
    183   */
    184  async initialize(options = {}) {
    185    this.createUI();
    186 
    187    if (options.stylesheetToSelect) {
    188      const { stylesheet, line, column } = options.stylesheetToSelect;
    189      // If a stylesheet resource and its location was passed (e.g. user clicked on a stylesheet
    190      // location in the rule view), we can directly add it to the list and select it
    191      // before watching for resources, for improved performance.
    192      if (stylesheet.resourceId) {
    193        try {
    194          await this.#handleStyleSheetResource(stylesheet);
    195          await this.selectStyleSheet(
    196            stylesheet,
    197            line - 1,
    198            column ? column - 1 : 0
    199          );
    200        } catch (e) {
    201          console.error(e);
    202        }
    203      }
    204    }
    205 
    206    await this.#commands.resourceCommand.watchResources(
    207      [this.#commands.resourceCommand.TYPES.DOCUMENT_EVENT],
    208      { onAvailable: this.#onResourceAvailable }
    209    );
    210    await this.#commands.targetCommand.watchTargets({
    211      types: [this.#commands.targetCommand.TYPES.FRAME],
    212      onAvailable: this.#onTargetAvailable,
    213      onDestroyed: this.#onTargetDestroyed,
    214    });
    215 
    216    this.#startLoadingStyleSheets();
    217    await this.#commands.resourceCommand.watchResources(
    218      [this.#commands.resourceCommand.TYPES.STYLESHEET],
    219      {
    220        onAvailable: this.#onResourceAvailable,
    221        onUpdated: this.#onResourceUpdated,
    222        onDestroyed: this.#onResourceDestroyed,
    223      }
    224    );
    225    await this.#waitForLoadingStyleSheets();
    226  }
    227 
    228  /**
    229   * Build the initial UI and wire buttons with event handlers.
    230   */
    231  createUI() {
    232    this.#filterInput = this.#root.querySelector(".devtools-filterinput");
    233    this.#filterInputClearButton = this.#root.querySelector(
    234      ".devtools-searchinput-clear"
    235    );
    236    this.#nav = this.#root.querySelector(".splitview-nav");
    237    this.#side = this.#root.querySelector(".splitview-side-details");
    238    this.#tplSummary = this.#root.querySelector(
    239      "#splitview-tpl-summary-stylesheet"
    240    );
    241    this.#tplDetails = this.#root.querySelector(
    242      "#splitview-tpl-details-stylesheet"
    243    );
    244 
    245    const eventListenersConfig = { signal: this.#uiAbortController.signal };
    246 
    247    // Add click event on the "new stylesheet" button in the toolbar and on the
    248    // "append a new stylesheet" link (visible when there are no stylesheets).
    249    for (const el of this.#root.querySelectorAll(".style-editor-newButton")) {
    250      el.addEventListener(
    251        "click",
    252        async () => {
    253          const stylesheetsFront =
    254            await this.currentTarget.getFront("stylesheets");
    255          stylesheetsFront.addStyleSheet(null);
    256          this.#clearFilterInput();
    257        },
    258        eventListenersConfig
    259      );
    260    }
    261 
    262    this.#root.querySelector(".style-editor-importButton").addEventListener(
    263      "click",
    264      () => {
    265        this.#importFromFile(this._mockImportFile || null, this.#window);
    266        this.#clearFilterInput();
    267      },
    268      eventListenersConfig
    269    );
    270 
    271    this.#prettyPrintButton = this.#root.querySelector(
    272      ".style-editor-prettyPrintButton"
    273    );
    274    this.#prettyPrintButton.addEventListener(
    275      "click",
    276      () => {
    277        if (!this.selectedEditor) {
    278          return;
    279        }
    280        this.#prettyPrintButton.classList.add("pretty");
    281        this.selectedEditor.prettifySourceText();
    282      },
    283      eventListenersConfig
    284    );
    285 
    286    this.#root
    287      .querySelector("#style-editor-options")
    288      .addEventListener(
    289        "click",
    290        this.#onOptionsButtonClick,
    291        eventListenersConfig
    292      );
    293 
    294    this.#filterInput.addEventListener(
    295      "input",
    296      this.#onFilterInputChange,
    297      eventListenersConfig
    298    );
    299 
    300    this.#filterInputClearButton.addEventListener(
    301      "click",
    302      () => this.#clearFilterInput(),
    303      eventListenersConfig
    304    );
    305 
    306    this.#panelDoc.addEventListener(
    307      "contextmenu",
    308      () => {
    309        this.#contextMenuStyleSheet = null;
    310      },
    311      { ...eventListenersConfig, capture: true }
    312    );
    313 
    314    this.#optionsButton = this.#panelDoc.getElementById("style-editor-options");
    315 
    316    this.#contextMenu = this.#panelDoc.getElementById("sidebar-context");
    317    this.#contextMenu.addEventListener(
    318      "popupshowing",
    319      this.#updateContextMenuItems,
    320      eventListenersConfig
    321    );
    322 
    323    this.#openLinkNewTabItem = this.#panelDoc.getElementById(
    324      "context-openlinknewtab"
    325    );
    326    this.#openLinkNewTabItem.addEventListener(
    327      "command",
    328      this.#openLinkNewTab,
    329      eventListenersConfig
    330    );
    331 
    332    this.#copyUrlItem = this.#panelDoc.getElementById("context-copyurl");
    333    this.#copyUrlItem.addEventListener(
    334      "command",
    335      this.#copyUrl,
    336      eventListenersConfig
    337    );
    338 
    339    // items list focus and search-on-type handling
    340    this.#nav.addEventListener(
    341      "keydown",
    342      this.#onNavKeyDown,
    343      eventListenersConfig
    344    );
    345 
    346    this.#shortcuts = new KeyShortcuts({
    347      window: this.#window,
    348    });
    349    this.#shortcuts.on(
    350      `CmdOrCtrl+${getString("focusFilterInput.commandkey")}`,
    351      this.#onFocusFilterInputKeyboardShortcut
    352    );
    353 
    354    const nav = this.#panelDoc.querySelector(".splitview-controller");
    355    nav.style.width = Services.prefs.getIntPref(PREF_NAV_WIDTH) + "px";
    356  }
    357 
    358  #clearFilterInput() {
    359    this.#filterInput.value = "";
    360    this.#onFilterInputChange();
    361  }
    362 
    363  #onFilterInputChange = () => {
    364    this.#filter = this.#filterInput.value;
    365    this.#filterInputClearButton.toggleAttribute("hidden", !this.#filter);
    366 
    367    for (const summary of this.#nav.childNodes) {
    368      // Don't update nav class for every element, we do it after the loop.
    369      this.handleSummaryVisibility(summary, {
    370        triggerOnFilterStateChange: false,
    371      });
    372    }
    373 
    374    this.#onFilterStateChange();
    375 
    376    if (this.#activeSummary == null) {
    377      const firstVisibleSummary = Array.from(this.#nav.childNodes).find(
    378        node => !node.classList.contains(FILTERED_CLASSNAME)
    379      );
    380 
    381      if (firstVisibleSummary) {
    382        this.setActiveSummary(firstVisibleSummary, { reason: "filter-auto" });
    383      }
    384    }
    385  };
    386 
    387  #onFilterStateChange() {
    388    const summaries = Array.from(this.#nav.childNodes);
    389    const hasVisibleSummary = summaries.some(
    390      node => !node.classList.contains(FILTERED_CLASSNAME)
    391    );
    392    const allFiltered = !!summaries.length && !hasVisibleSummary;
    393 
    394    this.#nav.classList.toggle(ALL_FILTERED_CLASSNAME, allFiltered);
    395 
    396    this.#filterInput
    397      .closest(".devtools-searchbox")
    398      .classList.toggle("devtools-searchbox-no-match", !!allFiltered);
    399  }
    400 
    401  #onFocusFilterInputKeyboardShortcut = e => {
    402    // Prevent the print modal to be displayed.
    403    if (e) {
    404      e.stopPropagation();
    405      e.preventDefault();
    406    }
    407    this.#filterInput.select();
    408  };
    409 
    410  #onNavKeyDown = event => {
    411    function getFocusedItemWithin(nav) {
    412      let node = nav.ownerDocument.activeElement;
    413      while (node && node.parentNode != nav) {
    414        node = node.parentNode;
    415      }
    416      return node;
    417    }
    418 
    419    // do not steal focus from inside iframes or textboxes
    420    if (
    421      event.target.ownerDocument != this.#nav.ownerDocument ||
    422      event.target.tagName == "input" ||
    423      event.target.tagName == "textarea" ||
    424      event.target.classList.contains("textbox")
    425    ) {
    426      return false;
    427    }
    428 
    429    // handle keyboard navigation within the items list
    430    const visibleElements = Array.from(
    431      this.#nav.querySelectorAll(`li:not(.${FILTERED_CLASSNAME})`)
    432    );
    433    // Elements have a different visual order (due to the use of order), so
    434    // we need to sort them by their data-ordinal attribute
    435    visibleElements.sort(
    436      (a, b) => a.getAttribute("data-ordinal") - b.getAttribute("data-ordinal")
    437    );
    438 
    439    let elementToFocus;
    440    if (
    441      event.keyCode == lazy.KeyCodes.DOM_VK_PAGE_UP ||
    442      event.keyCode == lazy.KeyCodes.DOM_VK_HOME
    443    ) {
    444      elementToFocus = visibleElements[0];
    445    } else if (
    446      event.keyCode == lazy.KeyCodes.DOM_VK_PAGE_DOWN ||
    447      event.keyCode == lazy.KeyCodes.DOM_VK_END
    448    ) {
    449      elementToFocus = visibleElements.at(-1);
    450    } else if (event.keyCode == lazy.KeyCodes.DOM_VK_UP) {
    451      const focusedIndex = visibleElements.indexOf(
    452        getFocusedItemWithin(this.#nav)
    453      );
    454      elementToFocus = visibleElements[focusedIndex - 1];
    455    } else if (event.keyCode == lazy.KeyCodes.DOM_VK_DOWN) {
    456      const focusedIndex = visibleElements.indexOf(
    457        getFocusedItemWithin(this.#nav)
    458      );
    459      elementToFocus = visibleElements[focusedIndex + 1];
    460    }
    461 
    462    if (elementToFocus !== undefined) {
    463      event.stopPropagation();
    464      event.preventDefault();
    465      elementToFocus.focus();
    466      return false;
    467    }
    468 
    469    return true;
    470  };
    471 
    472  /**
    473   * Opens the Options Popup Menu
    474   *
    475   * @param {number} screenX
    476   * @param {number} screenY
    477   *   Both obtained from the event object, used to position the popup
    478   */
    479  #onOptionsButtonClick = ({ screenX, screenY }) => {
    480    this.#optionsMenu = optionsPopupMenu(
    481      this.#toggleOrigSources,
    482      this.#toggleAtRulesSidebar
    483    );
    484 
    485    this.#optionsMenu.once("open", () => {
    486      this.#optionsButton.setAttribute("open", true);
    487    });
    488    this.#optionsMenu.once("close", () => {
    489      this.#optionsButton.removeAttribute("open");
    490    });
    491 
    492    this.#optionsMenu.popup(screenX, screenY, this.#toolbox.doc);
    493  };
    494 
    495  /**
    496   * Be called when changing the original sources pref.
    497   */
    498  #onOrigSourcesPrefChanged = async () => {
    499    this.#clear();
    500    // When we toggle the source-map preference, we clear the panel and re-fetch the exact
    501    // same stylesheet resources from ResourceCommand, but `_addStyleSheet` will trigger
    502    // or ignore the additional source-map mapping.
    503    this.#root.classList.add("loading");
    504    for (const resource of this.#commands.resourceCommand.getAllResources(
    505      this.#commands.resourceCommand.TYPES.STYLESHEET
    506    )) {
    507      await this.#handleStyleSheetResource(resource);
    508    }
    509 
    510    this.#root.classList.remove("loading");
    511 
    512    this.emit("stylesheets-refreshed");
    513  };
    514 
    515  /**
    516   * Remove all editors and add loading indicator.
    517   */
    518  #clear = () => {
    519    // remember selected sheet and line number for next load
    520    if (this.selectedEditor && this.selectedEditor.sourceEditor) {
    521      const href = this.selectedEditor.styleSheet.href;
    522      const { line, ch } = this.selectedEditor.sourceEditor.getCursor();
    523 
    524      this.#styleSheetToSelect = {
    525        stylesheet: href,
    526        line,
    527        col: ch,
    528      };
    529    }
    530 
    531    // remember saved file locations
    532    for (const editor of this.editors) {
    533      if (editor.savedFile) {
    534        const identifier = this.getStyleSheetIdentifier(editor.styleSheet);
    535        this.savedLocations[identifier] = editor.savedFile;
    536      }
    537    }
    538 
    539    this.#clearStyleSheetEditors();
    540    // Clear the left sidebar items and their associated elements.
    541    while (this.#nav.hasChildNodes()) {
    542      this.removeSplitViewItem(this.#nav.firstChild);
    543    }
    544 
    545    this.selectedEditor = null;
    546    // Here the keys are style sheet actors, and the values are
    547    // promises that resolve to the sheet's editor.  See |_addStyleSheet|.
    548    this.#seenSheets = new Map();
    549 
    550    this.emit("stylesheets-clear");
    551  };
    552 
    553  /**
    554   * Add an editor for this stylesheet. Add editors for its original sources
    555   * instead (e.g. Sass sources), if applicable.
    556   *
    557   * @param  {Resource} resource
    558   *         The STYLESHEET resource which is received from resource command.
    559   * @return {Promise}
    560   *         A promise that resolves to the style sheet's editor when the style sheet has
    561   *         been fully loaded.  If the style sheet has a source map, and source mapping
    562   *         is enabled, then the promise resolves to null.
    563   */
    564  #addStyleSheet(resource) {
    565    if (!this.#seenSheets.has(resource)) {
    566      const promise = (async () => {
    567        // When the StyleSheet is mapped to one or many original sources,
    568        // do not create an editor for the minified StyleSheet.
    569        const hasValidOriginalSource =
    570          await this.#tryAddingOriginalStyleSheets(resource);
    571        if (hasValidOriginalSource) {
    572          return null;
    573        }
    574        // Otherwise, if source-map failed or this is a non-source-map CSS
    575        // create an editor for it.
    576        return this.#addStyleSheetEditor(resource);
    577      })();
    578      this.#seenSheets.set(resource, promise);
    579    }
    580    return this.#seenSheets.get(resource);
    581  }
    582 
    583  /**
    584   * Check if the given StyleSheet relates to an original StyleSheet (via source maps).
    585   * If one is found, create an editor for the original one.
    586   *
    587   * @param  {Resource} resource
    588   *         The STYLESHEET resource which is received from resource command.
    589   * @return Boolean
    590   *         Return true, when we found a viable related original StyleSheet.
    591   */
    592  async #tryAddingOriginalStyleSheets(resource) {
    593    // Avoid querying the SourceMap if this feature is disabled.
    594    if (!Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) {
    595      return false;
    596    }
    597 
    598    const sourceMapLoader = this.#toolbox.sourceMapLoader;
    599    const {
    600      href,
    601      nodeHref,
    602      resourceId: id,
    603      sourceMapURL,
    604      sourceMapBaseURL,
    605    } = resource;
    606    let sources;
    607    try {
    608      sources = await sourceMapLoader.getOriginalURLs({
    609        id,
    610        url: href || nodeHref,
    611        sourceMapBaseURL,
    612        sourceMapURL,
    613      });
    614    } catch (e) {
    615      // Ignore any source map error, they will be logged
    616      // via the SourceMapLoader and Toolbox into the Web Console.
    617      return false;
    618    }
    619 
    620    // Return the generated CSS if the source-map failed to be parsed
    621    // or did not generate any original source.
    622    if (!sources || !sources.length) {
    623      return false;
    624    }
    625 
    626    // A single generated sheet might map to multiple original
    627    // sheets, so make editors for each of them.
    628    for (const { id: originalId, url: originalURL } of sources) {
    629      const original = new lazy.OriginalSource(
    630        originalURL,
    631        originalId,
    632        sourceMapLoader
    633      );
    634 
    635      // set so the first sheet will be selected, even if it's a source
    636      original.styleSheetIndex = resource.styleSheetIndex;
    637      original.relatedStyleSheet = resource;
    638      original.resourceId = resource.resourceId;
    639      original.targetFront = resource.targetFront;
    640      original.atRules = resource.atRules;
    641      await this.#addStyleSheetEditor(original);
    642    }
    643 
    644    return true;
    645  }
    646 
    647  #removeStyleSheet(resource, editor) {
    648    this.#seenSheets.delete(resource);
    649    this.#removeStyleSheetEditor(editor);
    650  }
    651 
    652  #getInlineStyleSheetsCount() {
    653    let count = 0;
    654    for (const editor of this.editors) {
    655      if (!editor.styleSheet.href && !editor.styleSheet.constructed) {
    656        count++;
    657      }
    658    }
    659    return count;
    660  }
    661 
    662  #getNewStyleSheetsCount() {
    663    let count = 0;
    664    for (const editor of this.editors) {
    665      if (editor.isNew) {
    666        count++;
    667      }
    668    }
    669    return count;
    670  }
    671 
    672  #getConstructedSheetsCount() {
    673    let count = 0;
    674    for (const editor of this.editors) {
    675      if (editor.styleSheet.constructed) {
    676        count++;
    677      }
    678    }
    679    return count;
    680  }
    681 
    682  /**
    683   * Finds the index to be shown in the Style Editor for inline, constructed or
    684   * user-created style sheets, returns undefined if not any of those.
    685   *
    686   * @param {StyleSheet} styleSheet
    687   *        Object representing stylesheet
    688   * @return {number}
    689   *         1-based Integer representing the index of the current stylesheet
    690   *         among all stylesheets of its type (inline, constructed or user-created).
    691   *         Defaults to 0 when non-applicable (e.g. for stylesheet with href)
    692   */
    693  #getNextFriendlyIndex(styleSheet) {
    694    if (styleSheet.href) {
    695      return 0;
    696    }
    697 
    698    if (styleSheet.isNew) {
    699      return this.#getNewStyleSheetsCount() + 1;
    700    }
    701 
    702    if (styleSheet.constructed) {
    703      return this.#getConstructedSheetsCount() + 1;
    704    }
    705 
    706    return this.#getInlineStyleSheetsCount() + 1;
    707  }
    708 
    709  /**
    710   * Add a new editor to the UI for a source.
    711   *
    712   * @param  {Resource} resource
    713   *         The resource which is received from resource command.
    714   * @return {Promise} that is resolved with the created StyleSheetEditor when
    715   *                   the editor is fully initialized or rejected on error.
    716   */
    717  async #addStyleSheetEditor(resource) {
    718    const editor = new StyleSheetEditor(
    719      resource,
    720      this.#window,
    721      this.#getNextFriendlyIndex(resource)
    722    );
    723 
    724    editor.on("property-change", this.#summaryChange.bind(this, editor));
    725    editor.on("at-rules-changed", this.#updateAtRulesList.bind(this, editor));
    726    editor.on("linked-css-file", this.#summaryChange.bind(this, editor));
    727    editor.on("linked-css-file-error", this.#summaryChange.bind(this, editor));
    728    editor.on("error", this.#onError);
    729    editor.on(
    730      "filter-input-keyboard-shortcut",
    731      this.#onFocusFilterInputKeyboardShortcut
    732    );
    733 
    734    // onAtRulesChanged fires at-rules-changed, so call the function after
    735    // registering the listener in order to ensure to get at-rules-changed event.
    736    editor.onAtRulesChanged(resource.atRules);
    737 
    738    this.editors.push(editor);
    739 
    740    try {
    741      await editor.fetchSource();
    742    } catch (e) {
    743      // if the editor was destroyed while fetching dependencies, we don't want to go further.
    744      if (!this.editors.includes(editor)) {
    745        return null;
    746      }
    747      throw e;
    748    }
    749 
    750    this.#sourceLoaded(editor);
    751 
    752    if (resource.fileName) {
    753      this.emit("test:editor-updated", editor);
    754    }
    755 
    756    return editor;
    757  }
    758 
    759  /**
    760   * Import a style sheet from file and asynchronously create a
    761   * new stylesheet on the debuggee for it.
    762   *
    763   * @param {mixed} file
    764   *        Optional nsIFile or filename string.
    765   *        If not set a file picker will be shown.
    766   * @param {nsIWindow} parentWindow
    767   *        Optional parent window for the file picker.
    768   */
    769  #importFromFile(file, parentWindow) {
    770    const onFileSelected = selectedFile => {
    771      if (!selectedFile) {
    772        // nothing selected
    773        return;
    774      }
    775      lazy.NetUtil.asyncFetch(
    776        {
    777          uri: lazy.NetUtil.newURI(selectedFile),
    778          loadingNode: this.#window.document,
    779          securityFlags:
    780            Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
    781          contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER,
    782        },
    783        async (stream, status) => {
    784          if (!Components.isSuccessCode(status)) {
    785            this.emit("error", { key: LOAD_ERROR, level: "warning" });
    786            return;
    787          }
    788          const source = lazy.NetUtil.readInputStreamToString(
    789            stream,
    790            stream.available()
    791          );
    792          stream.close();
    793 
    794          const stylesheetsFront =
    795            await this.currentTarget.getFront("stylesheets");
    796          stylesheetsFront.addStyleSheet(source, selectedFile.path);
    797        }
    798      );
    799    };
    800 
    801    showFilePicker(file, false, parentWindow, onFileSelected);
    802  }
    803 
    804  /**
    805   * Forward any error from a stylesheet.
    806   *
    807   * @param  {data} data
    808   *         The event data
    809   */
    810  #onError = data => {
    811    this.emit("error", data);
    812  };
    813 
    814  /**
    815   * Toggle the original sources pref.
    816   */
    817  #toggleOrigSources() {
    818    const isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES);
    819    Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled);
    820  }
    821 
    822  /**
    823   * Toggle the pref for showing the at-rules sidebar (for @media, @layer, @container, …)
    824   * in each editor.
    825   */
    826  #toggleAtRulesSidebar() {
    827    const isEnabled = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR);
    828    Services.prefs.setBoolPref(PREF_AT_RULES_SIDEBAR, !isEnabled);
    829  }
    830 
    831  /**
    832   * Toggle the at-rules sidebar in each editor depending on the setting.
    833   */
    834  #onAtRulesSidebarPrefChanged = () => {
    835    this.editors.forEach(this.#updateAtRulesList);
    836  };
    837 
    838  /**
    839   * This method handles the following cases related to the context
    840   * menu items "_openLinkNewTabItem" and "_copyUrlItem":
    841   *
    842   * 1) There was a stylesheet clicked on and it is external: show and
    843   * enable the context menu item
    844   * 2) There was a stylesheet clicked on and it is inline: show and
    845   * disable the context menu item
    846   * 3) There was no stylesheet clicked on (the right click happened
    847   * below the list): hide the context menu
    848   */
    849  #updateContextMenuItems = async () => {
    850    this.#openLinkNewTabItem.hidden = !this.#contextMenuStyleSheet;
    851    this.#copyUrlItem.hidden = !this.#contextMenuStyleSheet;
    852 
    853    if (this.#contextMenuStyleSheet) {
    854      this.#openLinkNewTabItem.setAttribute(
    855        "disabled",
    856        !this.#contextMenuStyleSheet.href
    857      );
    858      this.#copyUrlItem.setAttribute(
    859        "disabled",
    860        !this.#contextMenuStyleSheet.href
    861      );
    862    }
    863  };
    864 
    865  /**
    866   * Open a particular stylesheet in a new tab.
    867   */
    868  #openLinkNewTab = () => {
    869    if (this.#contextMenuStyleSheet) {
    870      lazy.openContentLink(this.#contextMenuStyleSheet.href);
    871    }
    872  };
    873 
    874  /**
    875   * Copies a stylesheet's URL.
    876   */
    877  #copyUrl = () => {
    878    if (this.#contextMenuStyleSheet) {
    879      lazy.copyString(this.#contextMenuStyleSheet.href);
    880    }
    881  };
    882 
    883  /**
    884   * Remove a particular stylesheet editor from the UI
    885   *
    886   * @param {StyleSheetEditor}  editor
    887   *        The editor to remove.
    888   */
    889  #removeStyleSheetEditor(editor) {
    890    if (editor.summary) {
    891      this.removeSplitViewItem(editor.summary);
    892    } else {
    893      const self = this;
    894      this.on("editor-added", function onAdd(added) {
    895        if (editor == added) {
    896          self.off("editor-added", onAdd);
    897          self.removeSplitViewItem(editor.summary);
    898        }
    899      });
    900    }
    901 
    902    editor.destroy();
    903    this.editors.splice(this.editors.indexOf(editor), 1);
    904  }
    905 
    906  /**
    907   * Clear all the editors from the UI.
    908   */
    909  #clearStyleSheetEditors() {
    910    for (const editor of this.editors) {
    911      editor.destroy();
    912    }
    913    this.editors = [];
    914  }
    915 
    916  /**
    917   * Called when a StyleSheetEditor's source has been fetched.
    918   * Add new sidebar item and editor to the UI
    919   *
    920   * @param  {StyleSheetEditor} editor
    921   *         Editor to create UI for.
    922   */
    923  #sourceLoaded(editor) {
    924    // Create the detail and summary nodes from the templates node (declared in index.xhtml)
    925    const details = this.#tplDetails.cloneNode(true);
    926    details.id = "";
    927    const summary = this.#tplSummary.cloneNode(true);
    928    summary.id = "";
    929 
    930    let ordinal = editor.styleSheet.styleSheetIndex;
    931    ordinal = ordinal == -1 ? Number.MAX_SAFE_INTEGER : ordinal;
    932    summary.style.order = ordinal;
    933    summary.setAttribute("data-ordinal", ordinal);
    934 
    935    const isSystem = !!editor.styleSheet.system;
    936    if (isSystem) {
    937      summary.classList.add("stylesheet-readonly");
    938    }
    939 
    940    this.#nav.appendChild(summary);
    941    this.#side.appendChild(details);
    942 
    943    this.#summaryDataMap.set(summary, {
    944      details,
    945      editor,
    946    });
    947 
    948    const createdEditor = editor;
    949    createdEditor.summary = summary;
    950    createdEditor.details = details;
    951 
    952    const eventListenersConfig = { signal: this.#uiAbortController.signal };
    953 
    954    summary.addEventListener(
    955      "click",
    956      event => {
    957        event.stopPropagation();
    958        this.setActiveSummary(summary);
    959      },
    960      eventListenersConfig
    961    );
    962 
    963    const stylesheetToggle = summary.querySelector(".stylesheet-toggle");
    964    if (isSystem) {
    965      stylesheetToggle.disabled = true;
    966      this.#window.document.l10n.setAttributes(
    967        stylesheetToggle,
    968        "styleeditor-visibility-toggle-system"
    969      );
    970    } else {
    971      stylesheetToggle.addEventListener(
    972        "click",
    973        event => {
    974          event.stopPropagation();
    975          event.target.blur();
    976 
    977          createdEditor.toggleDisabled();
    978        },
    979        eventListenersConfig
    980      );
    981    }
    982 
    983    summary.querySelector(".stylesheet-name").addEventListener(
    984      "keypress",
    985      event => {
    986        if (event.keyCode == lazy.KeyCodes.DOM_VK_RETURN) {
    987          this.setActiveSummary(summary);
    988        }
    989      },
    990      eventListenersConfig
    991    );
    992 
    993    summary.querySelector(".stylesheet-saveButton").addEventListener(
    994      "click",
    995      event => {
    996        event.stopPropagation();
    997        event.target.blur();
    998 
    999        createdEditor.saveToFile(createdEditor.savedFile);
   1000      },
   1001      eventListenersConfig
   1002    );
   1003 
   1004    this.#updateSummaryForEditor(createdEditor, summary);
   1005 
   1006    summary.addEventListener(
   1007      "contextmenu",
   1008      () => {
   1009        this.#contextMenuStyleSheet = createdEditor.styleSheet;
   1010      },
   1011      eventListenersConfig
   1012    );
   1013 
   1014    summary.addEventListener(
   1015      "focus",
   1016      function onSummaryFocus(event) {
   1017        if (event.target == summary) {
   1018          // autofocus the stylesheet name
   1019          summary.querySelector(".stylesheet-name").focus();
   1020        }
   1021      },
   1022      eventListenersConfig
   1023    );
   1024 
   1025    const sidebar = details.querySelector(".stylesheet-sidebar");
   1026    sidebar.style.width = Services.prefs.getIntPref(PREF_SIDEBAR_WIDTH) + "px";
   1027 
   1028    const splitter = details.querySelector(".devtools-side-splitter");
   1029    splitter.addEventListener(
   1030      "mousemove",
   1031      () => {
   1032        const sidebarWidth = parseInt(sidebar.style.width, 10);
   1033        if (!isNaN(sidebarWidth)) {
   1034          Services.prefs.setIntPref(PREF_SIDEBAR_WIDTH, sidebarWidth);
   1035 
   1036          // update all at-rules sidebars for consistency
   1037          const sidebars = [
   1038            ...this.#panelDoc.querySelectorAll(".stylesheet-sidebar"),
   1039          ];
   1040          for (const atRuleSidebar of sidebars) {
   1041            atRuleSidebar.style.width = sidebarWidth + "px";
   1042          }
   1043        }
   1044      },
   1045      eventListenersConfig
   1046    );
   1047 
   1048    // autofocus if it's a new user-created stylesheet
   1049    if (createdEditor.isNew) {
   1050      this.#selectEditor(createdEditor);
   1051    }
   1052 
   1053    if (this.#isEditorToSelect(createdEditor)) {
   1054      this.switchToSelectedSheet();
   1055    }
   1056 
   1057    // If this is the first stylesheet and there is no pending request to
   1058    // select a particular style sheet, select this sheet.
   1059    if (
   1060      !this.selectedEditor &&
   1061      !this.#styleSheetBoundToSelect &&
   1062      createdEditor.styleSheet.styleSheetIndex == 0 &&
   1063      !summary.classList.contains(FILTERED_CLASSNAME)
   1064    ) {
   1065      this.#selectEditor(createdEditor);
   1066    }
   1067    this.emit("editor-added", createdEditor);
   1068  }
   1069 
   1070  /**
   1071   * Switch to the editor that has been marked to be selected.
   1072   *
   1073   * @return {Promise}
   1074   *         Promise that will resolve when the editor is selected.
   1075   */
   1076  switchToSelectedSheet() {
   1077    const toSelect = this.#styleSheetToSelect;
   1078 
   1079    for (const editor of this.editors) {
   1080      if (this.#isEditorToSelect(editor)) {
   1081        // The _styleSheetBoundToSelect will always hold the latest pending
   1082        // requested style sheet (with line and column) which is not yet
   1083        // selected by the source editor. Only after we select that particular
   1084        // editor and go the required line and column, it will become null.
   1085        this.#styleSheetBoundToSelect = this.#styleSheetToSelect;
   1086        this.#styleSheetToSelect = null;
   1087        return this.#selectEditor(editor, toSelect.line, toSelect.col);
   1088      }
   1089    }
   1090 
   1091    return Promise.resolve();
   1092  }
   1093 
   1094  /**
   1095   * Returns whether a given editor is the current editor to be selected. Tests
   1096   * based on href or underlying stylesheet.
   1097   *
   1098   * @param {StyleSheetEditor} editor
   1099   *        The editor to test.
   1100   */
   1101  #isEditorToSelect(editor) {
   1102    const toSelect = this.#styleSheetToSelect;
   1103    if (!toSelect) {
   1104      return false;
   1105    }
   1106    const isHref =
   1107      toSelect.stylesheet === null || typeof toSelect.stylesheet == "string";
   1108 
   1109    return (
   1110      (isHref && editor.styleSheet.href == toSelect.stylesheet) ||
   1111      toSelect.stylesheet == editor.styleSheet
   1112    );
   1113  }
   1114 
   1115  /**
   1116   * Select an editor in the UI.
   1117   *
   1118   * @param  {StyleSheetEditor} editor
   1119   *         Editor to switch to.
   1120   * @param  {number} line
   1121   *         Line number to jump to
   1122   * @param  {number} col
   1123   *         Column number to jump to
   1124   * @return {Promise}
   1125   *         Promise that will resolve when the editor is selected and ready
   1126   *         to be used.
   1127   */
   1128  #selectEditor(editor, line = null, col = null) {
   1129    // Don't go further if the editor was destroyed in the meantime
   1130    if (!this.editors.includes(editor)) {
   1131      return null;
   1132    }
   1133 
   1134    const editorPromise = editor.getSourceEditor().then(() => {
   1135      // line/col are null when the style editor is initialized and the first stylesheet
   1136      // editor is selected. Unfortunately, this function might be called also when the
   1137      // panel is opened from clicking on a CSS warning in the WebConsole panel, in which
   1138      // case we have specific line+col.
   1139      // There's no guarantee which one could be called first, and it happened that we
   1140      // were setting the cursor once for the correct line coming from the webconsole,
   1141      // and then re-setting it to the default value (which was <0,0>).
   1142      // To avoid the race, we simply don't explicitly set the cursor to any default value,
   1143      // which is not a big deal as CodeMirror does init it to <0,0> anyway.
   1144      // See Bug 1738124 for more information.
   1145      if (line !== null || col !== null) {
   1146        editor.setCursor(line, col);
   1147      }
   1148      this.#styleSheetBoundToSelect = null;
   1149    });
   1150 
   1151    const summaryPromise = this.getEditorSummary(editor).then(summary => {
   1152      // Don't go further if the editor was destroyed in the meantime
   1153      if (!this.editors.includes(editor)) {
   1154        throw new Error("Editor was destroyed");
   1155      }
   1156      this.setActiveSummary(summary);
   1157    });
   1158 
   1159    return Promise.all([editorPromise, summaryPromise]);
   1160  }
   1161 
   1162  getEditorSummary(editor) {
   1163    const self = this;
   1164 
   1165    if (editor.summary) {
   1166      return Promise.resolve(editor.summary);
   1167    }
   1168 
   1169    return new Promise(resolve => {
   1170      this.on("editor-added", function onAdd(selected) {
   1171        if (selected == editor) {
   1172          self.off("editor-added", onAdd);
   1173          resolve(editor.summary);
   1174        }
   1175      });
   1176    });
   1177  }
   1178 
   1179  getEditorDetails(editor) {
   1180    const self = this;
   1181 
   1182    if (editor.details) {
   1183      return Promise.resolve(editor.details);
   1184    }
   1185 
   1186    return new Promise(resolve => {
   1187      this.on("editor-added", function onAdd(selected) {
   1188        if (selected == editor) {
   1189          self.off("editor-added", onAdd);
   1190          resolve(editor.details);
   1191        }
   1192      });
   1193    });
   1194  }
   1195 
   1196  /**
   1197   * Returns an identifier for the given style sheet.
   1198   *
   1199   * @param {StyleSheet} styleSheet
   1200   *        The style sheet to be identified.
   1201   */
   1202  getStyleSheetIdentifier(styleSheet) {
   1203    // Identify inline style sheets by their host page URI and index
   1204    // at the page.
   1205    return styleSheet.href
   1206      ? styleSheet.href
   1207      : "inline-" + styleSheet.styleSheetIndex + "-at-" + styleSheet.nodeHref;
   1208  }
   1209 
   1210  /**
   1211   * Get the OriginalSource object for a given original sourceId returned from
   1212   * the sourcemap worker service.
   1213   *
   1214   * @param {string} sourceId
   1215   *        The ID to search for from the sourcemap worker.
   1216   *
   1217   * @return {OriginalSource | null}
   1218   */
   1219  getOriginalSourceSheet(sourceId) {
   1220    for (const editor of this.editors) {
   1221      const { styleSheet } = editor;
   1222      if (styleSheet.isOriginalSource && styleSheet.sourceId === sourceId) {
   1223        return styleSheet;
   1224      }
   1225    }
   1226    return null;
   1227  }
   1228 
   1229  /**
   1230   * Given an URL, find a stylesheet resource with that URL, if one has been
   1231   * loaded into the editor.js
   1232   *
   1233   * Do not use this unless you have no other way to get a StyleSheet resource
   1234   * multiple sheets could share the same URL, so this will give you _one_
   1235   * of possibly many sheets with that URL.
   1236   *
   1237   * @param {string} url
   1238   *        An arbitrary URL to search for.
   1239   *
   1240   * @return {StyleSheetResource|null}
   1241   */
   1242  getStylesheetResourceForGeneratedURL(url) {
   1243    for (const styleSheet of this.#seenSheets.keys()) {
   1244      const sheetURL = styleSheet.href || styleSheet.nodeHref;
   1245      if (!styleSheet.isOriginalSource && sheetURL === url) {
   1246        return styleSheet;
   1247      }
   1248    }
   1249    return null;
   1250  }
   1251 
   1252  /**
   1253   * selects a stylesheet and optionally moves the cursor to a selected line
   1254   *
   1255   * @param {StyleSheetResource} stylesheet
   1256   *        Stylesheet to select or href of stylesheet to select
   1257   * @param {number} line
   1258   *        Line to which the caret should be moved (zero-indexed).
   1259   * @param {number} col
   1260   *        Column to which the caret should be moved (zero-indexed).
   1261   * @return {Promise}
   1262   *         Promise that will resolve when the editor is selected and ready
   1263   *         to be used.
   1264   */
   1265  selectStyleSheet(stylesheet, line, col) {
   1266    this.#styleSheetToSelect = {
   1267      stylesheet,
   1268      line,
   1269      col,
   1270    };
   1271 
   1272    /* Switch to the editor for this sheet, if it exists yet.
   1273       Otherwise each editor will be checked when it's created. */
   1274    return this.switchToSelectedSheet();
   1275  }
   1276 
   1277  /**
   1278   * Handler for an editor's 'property-changed' event.
   1279   * Update the summary in the UI.
   1280   *
   1281   * @param  {StyleSheetEditor} editor
   1282   *         Editor for which a property has changed
   1283   */
   1284  #summaryChange(editor) {
   1285    this.#updateSummaryForEditor(editor);
   1286  }
   1287 
   1288  /**
   1289   * Update split view summary of given StyleEditor instance.
   1290   *
   1291   * @param {StyleSheetEditor} editor
   1292   * @param {DOMElement} summary
   1293   *        Optional item's summary element to update. If none, item
   1294   *        corresponding to passed editor is used.
   1295   */
   1296  #updateSummaryForEditor(editor, summary) {
   1297    summary = summary || editor.summary;
   1298    if (!summary) {
   1299      return;
   1300    }
   1301 
   1302    let ruleCount = editor.styleSheet.ruleCount;
   1303    if (editor.styleSheet.relatedStyleSheet) {
   1304      ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount;
   1305    }
   1306    if (ruleCount === undefined) {
   1307      ruleCount = "-";
   1308    }
   1309 
   1310    this.#panelDoc.l10n.setArgs(
   1311      summary.querySelector(".stylesheet-rule-count"),
   1312      {
   1313        ruleCount,
   1314      }
   1315    );
   1316 
   1317    summary.classList.toggle("disabled", !!editor.styleSheet.disabled);
   1318    summary.classList.toggle("unsaved", !!editor.unsaved);
   1319    summary.classList.toggle("linked-file-error", !!editor.linkedCSSFileError);
   1320 
   1321    const label = summary.querySelector(".stylesheet-name > label");
   1322    label.setAttribute("value", editor.friendlyName);
   1323    if (editor.styleSheet.href) {
   1324      label.setAttribute("tooltiptext", editor.styleSheet.href);
   1325    }
   1326 
   1327    let linkedCSSSource = "";
   1328    if (editor.linkedCSSFile) {
   1329      linkedCSSSource = PathUtils.filename(editor.linkedCSSFile);
   1330    } else if (editor.styleSheet.relatedStyleSheet) {
   1331      // Compute a friendly name for the related generated source
   1332      // (relatedStyleSheet is set on original CSS to refer to the generated one)
   1333      linkedCSSSource = shortSource(editor.styleSheet.relatedStyleSheet);
   1334      try {
   1335        linkedCSSSource = decodeURI(linkedCSSSource);
   1336      } catch (e) {}
   1337    }
   1338    text(summary, ".stylesheet-linked-file", linkedCSSSource);
   1339    text(summary, ".stylesheet-title", editor.styleSheet.title || "");
   1340 
   1341    // We may need to change the summary visibility as a result of the changes.
   1342    this.handleSummaryVisibility(summary);
   1343  }
   1344 
   1345  /**
   1346   * Update the pretty print button.
   1347   * The button will be disabled if:
   1348   * - the selected file is read-only
   1349   * - OR the selected file is an original file
   1350   */
   1351  #updatePrettyPrintButton() {
   1352    const isReadOnly = !!this.selectedEditor?.sourceEditor?.config?.readOnly;
   1353    const isOriginalSource =
   1354      !!this.selectedEditor?.styleSheet?.isOriginalSource;
   1355 
   1356    const disable = !this.selectedEditor || isOriginalSource || isReadOnly;
   1357 
   1358    // Only update the button if its state needs it
   1359    if (disable !== this.#prettyPrintButton.hasAttribute("disabled")) {
   1360      this.#prettyPrintButton.toggleAttribute("disabled");
   1361    }
   1362    this.#prettyPrintButton.classList.toggle(
   1363      "pretty",
   1364      this.selectedEditor?.isPrettyPrinted || false
   1365    );
   1366    let l10nString;
   1367    if (disable) {
   1368      if (isReadOnly) {
   1369        l10nString = "styleeditor-pretty-print-button-disabled-read-only";
   1370      } else if (isOriginalSource) {
   1371        l10nString = "styleeditor-pretty-print-button-disabled";
   1372      }
   1373    } else {
   1374      l10nString = "styleeditor-pretty-print-button";
   1375    }
   1376 
   1377    this.#window.document.l10n.setAttributes(
   1378      this.#prettyPrintButton,
   1379      l10nString
   1380    );
   1381  }
   1382 
   1383  /**
   1384   * Update the at-rules sidebar for an editor. Hide if there are no rules
   1385   * Display a list of the at-rules (@media, @layer, @container, …) in the editor's associated style sheet.
   1386   * Emits a 'at-rules-list-changed' event after updating the UI.
   1387   *
   1388   * @param  {StyleSheetEditor} editor
   1389   *         Editor to update sidebar of
   1390   */
   1391  #updateAtRulesList = editor => {
   1392    (async function () {
   1393      const details = await this.getEditorDetails(editor);
   1394      const rules = editor.atRules;
   1395      const showSidebar = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR);
   1396      const sidebar = details.querySelector(".stylesheet-sidebar");
   1397 
   1398      let inSource = false;
   1399 
   1400      const listItems = [];
   1401      for (const rule of rules) {
   1402        const { line, column } = rule;
   1403 
   1404        let location = {
   1405          line,
   1406          column,
   1407          source: editor.styleSheet.href,
   1408          styleSheet: editor.styleSheet,
   1409        };
   1410        if (editor.styleSheet.isOriginalSource) {
   1411          const styleSheet = editor.cssSheet;
   1412          location = await editor.styleSheet.getOriginalLocation(
   1413            styleSheet,
   1414            line,
   1415            column
   1416          );
   1417        }
   1418 
   1419        // this at-rule is from a different original source
   1420        if (location.source != editor.styleSheet.href) {
   1421          continue;
   1422        }
   1423        inSource = true;
   1424 
   1425        const div = this.#panelDoc.createElementNS(HTML_NS, "div");
   1426        div.classList.add("at-rule-label", rule.type);
   1427        div.addEventListener(
   1428          "click",
   1429          this.#jumpToLocation.bind(this, location)
   1430        );
   1431 
   1432        const ruleTextContainer = this.#panelDoc.createElementNS(
   1433          HTML_NS,
   1434          "div"
   1435        );
   1436        const type = this.#panelDoc.createElementNS(HTML_NS, "span");
   1437        type.className = "at-rule-type";
   1438        type.append(this.#panelDoc.createTextNode(`@${rule.type}\u00A0`));
   1439        if (rule.type == "layer" && rule.layerName) {
   1440          type.append(this.#panelDoc.createTextNode(`${rule.layerName}\u00A0`));
   1441        } else if (rule.type === "property") {
   1442          type.append(
   1443            this.#panelDoc.createTextNode(`${rule.propertyName}\u00A0`)
   1444          );
   1445        } else if (rule.type === "position-try") {
   1446          type.append(
   1447            this.#panelDoc.createTextNode(`${rule.positionTryName}\u00A0`)
   1448          );
   1449        } else if (rule.type === "custom-media") {
   1450          const parts = [];
   1451          const { customMediaName, customMediaQuery } = rule;
   1452          for (let i = 0, len = customMediaQuery.length; i < len; i++) {
   1453            const media = customMediaQuery[i];
   1454            const queryEl = this.#panelDoc.createElementNS(HTML_NS, "span");
   1455            queryEl.textContent = media.text;
   1456            if (!media.matches) {
   1457              queryEl.classList.add("media-condition-unmatched");
   1458            }
   1459            parts.push(queryEl);
   1460            if (len > 1 && i !== len - 1) {
   1461              parts.push(", ");
   1462            }
   1463          }
   1464 
   1465          type.append(`${customMediaName} `, ...parts);
   1466        }
   1467 
   1468        const cond = this.#panelDoc.createElementNS(HTML_NS, "span");
   1469        cond.className = "at-rule-condition";
   1470        if (rule.type == "media" && !rule.matches) {
   1471          cond.classList.add("media-condition-unmatched");
   1472        }
   1473        if (this.#commands.descriptorFront.isLocalTab) {
   1474          this.#setConditionContents(cond, rule.conditionText, rule.type);
   1475        } else {
   1476          cond.textContent = rule.conditionText;
   1477        }
   1478 
   1479        const link = this.#panelDoc.createElementNS(HTML_NS, "div");
   1480        link.className = "at-rule-line theme-link";
   1481        if (location.line != -1) {
   1482          link.textContent = ":" + location.line;
   1483        }
   1484 
   1485        ruleTextContainer.append(type, cond);
   1486        div.append(ruleTextContainer, link);
   1487        listItems.push(div);
   1488      }
   1489 
   1490      const list = details.querySelector(".stylesheet-at-rules-list");
   1491      list.replaceChildren(...listItems);
   1492 
   1493      sidebar.hidden = !showSidebar || !inSource;
   1494 
   1495      this.emit("at-rules-list-changed", editor);
   1496    })
   1497      .bind(this)()
   1498      .catch(console.error);
   1499  };
   1500 
   1501  /**
   1502   * Set the condition text for the at-rule element.
   1503   * For media queries, it also injects links to open RDM at a specific size.
   1504   *
   1505   * @param {HTMLElement} element
   1506   *        The element corresponding to the media sidebar condition
   1507   * @param {string} ruleConditionText
   1508   *        The rule conditionText
   1509   * @param {string} type
   1510   *        The type of the at-rule (e.g. "media", "layer", "supports", …)
   1511   */
   1512  #setConditionContents(element, ruleConditionText, type) {
   1513    if (!ruleConditionText) {
   1514      return;
   1515    }
   1516 
   1517    // For non-media rules, we don't do anything more than displaying the conditionText
   1518    // as there are no other condition text that would justify opening RDM at a specific
   1519    // size (e.g. `@container` condition is relative to a container size, which varies
   1520    // depending the node the rule applies to).
   1521    if (type !== "media") {
   1522      const node = this.#panelDoc.createTextNode(ruleConditionText);
   1523      element.appendChild(node);
   1524      return;
   1525    }
   1526 
   1527    const minMaxPattern = /(min\-|max\-)(width|height):\s\d+(px)/gi;
   1528 
   1529    let match = minMaxPattern.exec(ruleConditionText);
   1530    let lastParsed = 0;
   1531    while (match && match.index != minMaxPattern.lastIndex) {
   1532      const matchEnd = match.index + match[0].length;
   1533      const node = this.#panelDoc.createTextNode(
   1534        ruleConditionText.substring(lastParsed, match.index)
   1535      );
   1536      element.appendChild(node);
   1537 
   1538      const link = this.#panelDoc.createElementNS(HTML_NS, "a");
   1539      link.href = "#";
   1540      link.className = "media-responsive-mode-toggle";
   1541      link.textContent = ruleConditionText.substring(match.index, matchEnd);
   1542      link.addEventListener("click", this.#onMediaConditionClick.bind(this));
   1543      element.appendChild(link);
   1544 
   1545      match = minMaxPattern.exec(ruleConditionText);
   1546      lastParsed = matchEnd;
   1547    }
   1548 
   1549    const node = this.#panelDoc.createTextNode(
   1550      ruleConditionText.substring(lastParsed, ruleConditionText.length)
   1551    );
   1552    element.appendChild(node);
   1553  }
   1554 
   1555  /**
   1556   * Called when a media condition is clicked
   1557   * If a responsive mode link is clicked, it will launch it.
   1558   *
   1559   * @param {object} e
   1560   *        Event object
   1561   */
   1562  #onMediaConditionClick(e) {
   1563    const conditionText = e.target.textContent;
   1564    const isWidthCond = conditionText.toLowerCase().indexOf("width") > -1;
   1565    const mediaVal = parseInt(/\d+/.exec(conditionText), 10);
   1566 
   1567    const options = isWidthCond ? { width: mediaVal } : { height: mediaVal };
   1568    this.#launchResponsiveMode(options);
   1569    e.preventDefault();
   1570    e.stopPropagation();
   1571  }
   1572 
   1573  /**
   1574   * Launches the responsive mode with a specific width or height.
   1575   *
   1576   * @param  {object} options
   1577   *         Object with width or/and height properties.
   1578   */
   1579  async #launchResponsiveMode(options = {}) {
   1580    const tab = this.#commands.descriptorFront.localTab;
   1581    const win = tab.ownerDocument.defaultView;
   1582 
   1583    await lazy.ResponsiveUIManager.openIfNeeded(win, tab, {
   1584      trigger: "style_editor",
   1585    });
   1586    this.emit("responsive-mode-opened");
   1587 
   1588    lazy.ResponsiveUIManager.getResponsiveUIForTab(tab).setViewportSize(
   1589      options
   1590    );
   1591  }
   1592 
   1593  /**
   1594   * Jump cursor to the editor for a stylesheet and line number for a rule.
   1595   *
   1596   * @param  {object} location
   1597   *         Location object with 'line', 'column', and 'source' properties.
   1598   */
   1599  #jumpToLocation(location) {
   1600    const source = location.styleSheet || location.source;
   1601    this.selectStyleSheet(source, location.line - 1, location.column - 1);
   1602  }
   1603 
   1604  #startLoadingStyleSheets() {
   1605    this.#root.classList.add("loading");
   1606    this.#loadingStyleSheets = [];
   1607  }
   1608 
   1609  async #waitForLoadingStyleSheets() {
   1610    while (this.#loadingStyleSheets?.length > 0) {
   1611      const pending = this.#loadingStyleSheets;
   1612      this.#loadingStyleSheets = [];
   1613      await Promise.all(pending);
   1614    }
   1615 
   1616    this.#loadingStyleSheets = null;
   1617    this.#root.classList.remove("loading");
   1618    this.emit("reloaded");
   1619  }
   1620 
   1621  async #handleStyleSheetResource(resource) {
   1622    try {
   1623      // The fileName is in resource means this stylesheet was imported from file by user.
   1624      const { fileName } = resource;
   1625      let file = fileName ? new lazy.FileUtils.File(fileName) : null;
   1626 
   1627      // recall location of saved file for this sheet after page reload
   1628      if (!file) {
   1629        const identifier = this.getStyleSheetIdentifier(resource);
   1630        const savedFile = this.savedLocations[identifier];
   1631        if (savedFile) {
   1632          file = savedFile;
   1633        }
   1634      }
   1635      resource.file = file;
   1636 
   1637      await this.#addStyleSheet(resource);
   1638    } catch (e) {
   1639      console.error(e);
   1640      this.emit("error", { key: LOAD_ERROR, level: "warning" });
   1641    }
   1642  }
   1643 
   1644  // onAvailable is a mandatory argument for watchTargets,
   1645  // but we don't do anything when a new target gets created.
   1646  #onTargetAvailable = () => {};
   1647 
   1648  #onTargetDestroyed = ({ targetFront }) => {
   1649    // Iterate over a copy of the list in order to prevent skipping
   1650    // over some items when removing items of this list
   1651    const editorsCopy = [...this.editors];
   1652    for (const editor of editorsCopy) {
   1653      const { styleSheet } = editor;
   1654      if (styleSheet.targetFront == targetFront) {
   1655        this.#removeStyleSheet(styleSheet, editor);
   1656      }
   1657    }
   1658  };
   1659 
   1660  #onResourceAvailable = async resources => {
   1661    const promises = [];
   1662    for (const resource of resources) {
   1663      if (
   1664        resource.resourceType ===
   1665        this.#commands.resourceCommand.TYPES.STYLESHEET
   1666      ) {
   1667        const onStyleSheetHandled = this.#handleStyleSheetResource(resource);
   1668 
   1669        if (this.#loadingStyleSheets) {
   1670          // In case of reloading/navigating and panel's opening
   1671          this.#loadingStyleSheets.push(onStyleSheetHandled);
   1672        }
   1673        promises.push(onStyleSheetHandled);
   1674        continue;
   1675      }
   1676 
   1677      if (!resource.targetFront.isTopLevel) {
   1678        continue;
   1679      }
   1680 
   1681      if (
   1682        resource.name === "will-navigate" &&
   1683        // When selecting a document in the Browser Toolbox iframe picker, we're getting
   1684        // a will-navigate event. In such case, we don't want to clear the list (see Bug 1981937)
   1685        (!this.#commands.targetCommand.descriptorFront
   1686          .isBrowserProcessDescriptor ||
   1687          !resource.isFrameSwitching)
   1688      ) {
   1689        this.#startLoadingStyleSheets();
   1690        this.#clear();
   1691      } else if (resource.name === "dom-complete") {
   1692        promises.push(this.#waitForLoadingStyleSheets());
   1693      }
   1694    }
   1695    await Promise.all(promises);
   1696  };
   1697 
   1698  #onResourceUpdated = async updates => {
   1699    // The editors are instantiated asynchronously from onResourceAvailable,
   1700    // but we may receive updates right after due to throttling.
   1701    // Ensure waiting for this async work before trying to update the related editors.
   1702    await this.#waitForLoadingStyleSheets();
   1703 
   1704    for (const { resource, update } of updates) {
   1705      if (
   1706        update.resourceType === this.#commands.resourceCommand.TYPES.STYLESHEET
   1707      ) {
   1708        const editor = this.editors.find(
   1709          e => e.resourceId === update.resourceId
   1710        );
   1711 
   1712        if (!editor) {
   1713          console.warn(
   1714            "Could not find StyleEditor to apply STYLESHEET resource update"
   1715          );
   1716          continue;
   1717        }
   1718 
   1719        switch (update.updateType) {
   1720          case "style-applied": {
   1721            editor.onStyleApplied(update);
   1722            break;
   1723          }
   1724          case "property-change": {
   1725            for (const [property, value] of Object.entries(
   1726              update.resourceUpdates
   1727            )) {
   1728              editor.onPropertyChange(property, value);
   1729            }
   1730            break;
   1731          }
   1732          case "at-rules-changed":
   1733          case "matches-change": {
   1734            editor.onAtRulesChanged(resource.atRules);
   1735            break;
   1736          }
   1737        }
   1738      }
   1739    }
   1740  };
   1741 
   1742  #onResourceDestroyed = resources => {
   1743    for (const resource of resources) {
   1744      if (
   1745        resource.resourceType !==
   1746        this.#commands.resourceCommand.TYPES.STYLESHEET
   1747      ) {
   1748        continue;
   1749      }
   1750 
   1751      const editorToRemove = this.editors.find(
   1752        editor => editor.styleSheet.resourceId == resource.resourceId
   1753      );
   1754 
   1755      if (editorToRemove) {
   1756        const { styleSheet } = editorToRemove;
   1757        this.#removeStyleSheet(styleSheet, editorToRemove);
   1758      }
   1759    }
   1760  };
   1761 
   1762  /**
   1763   * Set the active item's summary element.
   1764   *
   1765   * @param DOMElement summary
   1766   * @param {object} options
   1767   * @param {string=} options.reason: Indicates why the summary was selected. It's set to
   1768   *                  "filter-auto" when the summary was automatically selected as the result
   1769   *                  of the previous active summary being filtered out.
   1770   */
   1771  setActiveSummary(summary, options = {}) {
   1772    if (summary == this.#activeSummary) {
   1773      return;
   1774    }
   1775 
   1776    if (this.#activeSummary) {
   1777      const binding = this.#summaryDataMap.get(this.#activeSummary);
   1778 
   1779      this.#activeSummary.classList.remove("splitview-active");
   1780      binding.details.classList.remove("splitview-active");
   1781    }
   1782 
   1783    this.#activeSummary = summary;
   1784    if (!summary) {
   1785      this.selectedEditor = null;
   1786      return;
   1787    }
   1788 
   1789    const { details } = this.#summaryDataMap.get(summary);
   1790    summary.classList.add("splitview-active");
   1791    details.classList.add("splitview-active");
   1792 
   1793    this.showSummaryEditor(summary, options);
   1794  }
   1795 
   1796  /**
   1797   * Show summary's associated editor
   1798   *
   1799   * @param DOMElement summary
   1800   * @param {object} options
   1801   * @param {string=} options.reason: Indicates why the summary was selected. It's set to
   1802   *                  "filter-auto" when the summary was automatically selected as the result
   1803   *                  of the previous active summary being filtered out.
   1804   */
   1805  async showSummaryEditor(summary, options) {
   1806    const { details, editor } = this.#summaryDataMap.get(summary);
   1807    this.selectedEditor = editor;
   1808 
   1809    try {
   1810      if (!editor.sourceEditor) {
   1811        // only initialize source editor when we switch to this view
   1812        const inputElement = details.querySelector(".stylesheet-editor-input");
   1813        await editor.load(inputElement, this.#cssProperties);
   1814      }
   1815 
   1816      editor.onShow(options);
   1817 
   1818      this.#updatePrettyPrintButton();
   1819 
   1820      this.emit("editor-selected", editor);
   1821    } catch (e) {
   1822      console.error(e);
   1823    }
   1824  }
   1825 
   1826  /**
   1827   * Remove an item from the split view.
   1828   *
   1829   * @param DOMElement summary
   1830   *        Summary element of the item to remove.
   1831   */
   1832  removeSplitViewItem(summary) {
   1833    if (summary == this.#activeSummary) {
   1834      this.setActiveSummary(null);
   1835    }
   1836 
   1837    const data = this.#summaryDataMap.get(summary);
   1838    if (!data) {
   1839      return;
   1840    }
   1841 
   1842    summary.remove();
   1843    data.details.remove();
   1844  }
   1845 
   1846  /**
   1847   * Make the passed element visible or not, depending if it matches the current filter
   1848   *
   1849   * @param {Element} summary
   1850   * @param {object} options
   1851   * @param {boolean} options.triggerOnFilterStateChange: Set to false to avoid calling
   1852   *                  #onFilterStateChange directly here. This can be useful when this
   1853   *                  function is called for every item of the list, like in `setFilter`.
   1854   */
   1855  handleSummaryVisibility(summary, { triggerOnFilterStateChange = true } = {}) {
   1856    if (!this.#filter) {
   1857      summary.classList.remove(FILTERED_CLASSNAME);
   1858      return;
   1859    }
   1860 
   1861    const label = summary.querySelector(".stylesheet-name label");
   1862    const itemText = label.value.toLowerCase();
   1863    const matchesSearch = itemText.includes(this.#filter.toLowerCase());
   1864    summary.classList.toggle(FILTERED_CLASSNAME, !matchesSearch);
   1865 
   1866    if (this.#activeSummary == summary && !matchesSearch) {
   1867      this.setActiveSummary(null);
   1868    }
   1869 
   1870    if (triggerOnFilterStateChange) {
   1871      this.#onFilterStateChange();
   1872    }
   1873  }
   1874 
   1875  destroy() {
   1876    this.#commands.resourceCommand.unwatchResources(
   1877      [
   1878        this.#commands.resourceCommand.TYPES.DOCUMENT_EVENT,
   1879        this.#commands.resourceCommand.TYPES.STYLESHEET,
   1880      ],
   1881      {
   1882        onAvailable: this.#onResourceAvailable,
   1883        onUpdated: this.#onResourceUpdated,
   1884        onDestroyed: this.#onResourceDestroyed,
   1885      }
   1886    );
   1887    this.#commands.targetCommand.unwatchTargets({
   1888      types: [this.#commands.targetCommand.TYPES.FRAME],
   1889      onAvailable: this.#onTargetAvailable,
   1890      onDestroyed: this.#onTargetDestroyed,
   1891    });
   1892 
   1893    if (this.#uiAbortController) {
   1894      this.#uiAbortController.abort();
   1895      this.#uiAbortController = null;
   1896    }
   1897    this.#clearStyleSheetEditors();
   1898 
   1899    this.#seenSheets = null;
   1900    this.#filterInput = null;
   1901    this.#filterInputClearButton = null;
   1902    this.#nav = null;
   1903    this.#prettyPrintButton = null;
   1904    this.#side = null;
   1905    this.#tplDetails = null;
   1906    this.#tplSummary = null;
   1907 
   1908    const sidebar = this.#panelDoc.querySelector(".splitview-controller");
   1909    const sidebarWidth = parseInt(sidebar.style.width, 10);
   1910    if (!isNaN(sidebarWidth)) {
   1911      Services.prefs.setIntPref(PREF_NAV_WIDTH, sidebarWidth);
   1912    }
   1913 
   1914    if (this.#sourceMapPrefObserver) {
   1915      this.#sourceMapPrefObserver.off(
   1916        PREF_ORIG_SOURCES,
   1917        this.#onOrigSourcesPrefChanged
   1918      );
   1919      this.#sourceMapPrefObserver.destroy();
   1920      this.#sourceMapPrefObserver = null;
   1921    }
   1922 
   1923    if (this.#prefObserver) {
   1924      this.#prefObserver.off(
   1925        PREF_AT_RULES_SIDEBAR,
   1926        this.#onAtRulesSidebarPrefChanged
   1927      );
   1928      this.#prefObserver.destroy();
   1929      this.#prefObserver = null;
   1930    }
   1931 
   1932    if (this.#shortcuts) {
   1933      this.#shortcuts.destroy();
   1934      this.#shortcuts = null;
   1935    }
   1936  }
   1937 }