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