JSTerm.js (51065B)
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 "use strict"; 6 7 const { debounce } = require("resource://devtools/shared/debounce.js"); 8 const isMacOS = Services.appinfo.OS === "Darwin"; 9 10 const lazy = {}; 11 ChromeUtils.defineESModuleGetters(lazy, { 12 getFocusableElements: "resource://devtools/client/shared/focus.mjs", 13 }); 14 15 loader.lazyRequireGetter(this, "Debugger", "Debugger"); 16 loader.lazyRequireGetter( 17 this, 18 "EventEmitter", 19 "resource://devtools/shared/event-emitter.js" 20 ); 21 loader.lazyRequireGetter( 22 this, 23 "AutocompletePopup", 24 "resource://devtools/client/shared/autocomplete-popup.js" 25 ); 26 27 loader.lazyRequireGetter( 28 this, 29 "PropTypes", 30 "resource://devtools/client/shared/vendor/react-prop-types.js" 31 ); 32 loader.lazyRequireGetter( 33 this, 34 "KeyCodes", 35 "resource://devtools/client/shared/keycodes.js", 36 true 37 ); 38 loader.lazyRequireGetter( 39 this, 40 "Editor", 41 "resource://devtools/client/shared/sourceeditor/editor.js" 42 ); 43 loader.lazyRequireGetter( 44 this, 45 "l10n", 46 "resource://devtools/client/webconsole/utils/messages.js", 47 true 48 ); 49 loader.lazyRequireGetter( 50 this, 51 "saveAs", 52 "resource://devtools/shared/DevToolsUtils.js", 53 true 54 ); 55 loader.lazyRequireGetter( 56 this, 57 "beautify", 58 "resource://devtools/shared/jsbeautify/beautify.js" 59 ); 60 61 // React & Redux 62 const { 63 Component, 64 createFactory, 65 } = require("resource://devtools/client/shared/vendor/react.mjs"); 66 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 67 const { 68 connect, 69 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 70 71 // History Modules 72 const { 73 getHistory, 74 getHistoryValue, 75 } = require("resource://devtools/client/webconsole/selectors/history.js"); 76 const { 77 getAutocompleteState, 78 } = require("resource://devtools/client/webconsole/selectors/autocomplete.js"); 79 const actions = require("resource://devtools/client/webconsole/actions/index.js"); 80 81 const EvaluationContextSelector = createFactory( 82 require("resource://devtools/client/webconsole/components/Input/EvaluationContextSelector.js") 83 ); 84 85 // Constants used for defining the direction of JSTerm input history navigation. 86 const { 87 HISTORY_BACK, 88 HISTORY_FORWARD, 89 } = require("resource://devtools/client/webconsole/constants.js"); 90 91 const JSTERM_CODEMIRROR_ORIGIN = "jsterm"; 92 93 /** 94 * Create a JSTerminal (a JavaScript command line). This is attached to an 95 * existing HeadsUpDisplay (a Web Console instance). This code is responsible 96 * with handling command line input and code evaluation. 97 */ 98 class JSTerm extends Component { 99 static get propTypes() { 100 return { 101 // Returns previous or next value from the history 102 // (depending on direction argument). 103 getValueFromHistory: PropTypes.func.isRequired, 104 // History of executed expression (state). 105 history: PropTypes.object.isRequired, 106 // Console object. 107 webConsoleUI: PropTypes.object.isRequired, 108 // Needed for opening context menu 109 serviceContainer: PropTypes.object.isRequired, 110 // Handler for clipboard 'paste' event (also used for 'drop' event, callback). 111 onPaste: PropTypes.func, 112 // Evaluate provided expression. 113 evaluateExpression: PropTypes.func.isRequired, 114 // Update position in the history after executing an expression (action). 115 updateHistoryPosition: PropTypes.func.isRequired, 116 // Update autocomplete popup state. 117 autocompleteUpdate: PropTypes.func.isRequired, 118 autocompleteClear: PropTypes.func.isRequired, 119 // Data to be displayed in the autocomplete popup. 120 autocompleteData: PropTypes.object.isRequired, 121 // Toggle the editor mode. 122 editorToggle: PropTypes.func.isRequired, 123 // Dismiss the editor onboarding UI. 124 editorOnboardingDismiss: PropTypes.func.isRequired, 125 // Set the last JS input value. 126 terminalInputChanged: PropTypes.func.isRequired, 127 // Is the input in editor mode. 128 editorMode: PropTypes.bool, 129 editorWidth: PropTypes.number, 130 editorPrettifiedAt: PropTypes.number, 131 showEditorOnboarding: PropTypes.bool, 132 autocomplete: PropTypes.bool, 133 autocompletePopupPosition: PropTypes.string, 134 inputEnabled: PropTypes.bool, 135 }; 136 } 137 138 constructor(props) { 139 super(props); 140 141 const { webConsoleUI } = props; 142 143 this.webConsoleUI = webConsoleUI; 144 this.hudId = this.webConsoleUI.hudId; 145 146 this._onEditorChanges = this._onEditorChanges.bind(this); 147 this._onEditorBeforeChange = this._onEditorBeforeChange.bind(this); 148 this._onEditorKeyHandled = this._onEditorKeyHandled.bind(this); 149 this.onContextMenu = this.onContextMenu.bind(this); 150 this.imperativeUpdate = this.imperativeUpdate.bind(this); 151 152 // We debounce the autocompleteUpdate so we don't send too many requests to the server 153 // as the user is typing. 154 // The delay should be small enough to be unnoticed by the user. 155 this.autocompleteUpdate = debounce(this.props.autocompleteUpdate, 75, this); 156 157 // Updates to the terminal input which can trigger eager evaluations are 158 // similarly debounced. 159 this.terminalInputChanged = debounce( 160 this.props.terminalInputChanged, 161 75, 162 this 163 ); 164 165 // Because the autocomplete has a slight delay (75ms), there can be time where the 166 // codeMirror completion text is out-of-date, which might lead to issue when the user 167 // accept the autocompletion while the update of the completion text is still pending. 168 // In order to account for that, we put any future value of the completion text in 169 // this property. 170 this.pendingCompletionText = null; 171 172 /** 173 * Last input value. 174 * 175 * @type string 176 */ 177 this.lastInputValue = ""; 178 179 this.autocompletePopup = null; 180 181 EventEmitter.decorate(this); 182 webConsoleUI.jsterm = this; 183 } 184 185 componentDidMount() { 186 if (this.props.editorMode) { 187 this.setEditorWidth(this.props.editorWidth); 188 } 189 190 const autocompleteOptions = { 191 onSelect: this.onAutocompleteSelect.bind(this), 192 onClick: this.acceptProposedCompletion.bind(this), 193 listId: "webConsole_autocompletePopupListBox", 194 position: this.props.autocompletePopupPosition, 195 autoSelect: true, 196 useXulWrapper: true, 197 }; 198 199 const doc = this.webConsoleUI.document; 200 const { toolbox } = this.webConsoleUI.wrapper; 201 const tooltipDoc = toolbox ? toolbox.doc : doc; 202 // The popup will be attached to the toolbox document or HUD document in the case 203 // such as the browser console which doesn't have a toolbox. 204 this.autocompletePopup = new AutocompletePopup( 205 tooltipDoc, 206 autocompleteOptions 207 ); 208 209 if (this.node) { 210 const onArrowUp = () => { 211 let inputUpdated; 212 if (this.autocompletePopup.isOpen) { 213 this.autocompletePopup.selectPreviousItem(); 214 return null; 215 } 216 217 if (this.props.editorMode === false && this.canCaretGoPrevious()) { 218 inputUpdated = this.historyPeruse(HISTORY_BACK); 219 } 220 221 return inputUpdated ? null : "CodeMirror.Pass"; 222 }; 223 224 const onArrowDown = () => { 225 let inputUpdated; 226 if (this.autocompletePopup.isOpen) { 227 this.autocompletePopup.selectNextItem(); 228 return null; 229 } 230 231 if (this.props.editorMode === false && this.canCaretGoNext()) { 232 inputUpdated = this.historyPeruse(HISTORY_FORWARD); 233 } 234 235 return inputUpdated ? null : "CodeMirror.Pass"; 236 }; 237 238 const onArrowLeft = () => { 239 if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) { 240 this.clearCompletion(); 241 } 242 return "CodeMirror.Pass"; 243 }; 244 245 const onArrowRight = () => { 246 // We only want to complete on Right arrow if the completion text is 247 // displayed. 248 if (this.getAutoCompletionText()) { 249 this.acceptProposedCompletion(); 250 return null; 251 } 252 253 this.clearCompletion(); 254 return "CodeMirror.Pass"; 255 }; 256 257 const onCtrlCmdEnter = () => { 258 if (this.hasAutocompletionSuggestion()) { 259 return this.acceptProposedCompletion(); 260 } 261 262 this._execute(); 263 return null; 264 }; 265 266 this.editor = new Editor({ 267 autofocus: true, 268 enableCodeFolding: this.props.editorMode, 269 lineNumbers: this.props.editorMode, 270 lineWrapping: true, 271 mode: { 272 name: "javascript", 273 globalVars: true, 274 }, 275 styleActiveLine: false, 276 tabIndex: "0", 277 viewportMargin: Infinity, 278 disableSearchAddon: true, 279 extraKeys: { 280 Enter: () => { 281 // No need to handle shift + Enter as it's natively handled by CodeMirror. 282 283 const hasSuggestion = this.hasAutocompletionSuggestion(); 284 if ( 285 !hasSuggestion && 286 !Debugger.isCompilableUnit(this._getValue()) 287 ) { 288 // incomplete statement 289 return "CodeMirror.Pass"; 290 } 291 292 if (hasSuggestion) { 293 return this.acceptProposedCompletion(); 294 } 295 296 if (!this.props.editorMode) { 297 this._execute(); 298 return null; 299 } 300 return "CodeMirror.Pass"; 301 }, 302 303 "Cmd-Enter": onCtrlCmdEnter, 304 "Ctrl-Enter": onCtrlCmdEnter, 305 306 [Editor.accel("S")]: () => { 307 const value = this._getValue(); 308 if (!value) { 309 return null; 310 } 311 312 const date = new Date(); 313 const suggestedName = 314 `console-input-${date.getFullYear()}-` + 315 `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` + 316 `${date.getMinutes()}-${date.getSeconds()}.js`; 317 const data = new TextEncoder().encode(value); 318 return saveAs(window, data, suggestedName, [ 319 { 320 pattern: "*.js", 321 label: l10n.getStr("webconsole.input.openJavaScriptFileFilter"), 322 }, 323 ]); 324 }, 325 326 [Editor.accel("O")]: async () => this._openFile(), 327 328 Tab: () => { 329 if (this.hasEmptyInput()) { 330 this.editor.codeMirror.getInputField().blur(); 331 return false; 332 } 333 334 if ( 335 this.props.autocompleteData && 336 this.props.autocompleteData.getterPath 337 ) { 338 this.props.autocompleteUpdate( 339 true, 340 this.props.autocompleteData.getterPath 341 ); 342 return false; 343 } 344 345 const isSomethingSelected = this.editor.somethingSelected(); 346 const hasSuggestion = this.hasAutocompletionSuggestion(); 347 348 if (hasSuggestion && !isSomethingSelected) { 349 this.acceptProposedCompletion(); 350 return false; 351 } 352 353 if (!isSomethingSelected) { 354 this.insertStringAtCursor("\t"); 355 return false; 356 } 357 358 // Something is selected, let the editor handle the indent. 359 return true; 360 }, 361 362 "Shift-Tab": () => { 363 if (this.hasEmptyInput()) { 364 this.focusPreviousElement(); 365 return false; 366 } 367 368 const hasSuggestion = this.hasAutocompletionSuggestion(); 369 370 if (hasSuggestion) { 371 return false; 372 } 373 374 return "CodeMirror.Pass"; 375 }, 376 377 Up: onArrowUp, 378 "Cmd-Up": onArrowUp, 379 380 Down: onArrowDown, 381 "Cmd-Down": onArrowDown, 382 383 Left: onArrowLeft, 384 "Ctrl-Left": onArrowLeft, 385 "Cmd-Left": onArrowLeft, 386 "Alt-Left": onArrowLeft, 387 // On OSX, Ctrl-A navigates to the beginning of the line. 388 "Ctrl-A": isMacOS ? onArrowLeft : undefined, 389 390 Right: onArrowRight, 391 "Ctrl-Right": onArrowRight, 392 "Cmd-Right": onArrowRight, 393 "Alt-Right": onArrowRight, 394 395 "Ctrl-N": () => { 396 // Control-N differs from down arrow: it ignores autocomplete state. 397 // Note that we preserve the default 'down' navigation within 398 // multiline text. 399 if ( 400 Services.appinfo.OS === "Darwin" && 401 this.props.editorMode === false && 402 this.canCaretGoNext() && 403 this.historyPeruse(HISTORY_FORWARD) 404 ) { 405 return null; 406 } 407 408 this.clearCompletion(); 409 return "CodeMirror.Pass"; 410 }, 411 412 "Ctrl-P": () => { 413 // Control-P differs from up arrow: it ignores autocomplete state. 414 // Note that we preserve the default 'up' navigation within 415 // multiline text. 416 if ( 417 Services.appinfo.OS === "Darwin" && 418 this.props.editorMode === false && 419 this.canCaretGoPrevious() && 420 this.historyPeruse(HISTORY_BACK) 421 ) { 422 return null; 423 } 424 425 this.clearCompletion(); 426 return "CodeMirror.Pass"; 427 }, 428 429 PageUp: () => { 430 if (this.autocompletePopup.isOpen) { 431 this.autocompletePopup.selectPreviousPageItem(); 432 } else { 433 const { outputScroller } = this.webConsoleUI; 434 const { scrollTop, clientHeight } = outputScroller; 435 outputScroller.scrollTop = Math.max(0, scrollTop - clientHeight); 436 } 437 438 return null; 439 }, 440 441 PageDown: () => { 442 if (this.autocompletePopup.isOpen) { 443 this.autocompletePopup.selectNextPageItem(); 444 } else { 445 const { outputScroller } = this.webConsoleUI; 446 const { scrollTop, scrollHeight, clientHeight } = outputScroller; 447 outputScroller.scrollTop = Math.min( 448 scrollHeight, 449 scrollTop + clientHeight 450 ); 451 } 452 453 return null; 454 }, 455 456 Home: () => { 457 if (this.autocompletePopup.isOpen) { 458 this.autocompletePopup.selectItemAtIndex(0); 459 return null; 460 } 461 462 if (!this._getValue()) { 463 this.webConsoleUI.outputScroller.scrollTop = 0; 464 return null; 465 } 466 467 if (this.getAutoCompletionText()) { 468 this.clearCompletion(); 469 } 470 471 return "CodeMirror.Pass"; 472 }, 473 474 End: () => { 475 if (this.autocompletePopup.isOpen) { 476 this.autocompletePopup.selectItemAtIndex( 477 this.autocompletePopup.itemCount - 1 478 ); 479 return null; 480 } 481 482 if (!this._getValue()) { 483 const { outputScroller } = this.webConsoleUI; 484 outputScroller.scrollTop = outputScroller.scrollHeight; 485 return null; 486 } 487 488 if (this.getAutoCompletionText()) { 489 this.clearCompletion(); 490 } 491 492 return "CodeMirror.Pass"; 493 }, 494 495 "Ctrl-Space": () => { 496 if (!this.autocompletePopup.isOpen) { 497 this.props.autocompleteUpdate( 498 true, 499 null, 500 this._getExpressionVariables() 501 ); 502 return null; 503 } 504 505 return "CodeMirror.Pass"; 506 }, 507 508 Esc: false, 509 // Don't handle Ctrl/Cmd + F so it can be listened by a parent node 510 [Editor.accel("F")]: false, 511 }, 512 }); 513 514 this.editor.on("changes", this._onEditorChanges); 515 this.editor.on("beforeChange", this._onEditorBeforeChange); 516 this.editor.on("blur", this._onEditorBlur); 517 this.editor.on("keyHandled", this._onEditorKeyHandled); 518 519 this.editor.appendToLocalElement(this.node); 520 const cm = this.editor.codeMirror; 521 cm.on("paste", (_, event) => this.props.onPaste(event)); 522 cm.on("drop", (_, event) => this.props.onPaste(event)); 523 524 this.#abortController = new AbortController(); 525 const signal = this.#abortController.signal; 526 doc.addEventListener( 527 "visibilitychange", 528 () => { 529 if ( 530 doc.visibilityState == "hidden" && 531 this.autocompletePopup.isOpen 532 ) { 533 this.autocompletePopup.hidePopup(); 534 } 535 }, 536 { signal } 537 ); 538 this.node.addEventListener( 539 "keydown", 540 event => { 541 if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) { 542 if (this.autocompletePopup.isOpen) { 543 this.clearCompletion(); 544 event.preventDefault(); 545 event.stopPropagation(); 546 } 547 548 if ( 549 this.props.autocompleteData && 550 this.props.autocompleteData.getterPath 551 ) { 552 this.props.autocompleteClear(); 553 event.preventDefault(); 554 event.stopPropagation(); 555 } 556 } 557 }, 558 { signal } 559 ); 560 561 this.resizeObserver = new ResizeObserver(() => { 562 // If we don't have the node reference, or if the node isn't connected 563 // anymore, we disconnect the resize observer (componentWillUnmount is never 564 // called on this component, so we have to do it here). 565 if (!this.node || !this.node.isConnected) { 566 this.resizeObserver.disconnect(); 567 return; 568 } 569 // Calling `refresh` will update the cursor position, and all the selection blocks. 570 this.editor.codeMirror.refresh(); 571 }); 572 this.resizeObserver.observe(this.node); 573 574 // Update the character width needed for the popup offset calculations. 575 this._inputCharWidth = this._getInputCharWidth(); 576 this.lastInputValue && this._setValue(this.lastInputValue); 577 } 578 } 579 580 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 581 UNSAFE_componentWillReceiveProps(nextProps) { 582 this.imperativeUpdate(nextProps); 583 } 584 585 shouldComponentUpdate(nextProps) { 586 return ( 587 this.props.showEditorOnboarding !== nextProps.showEditorOnboarding || 588 this.props.editorMode !== nextProps.editorMode 589 ); 590 } 591 592 // AbortController to cancel all event listener on destroy. 593 #abortController = null; 594 595 /** 596 * Do all the imperative work needed after a Redux store update. 597 * 598 * @param {object} nextProps: props passed from shouldComponentUpdate. 599 */ 600 imperativeUpdate(nextProps) { 601 if (!nextProps) { 602 return; 603 } 604 605 if ( 606 nextProps.autocompleteData !== this.props.autocompleteData && 607 nextProps.autocompleteData.pendingRequestId === null 608 ) { 609 this.updateAutocompletionPopup(nextProps.autocompleteData); 610 } 611 612 if (nextProps.editorMode !== this.props.editorMode) { 613 if (this.editor) { 614 this.editor.setOption("lineNumbers", nextProps.editorMode); 615 this.editor.setOption("enableCodeFolding", nextProps.editorMode); 616 } 617 618 if (nextProps.editorMode && nextProps.editorWidth) { 619 this.setEditorWidth(nextProps.editorWidth); 620 } else { 621 this.setEditorWidth(null); 622 } 623 624 if (this.autocompletePopup.isOpen) { 625 this.autocompletePopup.hidePopup(); 626 } 627 } 628 629 if ( 630 nextProps.autocompletePopupPosition !== 631 this.props.autocompletePopupPosition && 632 this.autocompletePopup 633 ) { 634 this.autocompletePopup.position = nextProps.autocompletePopupPosition; 635 } 636 637 if ( 638 nextProps.editorPrettifiedAt && 639 nextProps.editorPrettifiedAt !== this.props.editorPrettifiedAt 640 ) { 641 this._setValue( 642 beautify.js(this._getValue(), { 643 // Read directly from prefs because this.editor.config.indentUnit and 644 // this.editor.getOption('indentUnit') are not really synced with 645 // prefs. 646 indent_size: Services.prefs.getIntPref("devtools.editor.tabsize"), 647 indent_with_tabs: !Services.prefs.getBoolPref( 648 "devtools.editor.expandtab" 649 ), 650 }) 651 ); 652 } 653 } 654 655 /** 656 * 657 * @param {number | null} editorWidth: The width to set the node to. If null, removes any 658 * `width` property on node style. 659 */ 660 setEditorWidth(editorWidth) { 661 if (!this.node) { 662 return; 663 } 664 665 if (editorWidth) { 666 this.node.style.width = `${editorWidth}px`; 667 } else { 668 this.node.style.removeProperty("width"); 669 } 670 } 671 672 focus() { 673 if (this.editor) { 674 this.editor.focus(); 675 } 676 } 677 678 focusPreviousElement() { 679 const inputField = this.editor.codeMirror.getInputField(); 680 681 const findPreviousFocusableElement = el => { 682 if (!el || !el.querySelectorAll) { 683 return null; 684 } 685 686 // We only want to get visible focusable element, and for that we can assert that 687 // the offsetParent isn't null. We can do that because we don't have fixed position 688 // element in the console. 689 const items = lazy 690 .getFocusableElements(el) 691 .filter(({ offsetParent }) => offsetParent !== null); 692 const inputIndex = items.indexOf(inputField); 693 694 if (items.length === 0 || (inputIndex > -1 && items.length === 1)) { 695 return findPreviousFocusableElement(el.parentNode); 696 } 697 698 const index = inputIndex > 0 ? inputIndex - 1 : items.length - 1; 699 return items[index]; 700 }; 701 702 const focusableEl = findPreviousFocusableElement(this.node.parentNode); 703 if (focusableEl) { 704 focusableEl.focus(); 705 } 706 } 707 708 /** 709 * Execute a string. Execution happens asynchronously in the content process. 710 */ 711 _execute() { 712 const value = this._getValue(); 713 // In editor mode, we only evaluate the text selection if there's one. The feature isn't 714 // enabled in inline mode as it can be confusing since input is cleared when evaluating. 715 const executeString = this.props.editorMode 716 ? this.getSelectedText() || value 717 : value; 718 719 if (!executeString) { 720 return; 721 } 722 723 if (!this.props.editorMode) { 724 // Calling this.props.terminalInputChanged instead of this.terminalInputChanged 725 // because we want to instantly hide the instant evaluation result, and don't want 726 // the delay we have in this.terminalInputChanged. 727 this.props.terminalInputChanged(""); 728 this._setValue(""); 729 } 730 this.clearCompletion(); 731 this.props.evaluateExpression(executeString); 732 } 733 734 /** 735 * Sets the value of the input field. 736 * 737 * @param string newValue 738 * The new value to set. 739 * @returns void 740 */ 741 _setValue(newValue = "") { 742 this.lastInputValue = newValue; 743 this.terminalInputChanged(newValue); 744 745 if (this.editor) { 746 // In order to get the autocomplete popup to work properly, we need to set the 747 // editor text and the cursor in the same operation. If we don't, the text change 748 // is done before the cursor is moved, and the autocompletion call to the server 749 // sends an erroneous query. 750 this.editor.codeMirror.operation(() => { 751 this.editor.setText(newValue); 752 753 // Set the cursor at the end of the input. 754 const lines = newValue.split("\n"); 755 this.editor.setCursor({ 756 line: lines.length - 1, 757 ch: lines[lines.length - 1].length, 758 }); 759 this.editor.setAutoCompletionText(); 760 }); 761 } 762 763 this.emitForTests("set-input-value"); 764 } 765 766 /** 767 * Gets the value from the input field 768 * 769 * @returns string 770 */ 771 _getValue() { 772 return this.editor ? this.editor.getText() || "" : ""; 773 } 774 775 /** 776 * Open the file picker for the user to select a javascript file and open it. 777 * 778 */ 779 async _openFile() { 780 const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); 781 fp.init( 782 this.webConsoleUI.document.defaultView.browsingContext, 783 l10n.getStr("webconsole.input.openJavaScriptFile"), 784 Ci.nsIFilePicker.modeOpen 785 ); 786 787 // Append file filters 788 fp.appendFilter( 789 l10n.getStr("webconsole.input.openJavaScriptFileFilter"), 790 "*.js" 791 ); 792 793 function readFile(file) { 794 return new Promise(resolve => { 795 IOUtils.read(file.path).then(data => { 796 const decoder = new TextDecoder(); 797 resolve(decoder.decode(data)); 798 }); 799 }); 800 } 801 802 const content = await new Promise(resolve => { 803 fp.open(rv => { 804 if (rv == Ci.nsIFilePicker.returnOK) { 805 const file = Cc["@mozilla.org/file/local;1"].createInstance( 806 Ci.nsIFile 807 ); 808 file.initWithPath(fp.file.path); 809 readFile(file).then(resolve); 810 } 811 }); 812 }); 813 814 this._setValue(content); 815 } 816 817 getSelectionStart() { 818 return this.getInputValueBeforeCursor().length; 819 } 820 821 getSelectedText() { 822 return this.editor.getSelection(); 823 } 824 825 /** 826 * Even handler for the "beforeChange" event fired by codeMirror. This event is fired 827 * when codeMirror is about to make a change to its DOM representation. 828 */ 829 _onEditorBeforeChange(cm, change) { 830 // If the user did not type a character that matches the completion text, then we 831 // clear it before the change is done to prevent a visual glitch. 832 // See Bugs 1491776 & 1558248. 833 const { from, to, origin, text } = change; 834 const isAddedText = 835 from.line === to.line && from.ch === to.ch && origin === "+input"; 836 837 // if there was no changes (hitting delete on an empty input, or suppr when at the end 838 // of the input), we bail out. 839 if ( 840 !isAddedText && 841 origin === "+delete" && 842 from.line === to.line && 843 from.ch === to.ch 844 ) { 845 return; 846 } 847 848 const addedText = text.join(""); 849 const completionText = this.getAutoCompletionText(); 850 851 const addedCharacterMatchCompletion = 852 isAddedText && completionText.startsWith(addedText); 853 854 const addedCharacterMatchPopupItem = 855 isAddedText && 856 this.autocompletePopup.items.some(({ preLabel, label }) => 857 label.startsWith(preLabel + addedText) 858 ); 859 const nextSelectedAutocompleteItemIndex = 860 addedCharacterMatchPopupItem && 861 this.autocompletePopup.items.findIndex(({ preLabel, label }) => 862 label.startsWith(preLabel + addedText) 863 ); 864 865 if (addedCharacterMatchPopupItem) { 866 this.autocompletePopup.selectItemAtIndex( 867 nextSelectedAutocompleteItemIndex, 868 { preventSelectCallback: true } 869 ); 870 } 871 872 if (!completionText || change.canceled || !addedCharacterMatchCompletion) { 873 this.setAutoCompletionText(""); 874 } 875 876 if (!addedCharacterMatchCompletion && !addedCharacterMatchPopupItem) { 877 this.autocompletePopup.hidePopup(); 878 } else if ( 879 !change.canceled && 880 (completionText || 881 addedCharacterMatchCompletion || 882 addedCharacterMatchPopupItem) 883 ) { 884 // The completion text will be updated when the debounced autocomplete update action 885 // is done, so in the meantime we set the pending value to pendingCompletionText. 886 // See Bug 1595068 for more information. 887 this.pendingCompletionText = completionText.substring(text.length); 888 // And we update the preLabel of the matching autocomplete items that may be used 889 // in the acceptProposedAutocompletion function. 890 this.autocompletePopup.items.forEach(item => { 891 if (item.label.startsWith(item.preLabel + addedText)) { 892 item.preLabel += addedText; 893 } 894 }); 895 } 896 } 897 898 /** 899 * Even handler for the "blur" event fired by codeMirror. 900 */ 901 _onEditorBlur(cm) { 902 if (cm.somethingSelected()) { 903 // If there's a selection when the input is blurred, then we remove it by setting 904 // the cursor at the position that matches the start of the first selection. 905 const [{ head }] = cm.listSelections(); 906 cm.setCursor(head, { scroll: false }); 907 } 908 } 909 910 /** 911 * Fired after a key is handled through a key map. 912 * 913 * @param {CodeMirror} cm: codeMirror instance 914 * @param {string} key: The key that was handled 915 */ 916 _onEditorKeyHandled(cm, key) { 917 // The autocloseBracket addon handle closing brackets keys when they're typed, but 918 // there's already an existing closing bracket. 919 // ex: 920 // 1. input is `foo(x|)` (where | represents the cursor) 921 // 2. user types `)` 922 // 3. input is now `foo(x)|` (i.e. the typed character wasn't inserted) 923 // In such case, _onEditorBeforeChange isn't triggered, so we need to hide the popup 924 // here. We can do that because this function won't be called when codeMirror _do_ 925 // insert the closing char. 926 const closingKeys = [`']'`, `')'`, "'}'"]; 927 if (this.autocompletePopup.isOpen && closingKeys.includes(key)) { 928 this.clearCompletion(); 929 } 930 } 931 932 /** 933 * Retrieve variable declared in the expression from the CodeMirror state, in order 934 * to display them in the autocomplete popup. 935 */ 936 _getExpressionVariables() { 937 const cm = this.editor.codeMirror; 938 const { state } = cm.getTokenAt(cm.getCursor()); 939 const variables = []; 940 941 if (state.context) { 942 for (let c = state.context; c; c = c.prev) { 943 for (let v = c.vars; v; v = v.next) { 944 if (v.name) { 945 variables.push(v.name); 946 } 947 } 948 } 949 } 950 951 const keys = ["localVars", "globalVars"]; 952 for (const key of keys) { 953 if (state[key]) { 954 for (let v = state[key]; v; v = v.next) { 955 if (v.name) { 956 variables.push(v.name); 957 } 958 } 959 } 960 } 961 962 return variables; 963 } 964 965 /** 966 * The editor "changes" event handler. 967 */ 968 _onEditorChanges(cm, changes) { 969 const value = this._getValue(); 970 971 if (this.lastInputValue !== value) { 972 // We don't autocomplete if the changes were made by JsTerm (e.g. autocomplete was 973 // accepted). 974 const isJsTermChangeOnly = changes.every( 975 ({ origin }) => origin === JSTERM_CODEMIRROR_ORIGIN 976 ); 977 978 if ( 979 !isJsTermChangeOnly && 980 (this.props.autocomplete || this.hasAutocompletionSuggestion()) 981 ) { 982 this.autocompleteUpdate(false, null, this._getExpressionVariables()); 983 } 984 this.lastInputValue = value; 985 this.terminalInputChanged(value); 986 } 987 } 988 989 /** 990 * Go up/down the history stack of input values. 991 * 992 * @param number direction 993 * History navigation direction: HISTORY_BACK or HISTORY_FORWARD. 994 * 995 * @returns boolean 996 * True if the input value changed, false otherwise. 997 */ 998 historyPeruse(direction) { 999 const { history, updateHistoryPosition, getValueFromHistory } = this.props; 1000 1001 if (!history.entries.length) { 1002 return false; 1003 } 1004 1005 const newInputValue = getValueFromHistory(direction); 1006 const expression = this._getValue(); 1007 updateHistoryPosition(direction, expression); 1008 1009 if (newInputValue != null) { 1010 this._setValue(newInputValue); 1011 return true; 1012 } 1013 1014 return false; 1015 } 1016 1017 /** 1018 * Test for empty input. 1019 * 1020 * @return boolean 1021 */ 1022 hasEmptyInput() { 1023 return this._getValue() === ""; 1024 } 1025 1026 /** 1027 * Check if the caret is at a location that allows selecting the previous item 1028 * in history when the user presses the Up arrow key. 1029 * 1030 * @return boolean 1031 * True if the caret is at a location that allows selecting the 1032 * previous item in history when the user presses the Up arrow key, 1033 * otherwise false. 1034 */ 1035 canCaretGoPrevious() { 1036 if (!this.editor) { 1037 return false; 1038 } 1039 1040 const inputValue = this._getValue(); 1041 const { line, ch } = this.editor.getCursor(); 1042 return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length); 1043 } 1044 1045 /** 1046 * Check if the caret is at a location that allows selecting the next item in 1047 * history when the user presses the Down arrow key. 1048 * 1049 * @return boolean 1050 * True if the caret is at a location that allows selecting the next 1051 * item in history when the user presses the Down arrow key, otherwise 1052 * false. 1053 */ 1054 canCaretGoNext() { 1055 if (!this.editor) { 1056 return false; 1057 } 1058 1059 const inputValue = this._getValue(); 1060 const multiline = /[\r\n]/.test(inputValue); 1061 1062 const { line, ch } = this.editor.getCursor(); 1063 return ( 1064 (!multiline && ch === 0) || 1065 this.editor.getDoc().getRange({ line: 0, ch: 0 }, { line, ch }).length === 1066 inputValue.length 1067 ); 1068 } 1069 1070 /** 1071 * Takes the data returned by the server and update the autocomplete popup state (i.e. 1072 * its visibility and items). 1073 * 1074 * @param {object} data 1075 * The autocompletion data as returned by the webconsole actor's autocomplete 1076 * service. Should be of the following shape: 1077 * { 1078 * matches: {Array} array of the properties matching the input, 1079 * matchProp: {String} The string used to filter the properties, 1080 * isElementAccess: {Boolean} True when the input is an element access, 1081 * i.e. `document["addEve`. 1082 * } 1083 * @fires autocomplete-updated 1084 */ 1085 async updateAutocompletionPopup(data) { 1086 if (!this.editor) { 1087 return; 1088 } 1089 1090 const { matches, matchProp, isElementAccess } = data; 1091 if (!matches.length) { 1092 this.clearCompletion(); 1093 return; 1094 } 1095 1096 const inputUntilCursor = this.getInputValueBeforeCursor(); 1097 1098 const items = matches.map(label => { 1099 let preLabel = label.substring(0, matchProp.length); 1100 // If the user is performing an element access, and if they did not typed a quote, 1101 // then we need to adjust the preLabel to match the quote from the label + what 1102 // the user entered. 1103 if (isElementAccess && /^['"`]/.test(matchProp) === false) { 1104 preLabel = label.substring(0, matchProp.length + 1); 1105 } 1106 return { preLabel, label, isElementAccess }; 1107 }); 1108 1109 if (items.length) { 1110 const { preLabel, label } = items[0]; 1111 let suffix = label.substring(preLabel.length); 1112 if (isElementAccess) { 1113 if (!matchProp) { 1114 suffix = label; 1115 } 1116 const inputAfterCursor = this._getValue().substring( 1117 inputUntilCursor.length 1118 ); 1119 // If there's not a bracket after the cursor, add it to the completionText. 1120 if (!inputAfterCursor.trimLeft().startsWith("]")) { 1121 suffix = suffix + "]"; 1122 } 1123 } 1124 this.setAutoCompletionText(suffix); 1125 } 1126 1127 const popup = this.autocompletePopup; 1128 // We don't want to trigger the onSelect callback since we already set the completion 1129 // text a few lines above. 1130 popup.setItems(items, 0, { 1131 preventSelectCallback: true, 1132 }); 1133 1134 const minimumAutoCompleteLength = 2; 1135 1136 // We want to show the autocomplete popup if: 1137 // - there are at least 2 matching results 1138 // - OR, if there's 1 result, but whose label does not start like the input (this can 1139 // happen with insensitive search: `num` will match `Number`). 1140 // - OR, if there's 1 result, but we can't show the completionText (because there's 1141 // some text after the cursor), unless the text in the popup is the same as the input. 1142 if ( 1143 items.length >= minimumAutoCompleteLength || 1144 (items.length === 1 && items[0].preLabel !== matchProp) || 1145 (items.length === 1 && 1146 !this.canDisplayAutoCompletionText() && 1147 items[0].label !== matchProp) 1148 ) { 1149 // We need to show the popup at the "." or "[". 1150 const xOffset = -1 * matchProp.length * this._inputCharWidth; 1151 const yOffset = 5; 1152 const popupAlignElement = 1153 this.props.serviceContainer.getJsTermTooltipAnchor(); 1154 this._openPopupPendingPromise = popup.openPopup( 1155 popupAlignElement, 1156 xOffset, 1157 yOffset, 1158 0, 1159 { 1160 preventSelectCallback: true, 1161 } 1162 ); 1163 await this._openPopupPendingPromise; 1164 this._openPopupPendingPromise = null; 1165 } else if ( 1166 items.length < minimumAutoCompleteLength && 1167 (popup.isOpen || this._openPopupPendingPromise) 1168 ) { 1169 if (this._openPopupPendingPromise) { 1170 await this._openPopupPendingPromise; 1171 } 1172 popup.hidePopup(); 1173 } 1174 1175 // Eager evaluation results incorporate the current autocomplete item. We need to 1176 // trigger it here as well as in onAutocompleteSelect as we set the items with 1177 // preventSelectCallback (which means we won't trigger onAutocompleteSelect when the 1178 // popup is open). 1179 this.terminalInputChanged( 1180 this.getInputValueWithCompletionText().expression 1181 ); 1182 1183 this.emit("autocomplete-updated"); 1184 } 1185 1186 onAutocompleteSelect() { 1187 const { selectedItem } = this.autocompletePopup; 1188 if (selectedItem) { 1189 const { preLabel, label, isElementAccess } = selectedItem; 1190 let suffix = label.substring(preLabel.length); 1191 1192 // If the user is performing an element access, we need to check if we should add 1193 // starting and ending quotes, as well as a closing bracket. 1194 if (isElementAccess) { 1195 const inputBeforeCursor = this.getInputValueBeforeCursor(); 1196 if (inputBeforeCursor.trim().endsWith("[")) { 1197 suffix = label; 1198 } 1199 1200 const inputAfterCursor = this._getValue().substring( 1201 inputBeforeCursor.length 1202 ); 1203 // If there's no closing bracket after the cursor, add it to the completionText. 1204 if (!inputAfterCursor.trimLeft().startsWith("]")) { 1205 suffix = suffix + "]"; 1206 } 1207 } 1208 this.setAutoCompletionText(suffix); 1209 } else { 1210 this.setAutoCompletionText(""); 1211 } 1212 // Eager evaluation results incorporate the current autocomplete item. 1213 this.terminalInputChanged( 1214 this.getInputValueWithCompletionText().expression 1215 ); 1216 } 1217 1218 /** 1219 * Clear the current completion information, cancel any pending autocompletion update 1220 * and close the autocomplete popup, if needed. 1221 * 1222 * @fires autocomplete-updated 1223 */ 1224 clearCompletion() { 1225 this.autocompleteUpdate.cancel(); 1226 // Update Eager evaluation result as the completion text was removed. 1227 this.terminalInputChanged(this._getValue()); 1228 1229 this.setAutoCompletionText(""); 1230 let onPopupClosed = Promise.resolve(); 1231 if (this.autocompletePopup) { 1232 this.autocompletePopup.clearItems(); 1233 1234 if (this.autocompletePopup.isOpen || this._openPopupPendingPromise) { 1235 onPopupClosed = this.autocompletePopup.once("popup-closed"); 1236 1237 if (this._openPopupPendingPromise) { 1238 this._openPopupPendingPromise.then(() => 1239 this.autocompletePopup.hidePopup() 1240 ); 1241 } else { 1242 this.autocompletePopup.hidePopup(); 1243 } 1244 onPopupClosed.then(() => this.focus()); 1245 } 1246 } 1247 onPopupClosed.then(() => this.emit("autocomplete-updated")); 1248 } 1249 1250 /** 1251 * Accept the proposed input completion. 1252 */ 1253 acceptProposedCompletion() { 1254 const { 1255 completionText, 1256 numberOfCharsToMoveTheCursorForward, 1257 numberOfCharsToReplaceCharsBeforeCursor, 1258 } = this.getInputValueWithCompletionText(); 1259 1260 this.autocompleteUpdate.cancel(); 1261 this.props.autocompleteClear(); 1262 1263 // If the code triggering the opening of the popup was already triggered but not yet 1264 // settled, then we need to wait until it's resolved in order to close the popup (See 1265 // Bug 1655406). 1266 if (this._openPopupPendingPromise) { 1267 this._openPopupPendingPromise.then(() => 1268 this.autocompletePopup.hidePopup() 1269 ); 1270 } 1271 1272 if (completionText) { 1273 this.insertStringAtCursor( 1274 completionText, 1275 numberOfCharsToReplaceCharsBeforeCursor 1276 ); 1277 1278 if (numberOfCharsToMoveTheCursorForward) { 1279 const { line, ch } = this.editor.getCursor(); 1280 this.editor.setCursor({ 1281 line, 1282 ch: ch + numberOfCharsToMoveTheCursorForward, 1283 }); 1284 } 1285 } 1286 } 1287 1288 /** 1289 * Returns an object containing the expression we would get if the user accepted the 1290 * current completion text. This is more than the current input + the completion text, 1291 * as there are special cases for element access and case-insensitive matches. 1292 * 1293 * @return {object}: An object of the following shape: 1294 * - {String} expression: The complete expression 1295 * - {String} completionText: the completion text only, which should be used 1296 * with the next property 1297 * - {Integer} numberOfCharsToReplaceCharsBeforeCursor: The number of chars that 1298 * should be removed from the current input before the cursor to 1299 * cleanly apply the completionText. This is handy when we only want 1300 * to insert the completionText. 1301 * - {Integer} numberOfCharsToMoveTheCursorForward: The number of chars that the 1302 * cursor should be moved after the completion is done. This can 1303 * be useful for element access where there's already a closing 1304 * quote and/or bracket. 1305 */ 1306 getInputValueWithCompletionText() { 1307 const inputBeforeCursor = this.getInputValueBeforeCursor(); 1308 const inputAfterCursor = this._getValue().substring( 1309 inputBeforeCursor.length 1310 ); 1311 let completionText = this.getAutoCompletionText(); 1312 let numberOfCharsToReplaceCharsBeforeCursor; 1313 let numberOfCharsToMoveTheCursorForward = 0; 1314 1315 // If the autocompletion popup is open, we always get the selected element from there, 1316 // since the autocompletion text might not be enough (e.g. `dOcUmEn` should 1317 // autocomplete to `document`, but the autocompletion text only shows `t`). 1318 if (this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem) { 1319 const { selectedItem } = this.autocompletePopup; 1320 const { label, preLabel, isElementAccess } = selectedItem; 1321 1322 completionText = label; 1323 numberOfCharsToReplaceCharsBeforeCursor = preLabel.length; 1324 1325 // If the user is performing an element access, we need to check if we should add 1326 // starting and ending quotes, as well as a closing bracket. 1327 if (isElementAccess) { 1328 const lastOpeningBracketIndex = inputBeforeCursor.lastIndexOf("["); 1329 if (lastOpeningBracketIndex > -1) { 1330 numberOfCharsToReplaceCharsBeforeCursor = inputBeforeCursor.substring( 1331 lastOpeningBracketIndex + 1 1332 ).length; 1333 } 1334 1335 // If the autoclose bracket option is enabled, the input might be in a state where 1336 // there's already the closing quote and the closing bracket, e.g. 1337 // `document["activeEl|"]`, so we don't need to add 1338 // Let's retrieve the completionText last character, to see if it's a quote. 1339 const completionTextLastChar = 1340 completionText[completionText.length - 1]; 1341 const endingQuote = [`"`, `'`, "`"].includes(completionTextLastChar) 1342 ? completionTextLastChar 1343 : ""; 1344 if ( 1345 endingQuote && 1346 inputAfterCursor.trimLeft().startsWith(endingQuote) 1347 ) { 1348 completionText = completionText.substring( 1349 0, 1350 completionText.length - 1 1351 ); 1352 numberOfCharsToMoveTheCursorForward++; 1353 } 1354 1355 // If there's not a closing bracket already, we add one. 1356 if ( 1357 !inputAfterCursor.trimLeft().match(new RegExp(`^${endingQuote}?]`)) 1358 ) { 1359 completionText = completionText + "]"; 1360 } else { 1361 // if there's already one, we want to move the cursor after the closing bracket. 1362 numberOfCharsToMoveTheCursorForward++; 1363 } 1364 } 1365 } 1366 1367 const expression = 1368 inputBeforeCursor.substring( 1369 0, 1370 inputBeforeCursor.length - 1371 (numberOfCharsToReplaceCharsBeforeCursor || 0) 1372 ) + 1373 completionText + 1374 inputAfterCursor; 1375 1376 return { 1377 completionText, 1378 expression, 1379 numberOfCharsToMoveTheCursorForward, 1380 numberOfCharsToReplaceCharsBeforeCursor, 1381 }; 1382 } 1383 1384 getInputValueBeforeCursor() { 1385 return this.editor 1386 ? this.editor 1387 .getDoc() 1388 .getRange({ line: 0, ch: 0 }, this.editor.getCursor()) 1389 : null; 1390 } 1391 1392 /** 1393 * Insert a string into the console at the cursor location, 1394 * moving the cursor to the end of the string. 1395 * 1396 * @param {string} str 1397 * @param {int} numberOfCharsToReplaceCharsBeforeCursor - defaults to 0 1398 */ 1399 insertStringAtCursor(str, numberOfCharsToReplaceCharsBeforeCursor = 0) { 1400 if (!this.editor) { 1401 return; 1402 } 1403 1404 const cursor = this.editor.getCursor(); 1405 const from = { 1406 line: cursor.line, 1407 ch: cursor.ch - numberOfCharsToReplaceCharsBeforeCursor, 1408 }; 1409 1410 this.editor 1411 .getDoc() 1412 .replaceRange(str, from, cursor, JSTERM_CODEMIRROR_ORIGIN); 1413 } 1414 1415 /** 1416 * Set the autocompletion text of the input. 1417 * 1418 * @param string suffix 1419 * The proposed suffix for the input value. 1420 */ 1421 setAutoCompletionText(suffix) { 1422 if (!this.editor) { 1423 return; 1424 } 1425 1426 this.pendingCompletionText = null; 1427 1428 if (suffix && !this.canDisplayAutoCompletionText()) { 1429 suffix = ""; 1430 } 1431 1432 this.editor.setAutoCompletionText(suffix); 1433 } 1434 1435 getAutoCompletionText() { 1436 const renderedCompletionText = 1437 this.editor && this.editor.getAutoCompletionText(); 1438 return typeof this.pendingCompletionText === "string" 1439 ? this.pendingCompletionText 1440 : renderedCompletionText; 1441 } 1442 1443 /** 1444 * Indicate if the input has an autocompletion suggestion, i.e. that there is either 1445 * something in the autocompletion text or that there's a selected item in the 1446 * autocomplete popup. 1447 */ 1448 hasAutocompletionSuggestion() { 1449 // We can have cases where the popup is opened but we can't display the autocompletion 1450 // text. 1451 return ( 1452 this.getAutoCompletionText() || 1453 (this.autocompletePopup.isOpen && 1454 Number.isInteger(this.autocompletePopup.selectedIndex) && 1455 this.autocompletePopup.selectedIndex > -1) 1456 ); 1457 } 1458 1459 /** 1460 * Returns a boolean indicating if we can display an autocompletion text in the input, 1461 * i.e. if there is no characters displayed on the same line of the cursor and after it. 1462 */ 1463 canDisplayAutoCompletionText() { 1464 if (!this.editor) { 1465 return false; 1466 } 1467 1468 const { ch, line } = this.editor.getCursor(); 1469 const lineContent = this.editor.getLine(line); 1470 const textAfterCursor = lineContent.substring(ch); 1471 return textAfterCursor === ""; 1472 } 1473 1474 /** 1475 * Calculates and returns the width of a single character of the input box. 1476 * This will be used in opening the popup at the correct offset. 1477 * 1478 * @returns {number | null}: Width off the "x" char, or null if the input does not exist. 1479 */ 1480 _getInputCharWidth() { 1481 return this.editor ? this.editor.defaultCharWidth() : null; 1482 } 1483 1484 onContextMenu(e) { 1485 this.props.serviceContainer.openEditContextMenu(e); 1486 } 1487 1488 destroy() { 1489 this.autocompleteUpdate.cancel(); 1490 this.terminalInputChanged.cancel(); 1491 this._openPopupPendingPromise = null; 1492 1493 if (this.autocompletePopup) { 1494 this.autocompletePopup.destroy(); 1495 this.autocompletePopup = null; 1496 } 1497 1498 if (this.#abortController) { 1499 this.#abortController.abort(); 1500 this.#abortController = null; 1501 } 1502 1503 if (this.editor) { 1504 this.resizeObserver.disconnect(); 1505 this.editor.destroy(); 1506 this.editor = null; 1507 } 1508 1509 this.webConsoleUI = null; 1510 } 1511 1512 renderOpenEditorButton() { 1513 if (this.props.editorMode) { 1514 return null; 1515 } 1516 1517 return dom.button({ 1518 className: 1519 "devtools-button webconsole-input-openEditorButton" + 1520 (this.props.showEditorOnboarding ? " devtools-feature-callout" : ""), 1521 title: l10n.getFormatStr("webconsole.input.openEditorButton.tooltip2", [ 1522 isMacOS ? "Cmd + B" : "Ctrl + B", 1523 ]), 1524 onClick: this.props.editorToggle, 1525 }); 1526 } 1527 1528 renderEvaluationContextSelector() { 1529 if (this.props.editorMode) { 1530 return null; 1531 } 1532 1533 return EvaluationContextSelector(this.props); 1534 } 1535 1536 renderEditorOnboarding() { 1537 if (!this.props.showEditorOnboarding) { 1538 return null; 1539 } 1540 1541 // We deliberately use getStr, and not getFormatStr, because we want keyboard 1542 // shortcuts to be wrapped in their own span. 1543 const label = l10n.getStr("webconsole.input.editor.onboarding.label"); 1544 let [prefix, suffix] = label.split("%1$S"); 1545 suffix = suffix.split("%2$S"); 1546 1547 const enterString = l10n.getStr("webconsole.enterKey"); 1548 1549 return dom.header( 1550 { className: "editor-onboarding" }, 1551 dom.img({ 1552 className: "editor-onboarding-fox", 1553 src: "chrome://devtools/skin/images/fox-smiling.svg", 1554 }), 1555 dom.p( 1556 {}, 1557 prefix, 1558 dom.span({ className: "editor-onboarding-shortcut" }, enterString), 1559 suffix[0], 1560 dom.span({ className: "editor-onboarding-shortcut" }, [ 1561 isMacOS ? `Cmd+${enterString}` : `Ctrl+${enterString}`, 1562 ]), 1563 suffix[1] 1564 ), 1565 dom.button( 1566 { 1567 className: "editor-onboarding-dismiss-button", 1568 onClick: () => this.props.editorOnboardingDismiss(), 1569 }, 1570 l10n.getStr("webconsole.input.editor.onboarding.dismiss.label") 1571 ) 1572 ); 1573 } 1574 1575 render() { 1576 if (!this.props.inputEnabled) { 1577 return null; 1578 } 1579 1580 return dom.div( 1581 { 1582 className: "jsterm-input-container devtools-input", 1583 key: "jsterm-container", 1584 "aria-live": "off", 1585 tabIndex: -1, 1586 onContextMenu: this.onContextMenu, 1587 ref: node => { 1588 this.node = node; 1589 }, 1590 }, 1591 dom.div( 1592 { className: "webconsole-input-buttons" }, 1593 this.renderEvaluationContextSelector(), 1594 this.renderOpenEditorButton() 1595 ), 1596 this.renderEditorOnboarding() 1597 ); 1598 } 1599 } 1600 1601 // Redux connect 1602 1603 function mapStateToProps(state) { 1604 return { 1605 history: getHistory(state), 1606 getValueFromHistory: direction => getHistoryValue(state, direction), 1607 autocompleteData: getAutocompleteState(state), 1608 showEditorOnboarding: state.ui.showEditorOnboarding, 1609 autocompletePopupPosition: state.prefs.eagerEvaluation ? "top" : "bottom", 1610 editorPrettifiedAt: state.ui.editorPrettifiedAt, 1611 }; 1612 } 1613 1614 function mapDispatchToProps(dispatch) { 1615 return { 1616 updateHistoryPosition: (direction, expression) => 1617 dispatch(actions.updateHistoryPosition(direction, expression)), 1618 autocompleteUpdate: (force, getterPath, expressionVars) => 1619 dispatch(actions.autocompleteUpdate(force, getterPath, expressionVars)), 1620 autocompleteClear: () => dispatch(actions.autocompleteClear()), 1621 evaluateExpression: expression => 1622 dispatch(actions.evaluateExpression(expression)), 1623 editorToggle: () => dispatch(actions.editorToggle()), 1624 editorOnboardingDismiss: () => dispatch(actions.editorOnboardingDismiss()), 1625 terminalInputChanged: value => 1626 dispatch(actions.terminalInputChanged(value)), 1627 }; 1628 } 1629 1630 module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);