Bug 1946184 - Fix computing the CSD margin right after calling HideWindowChrome(...
[gecko.git] / devtools / client / styleeditor / StyleEditorUI.sys.mjs
blob0276efd96193caa4dc01247a08820d67b3f34350
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/. */
5 import {
6   loader,
7   require,
8 } from "resource://devtools/shared/loader/Loader.sys.mjs";
10 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
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";
20 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
22 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
23 const {
24   shortSource,
25 } = require("resource://devtools/shared/inspector/css-logic.js");
27 const lazy = {};
29 loader.lazyRequireGetter(
30   lazy,
31   "KeyCodes",
32   "resource://devtools/client/shared/keycodes.js",
33   true
36 loader.lazyRequireGetter(
37   lazy,
38   "OriginalSource",
39   "resource://devtools/client/styleeditor/original-source.js",
40   true
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"
52 loader.lazyRequireGetter(
53   lazy,
54   "openContentLink",
55   "resource://devtools/client/shared/link.js",
56   true
58 loader.lazyRequireGetter(
59   lazy,
60   "copyString",
61   "resource://devtools/shared/platform/clipboard.js",
62   true
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";
71 const FILTERED_CLASSNAME = "splitview-filtered";
72 const ALL_FILTERED_CLASSNAME = "splitview-all-filtered";
74 const HTML_NS = "http://www.w3.org/1999/xhtml";
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;
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();
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");
140     this.editors = [];
141     this.selectedEditor = null;
142     this.savedLocations = {};
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   }
158   get cssProperties() {
159     return this.#cssProperties;
160   }
162   get currentTarget() {
163     return this.#commands.targetCommand.targetFront;
164   }
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   }
175   /**
176    * Initiates the style editor ui creation, and start to track TargetCommand updates.
177    *
178    * @params {Object} options
179    * @params {Object} options.stylesheetToSelect
180    * @params {StyleSheetResource} options.stylesheetToSelect.stylesheet
181    * @params {Integer} options.stylesheetToSelect.line
182    * @params {Integer} options.stylesheetToSelect.column
183    */
184   async initialize(options = {}) {
185     this.createUI();
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     }
206     await this.#toolbox.resourceCommand.watchResources(
207       [this.#toolbox.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     });
216     this.#startLoadingStyleSheets();
217     await this.#toolbox.resourceCommand.watchResources(
218       [this.#toolbox.resourceCommand.TYPES.STYLESHEET],
219       {
220         onAvailable: this.#onResourceAvailable,
221         onUpdated: this.#onResourceUpdated,
222         onDestroyed: this.#onResourceDestroyed,
223       }
224     );
225     await this.#waitForLoadingStyleSheets();
226   }
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     );
245     const eventListenersConfig = { signal: this.#uiAbortController.signal };
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     }
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     );
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         }
281         this.selectedEditor.prettifySourceText();
282       },
283       eventListenersConfig
284     );
286     this.#root
287       .querySelector("#style-editor-options")
288       .addEventListener(
289         "click",
290         this.#onOptionsButtonClick,
291         eventListenersConfig
292       );
294     this.#filterInput.addEventListener(
295       "input",
296       this.#onFilterInputChange,
297       eventListenersConfig
298     );
300     this.#filterInputClearButton.addEventListener(
301       "click",
302       () => this.#clearFilterInput(),
303       eventListenersConfig
304     );
306     this.#panelDoc.addEventListener(
307       "contextmenu",
308       () => {
309         this.#contextMenuStyleSheet = null;
310       },
311       { ...eventListenersConfig, capture: true }
312     );
314     this.#optionsButton = this.#panelDoc.getElementById("style-editor-options");
316     this.#contextMenu = this.#panelDoc.getElementById("sidebar-context");
317     this.#contextMenu.addEventListener(
318       "popupshowing",
319       this.#updateContextMenuItems,
320       eventListenersConfig
321     );
323     this.#openLinkNewTabItem = this.#panelDoc.getElementById(
324       "context-openlinknewtab"
325     );
326     this.#openLinkNewTabItem.addEventListener(
327       "command",
328       this.#openLinkNewTab,
329       eventListenersConfig
330     );
332     this.#copyUrlItem = this.#panelDoc.getElementById("context-copyurl");
333     this.#copyUrlItem.addEventListener(
334       "command",
335       this.#copyUrl,
336       eventListenersConfig
337     );
339     // items list focus and search-on-type handling
340     this.#nav.addEventListener(
341       "keydown",
342       this.#onNavKeyDown,
343       eventListenersConfig
344     );
346     this.#shortcuts = new KeyShortcuts({
347       window: this.#window,
348     });
349     this.#shortcuts.on(
350       `CmdOrCtrl+${getString("focusFilterInput.commandkey")}`,
351       this.#onFocusFilterInputKeyboardShortcut
352     );
354     const nav = this.#panelDoc.querySelector(".splitview-controller");
355     nav.style.width = Services.prefs.getIntPref(PREF_NAV_WIDTH) + "px";
356   }
358   #clearFilterInput() {
359     this.#filterInput.value = "";
360     this.#onFilterInputChange();
361   }
363   #onFilterInputChange = () => {
364     this.#filter = this.#filterInput.value;
365     this.#filterInputClearButton.toggleAttribute("hidden", !this.#filter);
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     }
374     this.#onFilterStateChange();
376     if (this.#activeSummary == null) {
377       const firstVisibleSummary = Array.from(this.#nav.childNodes).find(
378         node => !node.classList.contains(FILTERED_CLASSNAME)
379       );
381       if (firstVisibleSummary) {
382         this.setActiveSummary(firstVisibleSummary, { reason: "filter-auto" });
383       }
384     }
385   };
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;
394     this.#nav.classList.toggle(ALL_FILTERED_CLASSNAME, allFiltered);
396     this.#filterInput
397       .closest(".devtools-searchbox")
398       .classList.toggle("devtools-searchbox-no-match", !!allFiltered);
399   }
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   };
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     }
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     }
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     );
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     }
462     if (elementToFocus !== undefined) {
463       event.stopPropagation();
464       event.preventDefault();
465       elementToFocus.focus();
466       return false;
467     }
469     return true;
470   };
472   /**
473    * Opens the Options Popup Menu
474    *
475    * @params {number} screenX
476    * @params {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     );
485     this.#optionsMenu.once("open", () => {
486       this.#optionsButton.setAttribute("open", true);
487     });
488     this.#optionsMenu.once("close", () => {
489       this.#optionsButton.removeAttribute("open");
490     });
492     this.#optionsMenu.popup(screenX, screenY, this.#toolbox.doc);
493   };
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.#toolbox.resourceCommand.getAllResources(
505       this.#toolbox.resourceCommand.TYPES.STYLESHEET
506     )) {
507       await this.#handleStyleSheetResource(resource);
508     }
510     this.#root.classList.remove("loading");
512     this.emit("stylesheets-refreshed");
513   };
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();
524       this.#styleSheetToSelect = {
525         stylesheet: href,
526         line,
527         col: ch,
528       };
529     }
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     }
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     }
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();
550     this.emit("stylesheets-clear");
551   };
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   }
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     }
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     }
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     }
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       );
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     }
644     return true;
645   }
647   #removeStyleSheet(resource, editor) {
648     this.#seenSheets.delete(resource);
649     this.#removeStyleSheetEditor(editor);
650   }
652   #getInlineStyleSheetsCount() {
653     return this.editors.filter(editor => !editor.styleSheet.href).length;
654   }
656   #getNewStyleSheetsCount() {
657     return this.editors.filter(editor => editor.isNew).length;
658   }
660   /**
661    * Finds the index to be shown in the Style Editor for inline or
662    * user-created style sheets, returns undefined if not of either type.
663    *
664    * @param {StyleSheet}  styleSheet
665    *        Object representing stylesheet
666    * @return {(Number|undefined)}
667    *         Optional Integer representing the index of the current stylesheet
668    *         among all stylesheets of its type (inline or user-created)
669    */
670   #getNextFriendlyIndex(styleSheet) {
671     if (styleSheet.href) {
672       return undefined;
673     }
675     return styleSheet.isNew
676       ? this.#getNewStyleSheetsCount()
677       : this.#getInlineStyleSheetsCount();
678   }
680   /**
681    * Add a new editor to the UI for a source.
682    *
683    * @param  {Resource} resource
684    *         The resource which is received from resource command.
685    * @return {Promise} that is resolved with the created StyleSheetEditor when
686    *                   the editor is fully initialized or rejected on error.
687    */
688   async #addStyleSheetEditor(resource) {
689     const editor = new StyleSheetEditor(
690       resource,
691       this.#window,
692       this.#getNextFriendlyIndex(resource)
693     );
695     editor.on("property-change", this.#summaryChange.bind(this, editor));
696     editor.on("at-rules-changed", this.#updateAtRulesList.bind(this, editor));
697     editor.on("linked-css-file", this.#summaryChange.bind(this, editor));
698     editor.on("linked-css-file-error", this.#summaryChange.bind(this, editor));
699     editor.on("error", this.#onError);
700     editor.on(
701       "filter-input-keyboard-shortcut",
702       this.#onFocusFilterInputKeyboardShortcut
703     );
705     // onAtRulesChanged fires at-rules-changed, so call the function after
706     // registering the listener in order to ensure to get at-rules-changed event.
707     editor.onAtRulesChanged(resource.atRules);
709     this.editors.push(editor);
711     try {
712       await editor.fetchSource();
713     } catch (e) {
714       // if the editor was destroyed while fetching dependencies, we don't want to go further.
715       if (!this.editors.includes(editor)) {
716         return null;
717       }
718       throw e;
719     }
721     this.#sourceLoaded(editor);
723     if (resource.fileName) {
724       this.emit("test:editor-updated", editor);
725     }
727     return editor;
728   }
730   /**
731    * Import a style sheet from file and asynchronously create a
732    * new stylesheet on the debuggee for it.
733    *
734    * @param {mixed} file
735    *        Optional nsIFile or filename string.
736    *        If not set a file picker will be shown.
737    * @param {nsIWindow} parentWindow
738    *        Optional parent window for the file picker.
739    */
740   #importFromFile(file, parentWindow) {
741     const onFileSelected = selectedFile => {
742       if (!selectedFile) {
743         // nothing selected
744         return;
745       }
746       lazy.NetUtil.asyncFetch(
747         {
748           uri: lazy.NetUtil.newURI(selectedFile),
749           loadingNode: this.#window.document,
750           securityFlags:
751             Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
752           contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER,
753         },
754         async (stream, status) => {
755           if (!Components.isSuccessCode(status)) {
756             this.emit("error", { key: LOAD_ERROR, level: "warning" });
757             return;
758           }
759           const source = lazy.NetUtil.readInputStreamToString(
760             stream,
761             stream.available()
762           );
763           stream.close();
765           const stylesheetsFront =
766             await this.currentTarget.getFront("stylesheets");
767           stylesheetsFront.addStyleSheet(source, selectedFile.path);
768         }
769       );
770     };
772     showFilePicker(file, false, parentWindow, onFileSelected);
773   }
775   /**
776    * Forward any error from a stylesheet.
777    *
778    * @param  {data} data
779    *         The event data
780    */
781   #onError = data => {
782     this.emit("error", data);
783   };
785   /**
786    * Toggle the original sources pref.
787    */
788   #toggleOrigSources() {
789     const isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES);
790     Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled);
791   }
793   /**
794    * Toggle the pref for showing the at-rules sidebar (for @media, @layer, @container, â€¦)
795    * in each editor.
796    */
797   #toggleAtRulesSidebar() {
798     const isEnabled = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR);
799     Services.prefs.setBoolPref(PREF_AT_RULES_SIDEBAR, !isEnabled);
800   }
802   /**
803    * Toggle the at-rules sidebar in each editor depending on the setting.
804    */
805   #onAtRulesSidebarPrefChanged = () => {
806     this.editors.forEach(this.#updateAtRulesList);
807   };
809   /**
810    * This method handles the following cases related to the context
811    * menu items "_openLinkNewTabItem" and "_copyUrlItem":
812    *
813    * 1) There was a stylesheet clicked on and it is external: show and
814    * enable the context menu item
815    * 2) There was a stylesheet clicked on and it is inline: show and
816    * disable the context menu item
817    * 3) There was no stylesheet clicked on (the right click happened
818    * below the list): hide the context menu
819    */
820   #updateContextMenuItems = async () => {
821     this.#openLinkNewTabItem.hidden = !this.#contextMenuStyleSheet;
822     this.#copyUrlItem.hidden = !this.#contextMenuStyleSheet;
824     if (this.#contextMenuStyleSheet) {
825       this.#openLinkNewTabItem.setAttribute(
826         "disabled",
827         !this.#contextMenuStyleSheet.href
828       );
829       this.#copyUrlItem.setAttribute(
830         "disabled",
831         !this.#contextMenuStyleSheet.href
832       );
833     }
834   };
836   /**
837    * Open a particular stylesheet in a new tab.
838    */
839   #openLinkNewTab = () => {
840     if (this.#contextMenuStyleSheet) {
841       lazy.openContentLink(this.#contextMenuStyleSheet.href);
842     }
843   };
845   /**
846    * Copies a stylesheet's URL.
847    */
848   #copyUrl = () => {
849     if (this.#contextMenuStyleSheet) {
850       lazy.copyString(this.#contextMenuStyleSheet.href);
851     }
852   };
854   /**
855    * Remove a particular stylesheet editor from the UI
856    *
857    * @param {StyleSheetEditor}  editor
858    *        The editor to remove.
859    */
860   #removeStyleSheetEditor(editor) {
861     if (editor.summary) {
862       this.removeSplitViewItem(editor.summary);
863     } else {
864       const self = this;
865       this.on("editor-added", function onAdd(added) {
866         if (editor == added) {
867           self.off("editor-added", onAdd);
868           self.removeSplitViewItem(editor.summary);
869         }
870       });
871     }
873     editor.destroy();
874     this.editors.splice(this.editors.indexOf(editor), 1);
875   }
877   /**
878    * Clear all the editors from the UI.
879    */
880   #clearStyleSheetEditors() {
881     for (const editor of this.editors) {
882       editor.destroy();
883     }
884     this.editors = [];
885   }
887   /**
888    * Called when a StyleSheetEditor's source has been fetched.
889    * Add new sidebar item and editor to the UI
890    *
891    * @param  {StyleSheetEditor} editor
892    *         Editor to create UI for.
893    */
894   #sourceLoaded(editor) {
895     // Create the detail and summary nodes from the templates node (declared in index.xhtml)
896     const details = this.#tplDetails.cloneNode(true);
897     details.id = "";
898     const summary = this.#tplSummary.cloneNode(true);
899     summary.id = "";
901     let ordinal = editor.styleSheet.styleSheetIndex;
902     ordinal = ordinal == -1 ? Number.MAX_SAFE_INTEGER : ordinal;
903     summary.style.order = ordinal;
904     summary.setAttribute("data-ordinal", ordinal);
906     const isSystem = !!editor.styleSheet.system;
907     if (isSystem) {
908       summary.classList.add("stylesheet-system");
909     }
911     this.#nav.appendChild(summary);
912     this.#side.appendChild(details);
914     this.#summaryDataMap.set(summary, {
915       details,
916       editor,
917     });
919     const createdEditor = editor;
920     createdEditor.summary = summary;
921     createdEditor.details = details;
923     const eventListenersConfig = { signal: this.#uiAbortController.signal };
925     summary.addEventListener(
926       "click",
927       event => {
928         event.stopPropagation();
929         this.setActiveSummary(summary);
930       },
931       eventListenersConfig
932     );
934     const stylesheetToggle = summary.querySelector(".stylesheet-toggle");
935     if (isSystem) {
936       stylesheetToggle.disabled = true;
937       this.#window.document.l10n.setAttributes(
938         stylesheetToggle,
939         "styleeditor-visibility-toggle-system"
940       );
941     } else {
942       stylesheetToggle.addEventListener(
943         "click",
944         event => {
945           event.stopPropagation();
946           event.target.blur();
948           createdEditor.toggleDisabled();
949         },
950         eventListenersConfig
951       );
952     }
954     summary.querySelector(".stylesheet-name").addEventListener(
955       "keypress",
956       event => {
957         if (event.keyCode == lazy.KeyCodes.DOM_VK_RETURN) {
958           this.setActiveSummary(summary);
959         }
960       },
961       eventListenersConfig
962     );
964     summary.querySelector(".stylesheet-saveButton").addEventListener(
965       "click",
966       event => {
967         event.stopPropagation();
968         event.target.blur();
970         createdEditor.saveToFile(createdEditor.savedFile);
971       },
972       eventListenersConfig
973     );
975     this.#updateSummaryForEditor(createdEditor, summary);
977     summary.addEventListener(
978       "contextmenu",
979       () => {
980         this.#contextMenuStyleSheet = createdEditor.styleSheet;
981       },
982       eventListenersConfig
983     );
985     summary.addEventListener(
986       "focus",
987       function onSummaryFocus(event) {
988         if (event.target == summary) {
989           // autofocus the stylesheet name
990           summary.querySelector(".stylesheet-name").focus();
991         }
992       },
993       eventListenersConfig
994     );
996     const sidebar = details.querySelector(".stylesheet-sidebar");
997     sidebar.style.width = Services.prefs.getIntPref(PREF_SIDEBAR_WIDTH) + "px";
999     const splitter = details.querySelector(".devtools-side-splitter");
1000     splitter.addEventListener(
1001       "mousemove",
1002       () => {
1003         const sidebarWidth = parseInt(sidebar.style.width, 10);
1004         if (!isNaN(sidebarWidth)) {
1005           Services.prefs.setIntPref(PREF_SIDEBAR_WIDTH, sidebarWidth);
1007           // update all at-rules sidebars for consistency
1008           const sidebars = [
1009             ...this.#panelDoc.querySelectorAll(".stylesheet-sidebar"),
1010           ];
1011           for (const atRuleSidebar of sidebars) {
1012             atRuleSidebar.style.width = sidebarWidth + "px";
1013           }
1014         }
1015       },
1016       eventListenersConfig
1017     );
1019     // autofocus if it's a new user-created stylesheet
1020     if (createdEditor.isNew) {
1021       this.#selectEditor(createdEditor);
1022     }
1024     if (this.#isEditorToSelect(createdEditor)) {
1025       this.switchToSelectedSheet();
1026     }
1028     // If this is the first stylesheet and there is no pending request to
1029     // select a particular style sheet, select this sheet.
1030     if (
1031       !this.selectedEditor &&
1032       !this.#styleSheetBoundToSelect &&
1033       createdEditor.styleSheet.styleSheetIndex == 0 &&
1034       !summary.classList.contains(FILTERED_CLASSNAME)
1035     ) {
1036       this.#selectEditor(createdEditor);
1037     }
1038     this.emit("editor-added", createdEditor);
1039   }
1041   /**
1042    * Switch to the editor that has been marked to be selected.
1043    *
1044    * @return {Promise}
1045    *         Promise that will resolve when the editor is selected.
1046    */
1047   switchToSelectedSheet() {
1048     const toSelect = this.#styleSheetToSelect;
1050     for (const editor of this.editors) {
1051       if (this.#isEditorToSelect(editor)) {
1052         // The _styleSheetBoundToSelect will always hold the latest pending
1053         // requested style sheet (with line and column) which is not yet
1054         // selected by the source editor. Only after we select that particular
1055         // editor and go the required line and column, it will become null.
1056         this.#styleSheetBoundToSelect = this.#styleSheetToSelect;
1057         this.#styleSheetToSelect = null;
1058         return this.#selectEditor(editor, toSelect.line, toSelect.col);
1059       }
1060     }
1062     return Promise.resolve();
1063   }
1065   /**
1066    * Returns whether a given editor is the current editor to be selected. Tests
1067    * based on href or underlying stylesheet.
1068    *
1069    * @param {StyleSheetEditor} editor
1070    *        The editor to test.
1071    */
1072   #isEditorToSelect(editor) {
1073     const toSelect = this.#styleSheetToSelect;
1074     if (!toSelect) {
1075       return false;
1076     }
1077     const isHref =
1078       toSelect.stylesheet === null || typeof toSelect.stylesheet == "string";
1080     return (
1081       (isHref && editor.styleSheet.href == toSelect.stylesheet) ||
1082       toSelect.stylesheet == editor.styleSheet
1083     );
1084   }
1086   /**
1087    * Select an editor in the UI.
1088    *
1089    * @param  {StyleSheetEditor} editor
1090    *         Editor to switch to.
1091    * @param  {number} line
1092    *         Line number to jump to
1093    * @param  {number} col
1094    *         Column number to jump to
1095    * @return {Promise}
1096    *         Promise that will resolve when the editor is selected and ready
1097    *         to be used.
1098    */
1099   #selectEditor(editor, line = null, col = null) {
1100     // Don't go further if the editor was destroyed in the meantime
1101     if (!this.editors.includes(editor)) {
1102       return null;
1103     }
1105     const editorPromise = editor.getSourceEditor().then(() => {
1106       // line/col are null when the style editor is initialized and the first stylesheet
1107       // editor is selected. Unfortunately, this function might be called also when the
1108       // panel is opened from clicking on a CSS warning in the WebConsole panel, in which
1109       // case we have specific line+col.
1110       // There's no guarantee which one could be called first, and it happened that we
1111       // were setting the cursor once for the correct line coming from the webconsole,
1112       // and then re-setting it to the default value (which was <0,0>).
1113       // To avoid the race, we simply don't explicitly set the cursor to any default value,
1114       // which is not a big deal as CodeMirror does init it to <0,0> anyway.
1115       // See Bug 1738124 for more information.
1116       if (line !== null || col !== null) {
1117         editor.setCursor(line, col);
1118       }
1119       this.#styleSheetBoundToSelect = null;
1120     });
1122     const summaryPromise = this.getEditorSummary(editor).then(summary => {
1123       // Don't go further if the editor was destroyed in the meantime
1124       if (!this.editors.includes(editor)) {
1125         throw new Error("Editor was destroyed");
1126       }
1127       this.setActiveSummary(summary);
1128     });
1130     return Promise.all([editorPromise, summaryPromise]);
1131   }
1133   getEditorSummary(editor) {
1134     const self = this;
1136     if (editor.summary) {
1137       return Promise.resolve(editor.summary);
1138     }
1140     return new Promise(resolve => {
1141       this.on("editor-added", function onAdd(selected) {
1142         if (selected == editor) {
1143           self.off("editor-added", onAdd);
1144           resolve(editor.summary);
1145         }
1146       });
1147     });
1148   }
1150   getEditorDetails(editor) {
1151     const self = this;
1153     if (editor.details) {
1154       return Promise.resolve(editor.details);
1155     }
1157     return new Promise(resolve => {
1158       this.on("editor-added", function onAdd(selected) {
1159         if (selected == editor) {
1160           self.off("editor-added", onAdd);
1161           resolve(editor.details);
1162         }
1163       });
1164     });
1165   }
1167   /**
1168    * Returns an identifier for the given style sheet.
1169    *
1170    * @param {StyleSheet} styleSheet
1171    *        The style sheet to be identified.
1172    */
1173   getStyleSheetIdentifier(styleSheet) {
1174     // Identify inline style sheets by their host page URI and index
1175     // at the page.
1176     return styleSheet.href
1177       ? styleSheet.href
1178       : "inline-" + styleSheet.styleSheetIndex + "-at-" + styleSheet.nodeHref;
1179   }
1181   /**
1182    * Get the OriginalSource object for a given original sourceId returned from
1183    * the sourcemap worker service.
1184    *
1185    * @param {string} sourceId
1186    *        The ID to search for from the sourcemap worker.
1187    *
1188    * @return {OriginalSource | null}
1189    */
1190   getOriginalSourceSheet(sourceId) {
1191     for (const editor of this.editors) {
1192       const { styleSheet } = editor;
1193       if (styleSheet.isOriginalSource && styleSheet.sourceId === sourceId) {
1194         return styleSheet;
1195       }
1196     }
1197     return null;
1198   }
1200   /**
1201    * Given an URL, find a stylesheet resource with that URL, if one has been
1202    * loaded into the editor.js
1203    *
1204    * Do not use this unless you have no other way to get a StyleSheet resource
1205    * multiple sheets could share the same URL, so this will give you _one_
1206    * of possibly many sheets with that URL.
1207    *
1208    * @param {string} url
1209    *        An arbitrary URL to search for.
1210    *
1211    * @return {StyleSheetResource|null}
1212    */
1213   getStylesheetResourceForGeneratedURL(url) {
1214     for (const styleSheet of this.#seenSheets.keys()) {
1215       const sheetURL = styleSheet.href || styleSheet.nodeHref;
1216       if (!styleSheet.isOriginalSource && sheetURL === url) {
1217         return styleSheet;
1218       }
1219     }
1220     return null;
1221   }
1223   /**
1224    * selects a stylesheet and optionally moves the cursor to a selected line
1225    *
1226    * @param {StyleSheetResource} stylesheet
1227    *        Stylesheet to select or href of stylesheet to select
1228    * @param {Number} line
1229    *        Line to which the caret should be moved (zero-indexed).
1230    * @param {Number} col
1231    *        Column to which the caret should be moved (zero-indexed).
1232    * @return {Promise}
1233    *         Promise that will resolve when the editor is selected and ready
1234    *         to be used.
1235    */
1236   selectStyleSheet(stylesheet, line, col) {
1237     this.#styleSheetToSelect = {
1238       stylesheet,
1239       line,
1240       col,
1241     };
1243     /* Switch to the editor for this sheet, if it exists yet.
1244        Otherwise each editor will be checked when it's created. */
1245     return this.switchToSelectedSheet();
1246   }
1248   /**
1249    * Handler for an editor's 'property-changed' event.
1250    * Update the summary in the UI.
1251    *
1252    * @param  {StyleSheetEditor} editor
1253    *         Editor for which a property has changed
1254    */
1255   #summaryChange(editor) {
1256     this.#updateSummaryForEditor(editor);
1257   }
1259   /**
1260    * Update split view summary of given StyleEditor instance.
1261    *
1262    * @param {StyleSheetEditor} editor
1263    * @param {DOMElement} summary
1264    *        Optional item's summary element to update. If none, item
1265    *        corresponding to passed editor is used.
1266    */
1267   #updateSummaryForEditor(editor, summary) {
1268     summary = summary || editor.summary;
1269     if (!summary) {
1270       return;
1271     }
1273     let ruleCount = editor.styleSheet.ruleCount;
1274     if (editor.styleSheet.relatedStyleSheet) {
1275       ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount;
1276     }
1277     if (ruleCount === undefined) {
1278       ruleCount = "-";
1279     }
1281     this.#panelDoc.l10n.setArgs(
1282       summary.querySelector(".stylesheet-rule-count"),
1283       {
1284         ruleCount,
1285       }
1286     );
1288     summary.classList.toggle("disabled", !!editor.styleSheet.disabled);
1289     summary.classList.toggle("unsaved", !!editor.unsaved);
1290     summary.classList.toggle("linked-file-error", !!editor.linkedCSSFileError);
1292     const label = summary.querySelector(".stylesheet-name > label");
1293     label.setAttribute("value", editor.friendlyName);
1294     if (editor.styleSheet.href) {
1295       label.setAttribute("tooltiptext", editor.styleSheet.href);
1296     }
1298     let linkedCSSSource = "";
1299     if (editor.linkedCSSFile) {
1300       linkedCSSSource = PathUtils.filename(editor.linkedCSSFile);
1301     } else if (editor.styleSheet.relatedStyleSheet) {
1302       // Compute a friendly name for the related generated source
1303       // (relatedStyleSheet is set on original CSS to refer to the generated one)
1304       linkedCSSSource = shortSource(editor.styleSheet.relatedStyleSheet);
1305       try {
1306         linkedCSSSource = decodeURI(linkedCSSSource);
1307       } catch (e) {}
1308     }
1309     text(summary, ".stylesheet-linked-file", linkedCSSSource);
1310     text(summary, ".stylesheet-title", editor.styleSheet.title || "");
1312     // We may need to change the summary visibility as a result of the changes.
1313     this.handleSummaryVisibility(summary);
1314   }
1316   /**
1317    * Update the pretty print button.
1318    * The button will be disabled if the selected file is an original file.
1319    */
1320   #updatePrettyPrintButton() {
1321     const disable =
1322       !this.selectedEditor || !!this.selectedEditor.styleSheet.isOriginalSource;
1324     // Only update the button if its state needs it
1325     if (disable !== this.#prettyPrintButton.hasAttribute("disabled")) {
1326       this.#prettyPrintButton.toggleAttribute("disabled");
1327       const l10nString = disable
1328         ? "styleeditor-pretty-print-button-disabled"
1329         : "styleeditor-pretty-print-button";
1330       this.#window.document.l10n.setAttributes(
1331         this.#prettyPrintButton,
1332         l10nString
1333       );
1334     }
1335   }
1337   /**
1338    * Update the at-rules sidebar for an editor. Hide if there are no rules
1339    * Display a list of the at-rules (@media, @layer, @container, â€¦) in the editor's associated style sheet.
1340    * Emits a 'at-rules-list-changed' event after updating the UI.
1341    *
1342    * @param  {StyleSheetEditor} editor
1343    *         Editor to update sidebar of
1344    */
1345   #updateAtRulesList = editor => {
1346     (async function () {
1347       const details = await this.getEditorDetails(editor);
1348       const list = details.querySelector(".stylesheet-at-rules-list");
1350       while (list.firstChild) {
1351         list.firstChild.remove();
1352       }
1354       const rules = editor.atRules;
1355       const showSidebar = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR);
1356       const sidebar = details.querySelector(".stylesheet-sidebar");
1358       let inSource = false;
1360       for (const rule of rules) {
1361         const { line, column } = rule;
1363         let location = {
1364           line,
1365           column,
1366           source: editor.styleSheet.href,
1367           styleSheet: editor.styleSheet,
1368         };
1369         if (editor.styleSheet.isOriginalSource) {
1370           const styleSheet = editor.cssSheet;
1371           location = await editor.styleSheet.getOriginalLocation(
1372             styleSheet,
1373             line,
1374             column
1375           );
1376         }
1378         // this at-rule is from a different original source
1379         if (location.source != editor.styleSheet.href) {
1380           continue;
1381         }
1382         inSource = true;
1384         const div = this.#panelDoc.createElementNS(HTML_NS, "div");
1385         div.classList.add("at-rule-label", rule.type);
1386         div.addEventListener(
1387           "click",
1388           this.#jumpToLocation.bind(this, location)
1389         );
1391         const ruleTextContainer = this.#panelDoc.createElementNS(
1392           HTML_NS,
1393           "div"
1394         );
1395         const type = this.#panelDoc.createElementNS(HTML_NS, "span");
1396         type.className = "at-rule-type";
1397         type.append(this.#panelDoc.createTextNode(`@${rule.type}\u00A0`));
1398         if (rule.type == "layer" && rule.layerName) {
1399           type.append(this.#panelDoc.createTextNode(`${rule.layerName}\u00A0`));
1400         } else if (rule.type === "property") {
1401           type.append(
1402             this.#panelDoc.createTextNode(`${rule.propertyName}\u00A0`)
1403           );
1404         }
1406         const cond = this.#panelDoc.createElementNS(HTML_NS, "span");
1407         cond.className = "at-rule-condition";
1408         if (rule.type == "media" && !rule.matches) {
1409           cond.classList.add("media-condition-unmatched");
1410         }
1411         if (this.#commands.descriptorFront.isLocalTab) {
1412           this.#setConditionContents(cond, rule.conditionText, rule.type);
1413         } else {
1414           cond.textContent = rule.conditionText;
1415         }
1417         const link = this.#panelDoc.createElementNS(HTML_NS, "div");
1418         link.className = "at-rule-line theme-link";
1419         if (location.line != -1) {
1420           link.textContent = ":" + location.line;
1421         }
1423         ruleTextContainer.append(type, cond);
1424         div.append(ruleTextContainer, link);
1425         list.appendChild(div);
1426       }
1428       sidebar.hidden = !showSidebar || !inSource;
1430       this.emit("at-rules-list-changed", editor);
1431     })
1432       .bind(this)()
1433       .catch(console.error);
1434   };
1436   /**
1437    * Set the condition text for the at-rule element.
1438    * For media queries, it also injects links to open RDM at a specific size.
1439    *
1440    * @param {HTMLElement} element
1441    *        The element corresponding to the media sidebar condition
1442    * @param {String} ruleConditionText
1443    *        The rule conditionText
1444    * @param {String} type
1445    *        The type of the at-rule (e.g. "media", "layer", "supports", â€¦)
1446    */
1447   #setConditionContents(element, ruleConditionText, type) {
1448     if (!ruleConditionText) {
1449       return;
1450     }
1452     // For non-media rules, we don't do anything more than displaying the conditionText
1453     // as there are no other condition text that would justify opening RDM at a specific
1454     // size (e.g. `@container` condition is relative to a container size, which varies
1455     // depending the node the rule applies to).
1456     if (type !== "media") {
1457       const node = this.#panelDoc.createTextNode(ruleConditionText);
1458       element.appendChild(node);
1459       return;
1460     }
1462     const minMaxPattern = /(min\-|max\-)(width|height):\s\d+(px)/gi;
1464     let match = minMaxPattern.exec(ruleConditionText);
1465     let lastParsed = 0;
1466     while (match && match.index != minMaxPattern.lastIndex) {
1467       const matchEnd = match.index + match[0].length;
1468       const node = this.#panelDoc.createTextNode(
1469         ruleConditionText.substring(lastParsed, match.index)
1470       );
1471       element.appendChild(node);
1473       const link = this.#panelDoc.createElementNS(HTML_NS, "a");
1474       link.href = "#";
1475       link.className = "media-responsive-mode-toggle";
1476       link.textContent = ruleConditionText.substring(match.index, matchEnd);
1477       link.addEventListener("click", this.#onMediaConditionClick.bind(this));
1478       element.appendChild(link);
1480       match = minMaxPattern.exec(ruleConditionText);
1481       lastParsed = matchEnd;
1482     }
1484     const node = this.#panelDoc.createTextNode(
1485       ruleConditionText.substring(lastParsed, ruleConditionText.length)
1486     );
1487     element.appendChild(node);
1488   }
1490   /**
1491    * Called when a media condition is clicked
1492    * If a responsive mode link is clicked, it will launch it.
1493    *
1494    * @param {object} e
1495    *        Event object
1496    */
1497   #onMediaConditionClick(e) {
1498     const conditionText = e.target.textContent;
1499     const isWidthCond = conditionText.toLowerCase().indexOf("width") > -1;
1500     const mediaVal = parseInt(/\d+/.exec(conditionText), 10);
1502     const options = isWidthCond ? { width: mediaVal } : { height: mediaVal };
1503     this.#launchResponsiveMode(options);
1504     e.preventDefault();
1505     e.stopPropagation();
1506   }
1508   /**
1509    * Launches the responsive mode with a specific width or height.
1510    *
1511    * @param  {object} options
1512    *         Object with width or/and height properties.
1513    */
1514   async #launchResponsiveMode(options = {}) {
1515     const tab = this.#commands.descriptorFront.localTab;
1516     const win = tab.ownerDocument.defaultView;
1518     await lazy.ResponsiveUIManager.openIfNeeded(win, tab, {
1519       trigger: "style_editor",
1520     });
1521     this.emit("responsive-mode-opened");
1523     lazy.ResponsiveUIManager.getResponsiveUIForTab(tab).setViewportSize(
1524       options
1525     );
1526   }
1528   /**
1529    * Jump cursor to the editor for a stylesheet and line number for a rule.
1530    *
1531    * @param  {object} location
1532    *         Location object with 'line', 'column', and 'source' properties.
1533    */
1534   #jumpToLocation(location) {
1535     const source = location.styleSheet || location.source;
1536     this.selectStyleSheet(source, location.line - 1, location.column - 1);
1537   }
1539   #startLoadingStyleSheets() {
1540     this.#root.classList.add("loading");
1541     this.#loadingStyleSheets = [];
1542   }
1544   async #waitForLoadingStyleSheets() {
1545     while (this.#loadingStyleSheets?.length > 0) {
1546       const pending = this.#loadingStyleSheets;
1547       this.#loadingStyleSheets = [];
1548       await Promise.all(pending);
1549     }
1551     this.#loadingStyleSheets = null;
1552     this.#root.classList.remove("loading");
1553     this.emit("reloaded");
1554   }
1556   async #handleStyleSheetResource(resource) {
1557     try {
1558       // The fileName is in resource means this stylesheet was imported from file by user.
1559       const { fileName } = resource;
1560       let file = fileName ? new lazy.FileUtils.File(fileName) : null;
1562       // recall location of saved file for this sheet after page reload
1563       if (!file) {
1564         const identifier = this.getStyleSheetIdentifier(resource);
1565         const savedFile = this.savedLocations[identifier];
1566         if (savedFile) {
1567           file = savedFile;
1568         }
1569       }
1570       resource.file = file;
1572       await this.#addStyleSheet(resource);
1573     } catch (e) {
1574       console.error(e);
1575       this.emit("error", { key: LOAD_ERROR, level: "warning" });
1576     }
1577   }
1579   // onAvailable is a mandatory argument for watchTargets,
1580   // but we don't do anything when a new target gets created.
1581   #onTargetAvailable = () => {};
1583   #onTargetDestroyed = ({ targetFront }) => {
1584     // Iterate over a copy of the list in order to prevent skipping
1585     // over some items when removing items of this list
1586     const editorsCopy = [...this.editors];
1587     for (const editor of editorsCopy) {
1588       const { styleSheet } = editor;
1589       if (styleSheet.targetFront == targetFront) {
1590         this.#removeStyleSheet(styleSheet, editor);
1591       }
1592     }
1593   };
1595   #onResourceAvailable = async resources => {
1596     const promises = [];
1597     for (const resource of resources) {
1598       if (
1599         resource.resourceType === this.#toolbox.resourceCommand.TYPES.STYLESHEET
1600       ) {
1601         const onStyleSheetHandled = this.#handleStyleSheetResource(resource);
1603         if (this.#loadingStyleSheets) {
1604           // In case of reloading/navigating and panel's opening
1605           this.#loadingStyleSheets.push(onStyleSheetHandled);
1606         }
1607         promises.push(onStyleSheetHandled);
1608         continue;
1609       }
1611       if (!resource.targetFront.isTopLevel) {
1612         continue;
1613       }
1615       if (resource.name === "will-navigate") {
1616         this.#startLoadingStyleSheets();
1617         this.#clear();
1618       } else if (resource.name === "dom-complete") {
1619         promises.push(this.#waitForLoadingStyleSheets());
1620       }
1621     }
1622     await Promise.all(promises);
1623   };
1625   #onResourceUpdated = async updates => {
1626     // The editors are instantiated asynchronously from onResourceAvailable,
1627     // but we may receive updates right after due to throttling.
1628     // Ensure waiting for this async work before trying to update the related editors.
1629     await this.#waitForLoadingStyleSheets();
1631     for (const { resource, update } of updates) {
1632       if (
1633         update.resourceType === this.#toolbox.resourceCommand.TYPES.STYLESHEET
1634       ) {
1635         const editor = this.editors.find(
1636           e => e.resourceId === update.resourceId
1637         );
1639         if (!editor) {
1640           console.warn(
1641             "Could not find StyleEditor to apply STYLESHEET resource update"
1642           );
1643           continue;
1644         }
1646         switch (update.updateType) {
1647           case "style-applied": {
1648             editor.onStyleApplied(update);
1649             break;
1650           }
1651           case "property-change": {
1652             for (const [property, value] of Object.entries(
1653               update.resourceUpdates
1654             )) {
1655               editor.onPropertyChange(property, value);
1656             }
1657             break;
1658           }
1659           case "at-rules-changed":
1660           case "matches-change": {
1661             editor.onAtRulesChanged(resource.atRules);
1662             break;
1663           }
1664         }
1665       }
1666     }
1667   };
1669   #onResourceDestroyed = resources => {
1670     for (const resource of resources) {
1671       if (
1672         resource.resourceType !== this.#toolbox.resourceCommand.TYPES.STYLESHEET
1673       ) {
1674         continue;
1675       }
1677       const editorToRemove = this.editors.find(
1678         editor => editor.styleSheet.resourceId == resource.resourceId
1679       );
1681       if (editorToRemove) {
1682         const { styleSheet } = editorToRemove;
1683         this.#removeStyleSheet(styleSheet, editorToRemove);
1684       }
1685     }
1686   };
1688   /**
1689    * Set the active item's summary element.
1690    *
1691    * @param DOMElement summary
1692    * @param {Object} options
1693    * @param {String=} options.reason: Indicates why the summary was selected. It's set to
1694    *                  "filter-auto" when the summary was automatically selected as the result
1695    *                  of the previous active summary being filtered out.
1696    */
1697   setActiveSummary(summary, options = {}) {
1698     if (summary == this.#activeSummary) {
1699       return;
1700     }
1702     if (this.#activeSummary) {
1703       const binding = this.#summaryDataMap.get(this.#activeSummary);
1705       this.#activeSummary.classList.remove("splitview-active");
1706       binding.details.classList.remove("splitview-active");
1707     }
1709     this.#activeSummary = summary;
1710     if (!summary) {
1711       this.selectedEditor = null;
1712       return;
1713     }
1715     const { details } = this.#summaryDataMap.get(summary);
1716     summary.classList.add("splitview-active");
1717     details.classList.add("splitview-active");
1719     this.showSummaryEditor(summary, options);
1720   }
1722   /**
1723    * Show summary's associated editor
1724    *
1725    * @param DOMElement summary
1726    * @param {Object} options
1727    * @param {String=} options.reason: Indicates why the summary was selected. It's set to
1728    *                  "filter-auto" when the summary was automatically selected as the result
1729    *                  of the previous active summary being filtered out.
1730    */
1731   async showSummaryEditor(summary, options) {
1732     const { details, editor } = this.#summaryDataMap.get(summary);
1733     this.selectedEditor = editor;
1735     try {
1736       if (!editor.sourceEditor) {
1737         // only initialize source editor when we switch to this view
1738         const inputElement = details.querySelector(".stylesheet-editor-input");
1739         await editor.load(inputElement, this.#cssProperties);
1740       }
1742       editor.onShow(options);
1744       this.#updatePrettyPrintButton();
1746       this.emit("editor-selected", editor);
1747     } catch (e) {
1748       console.error(e);
1749     }
1750   }
1752   /**
1753    * Remove an item from the split view.
1754    *
1755    * @param DOMElement summary
1756    *        Summary element of the item to remove.
1757    */
1758   removeSplitViewItem(summary) {
1759     if (summary == this.#activeSummary) {
1760       this.setActiveSummary(null);
1761     }
1763     const data = this.#summaryDataMap.get(summary);
1764     if (!data) {
1765       return;
1766     }
1768     summary.remove();
1769     data.details.remove();
1770   }
1772   /**
1773    * Make the passed element visible or not, depending if it matches the current filter
1774    *
1775    * @param {Element} summary
1776    * @param {Object} options
1777    * @param {Boolean} options.triggerOnFilterStateChange: Set to false to avoid calling
1778    *                  #onFilterStateChange directly here. This can be useful when this
1779    *                  function is called for every item of the list, like in `setFilter`.
1780    */
1781   handleSummaryVisibility(summary, { triggerOnFilterStateChange = true } = {}) {
1782     if (!this.#filter) {
1783       summary.classList.remove(FILTERED_CLASSNAME);
1784       return;
1785     }
1787     const label = summary.querySelector(".stylesheet-name label");
1788     const itemText = label.value.toLowerCase();
1789     const matchesSearch = itemText.includes(this.#filter.toLowerCase());
1790     summary.classList.toggle(FILTERED_CLASSNAME, !matchesSearch);
1792     if (this.#activeSummary == summary && !matchesSearch) {
1793       this.setActiveSummary(null);
1794     }
1796     if (triggerOnFilterStateChange) {
1797       this.#onFilterStateChange();
1798     }
1799   }
1801   destroy() {
1802     this.#toolbox.resourceCommand.unwatchResources(
1803       [
1804         this.#toolbox.resourceCommand.TYPES.DOCUMENT_EVENT,
1805         this.#toolbox.resourceCommand.TYPES.STYLESHEET,
1806       ],
1807       {
1808         onAvailable: this.#onResourceAvailable,
1809         onUpdated: this.#onResourceUpdated,
1810         onDestroyed: this.#onResourceDestroyed,
1811       }
1812     );
1813     this.#commands.targetCommand.unwatchTargets({
1814       types: [this.#commands.targetCommand.TYPES.FRAME],
1815       onAvailable: this.#onTargetAvailable,
1816       onDestroyed: this.#onTargetDestroyed,
1817     });
1819     if (this.#uiAbortController) {
1820       this.#uiAbortController.abort();
1821       this.#uiAbortController = null;
1822     }
1823     this.#clearStyleSheetEditors();
1825     this.#seenSheets = null;
1826     this.#filterInput = null;
1827     this.#filterInputClearButton = null;
1828     this.#nav = null;
1829     this.#prettyPrintButton = null;
1830     this.#side = null;
1831     this.#tplDetails = null;
1832     this.#tplSummary = null;
1834     const sidebar = this.#panelDoc.querySelector(".splitview-controller");
1835     const sidebarWidth = parseInt(sidebar.style.width, 10);
1836     if (!isNaN(sidebarWidth)) {
1837       Services.prefs.setIntPref(PREF_NAV_WIDTH, sidebarWidth);
1838     }
1840     if (this.#sourceMapPrefObserver) {
1841       this.#sourceMapPrefObserver.off(
1842         PREF_ORIG_SOURCES,
1843         this.#onOrigSourcesPrefChanged
1844       );
1845       this.#sourceMapPrefObserver.destroy();
1846       this.#sourceMapPrefObserver = null;
1847     }
1849     if (this.#prefObserver) {
1850       this.#prefObserver.off(
1851         PREF_AT_RULES_SIDEBAR,
1852         this.#onAtRulesSidebarPrefChanged
1853       );
1854       this.#prefObserver.destroy();
1855       this.#prefObserver = null;
1856     }
1858     if (this.#shortcuts) {
1859       this.#shortcuts.destroy();
1860       this.#shortcuts = null;
1861     }
1862   }