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