/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at . */ import PropTypes from "prop-types"; import React, { PureComponent } from "react"; import { bindActionCreators } from "redux"; import ReactDOM from "react-dom"; import { connect } from "../../utils/connect"; import { getLineText, isLineBlackboxed } from "./../../utils/source"; import { createLocation } from "./../../utils/location"; import { features } from "../../utils/prefs"; import { getIndentation } from "../../utils/indentation"; import { showMenu } from "../../context-menu/menu"; import { createBreakpointItems, breakpointItemActions, } from "./menus/breakpoints"; import { continueToHereItem, editorItemActions, blackBoxLineMenuItem, } from "./menus/editor"; import { getActiveSearch, getSelectedLocation, getSelectedSource, getSelectedSourceTextContent, getSelectedBreakableLines, getConditionalPanelLocation, getSymbols, getIsCurrentThreadPaused, getCurrentThread, getThreadContext, getSkipPausing, getInlinePreview, getEditorWrapping, getHighlightedCalls, getBlackBoxRanges, isSourceBlackBoxed, getHighlightedLineRangeForSelectedSource, isSourceMapIgnoreListEnabled, isSourceOnSourceMapIgnoreList, } from "../../selectors"; // Redux actions import actions from "../../actions"; import SearchInFileBar from "./SearchInFileBar"; import HighlightLines from "./HighlightLines"; import Preview from "./Preview"; import Breakpoints from "./Breakpoints"; import ColumnBreakpoints from "./ColumnBreakpoints"; import DebugLine from "./DebugLine"; import HighlightLine from "./HighlightLine"; import EmptyLines from "./EmptyLines"; import EditorMenu from "./EditorMenu"; import ConditionalPanel from "./ConditionalPanel"; import InlinePreviews from "./InlinePreviews"; import HighlightCalls from "./HighlightCalls"; import Exceptions from "./Exceptions"; import BlackboxLines from "./BlackboxLines"; import { showSourceText, showLoading, showErrorMessage, getEditor, clearEditor, getCursorLine, getCursorColumn, lineAtHeight, toSourceLine, getDocument, scrollToColumn, toEditorPosition, getSourceLocationFromMouseEvent, hasDocument, onMouseOver, startOperation, endOperation, } from "../../utils/editor"; import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui"; const { debounce } = require("devtools/shared/debounce"); const classnames = require("devtools/client/shared/classnames.js"); const { appinfo } = Services; const isMacOS = appinfo.OS === "Darwin"; function isSecondary(ev) { return isMacOS && ev.ctrlKey && ev.button === 0; } function isCmd(ev) { return isMacOS ? ev.metaKey : ev.ctrlKey; } import "./Editor.css"; import "./Breakpoints.css"; import "./InlinePreview.css"; const cssVars = { searchbarHeight: "var(--editor-searchbar-height)", }; class Editor extends PureComponent { static get propTypes() { return { selectedSource: PropTypes.object, selectedSourceTextContent: PropTypes.object, selectedSourceIsBlackBoxed: PropTypes.bool, cx: PropTypes.object.isRequired, closeTab: PropTypes.func.isRequired, toggleBreakpointAtLine: PropTypes.func.isRequired, conditionalPanelLocation: PropTypes.object, closeConditionalPanel: PropTypes.func.isRequired, openConditionalPanel: PropTypes.func.isRequired, updateViewport: PropTypes.func.isRequired, isPaused: PropTypes.bool.isRequired, highlightCalls: PropTypes.func.isRequired, unhighlightCalls: PropTypes.func.isRequired, breakpointActions: PropTypes.object.isRequired, editorActions: PropTypes.object.isRequired, addBreakpointAtLine: PropTypes.func.isRequired, continueToHere: PropTypes.func.isRequired, toggleBlackBox: PropTypes.func.isRequired, updateCursorPosition: PropTypes.func.isRequired, jumpToMappedLocation: PropTypes.func.isRequired, selectedLocation: PropTypes.object, symbols: PropTypes.object, startPanelSize: PropTypes.number.isRequired, endPanelSize: PropTypes.number.isRequired, searchInFileEnabled: PropTypes.bool.isRequired, inlinePreviewEnabled: PropTypes.bool.isRequired, editorWrappingEnabled: PropTypes.bool.isRequired, skipPausing: PropTypes.bool.isRequired, blackboxedRanges: PropTypes.object.isRequired, breakableLines: PropTypes.object.isRequired, highlightedLineRange: PropTypes.object, isSourceOnIgnoreList: PropTypes.bool, }; } $editorWrapper; constructor(props) { super(props); this.state = { editor: null, contextMenu: null, }; } // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 UNSAFE_componentWillReceiveProps(nextProps) { let { editor } = this.state; if (!editor && nextProps.selectedSource) { editor = this.setupEditor(); } const shouldUpdateText = nextProps.selectedSource !== this.props.selectedSource || nextProps.selectedSourceTextContent !== this.props.selectedSourceTextContent || nextProps.symbols !== this.props.symbols; const shouldUpdateSize = nextProps.startPanelSize !== this.props.startPanelSize || nextProps.endPanelSize !== this.props.endPanelSize; const shouldScroll = nextProps.selectedLocation && this.shouldScrollToLocation(nextProps, editor); if (shouldUpdateText || shouldUpdateSize || shouldScroll) { startOperation(); if (shouldUpdateText) { this.setText(nextProps, editor); } if (shouldUpdateSize) { editor.codeMirror.setSize(); } if (shouldScroll) { this.scrollToLocation(nextProps, editor); } endOperation(); } if (this.props.selectedSource != nextProps.selectedSource) { this.props.updateViewport(); resizeBreakpointGutter(editor.codeMirror); resizeToggleButton(editor.codeMirror); } } setupEditor() { const editor = getEditor(); // disables the default search shortcuts editor._initShortcuts = () => {}; const node = ReactDOM.findDOMNode(this); if (node instanceof HTMLElement) { editor.appendToLocalElement(node.querySelector(".editor-mount")); } const { codeMirror } = editor; const codeMirrorWrapper = codeMirror.getWrapperElement(); codeMirror.on("gutterClick", this.onGutterClick); if (features.commandClick) { document.addEventListener("keydown", this.commandKeyDown); document.addEventListener("keyup", this.commandKeyUp); } // Set code editor wrapper to be focusable codeMirrorWrapper.tabIndex = 0; codeMirrorWrapper.addEventListener("keydown", e => this.onKeyDown(e)); codeMirrorWrapper.addEventListener("click", e => this.onClick(e)); codeMirrorWrapper.addEventListener("mouseover", onMouseOver(codeMirror)); const toggleFoldMarkerVisibility = e => { if (node instanceof HTMLElement) { node .querySelectorAll(".CodeMirror-guttermarker-subtle") .forEach(elem => { elem.classList.toggle("visible"); }); } }; const codeMirrorGutter = codeMirror.getGutterElement(); codeMirrorGutter.addEventListener("mouseleave", toggleFoldMarkerVisibility); codeMirrorGutter.addEventListener("mouseenter", toggleFoldMarkerVisibility); codeMirrorWrapper.addEventListener("contextmenu", event => this.openMenu(event) ); codeMirror.on("scroll", this.onEditorScroll); this.onEditorScroll(); this.setState({ editor }); return editor; } componentDidMount() { const { shortcuts } = this.context; shortcuts.on(L10N.getStr("toggleBreakpoint.key"), this.onToggleBreakpoint); shortcuts.on( L10N.getStr("toggleCondPanel.breakpoint.key"), this.onToggleConditionalPanel ); shortcuts.on( L10N.getStr("toggleCondPanel.logPoint.key"), this.onToggleConditionalPanel ); shortcuts.on( L10N.getStr("sourceTabs.closeTab.key"), this.onCloseShortcutPress ); shortcuts.on("Esc", this.onEscape); } onCloseShortcutPress = e => { const { cx, selectedSource } = this.props; if (selectedSource) { e.preventDefault(); e.stopPropagation(); this.props.closeTab(cx, selectedSource, "shortcut"); } }; componentWillUnmount() { const { editor } = this.state; if (editor) { editor.destroy(); editor.codeMirror.off("scroll", this.onEditorScroll); this.setState({ editor: null }); } const { shortcuts } = this.context; shortcuts.off(L10N.getStr("sourceTabs.closeTab.key")); shortcuts.off(L10N.getStr("toggleBreakpoint.key")); shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key")); shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key")); } getCurrentLine() { const { codeMirror } = this.state.editor; const { selectedSource } = this.props; if (!selectedSource) { return null; } const line = getCursorLine(codeMirror); return toSourceLine(selectedSource.id, line); } onToggleBreakpoint = e => { e.preventDefault(); e.stopPropagation(); const line = this.getCurrentLine(); if (typeof line !== "number") { return; } this.props.toggleBreakpointAtLine(this.props.cx, line); }; onToggleConditionalPanel = e => { e.stopPropagation(); e.preventDefault(); const { conditionalPanelLocation, closeConditionalPanel, openConditionalPanel, selectedSource, } = this.props; const line = this.getCurrentLine(); const { codeMirror } = this.state.editor; // add one to column for correct position in editor. const column = getCursorColumn(codeMirror) + 1; if (conditionalPanelLocation) { return closeConditionalPanel(); } if (!selectedSource || typeof line !== "number") { return null; } return openConditionalPanel( createLocation({ line, column, source: selectedSource, }), false ); }; onEditorScroll = debounce(this.props.updateViewport, 75); commandKeyDown = e => { const { key } = e; if (this.props.isPaused && key === "Meta") { const { cx, highlightCalls } = this.props; highlightCalls(cx); } }; commandKeyUp = e => { const { key } = e; if (key === "Meta") { const { cx, unhighlightCalls } = this.props; unhighlightCalls(cx); } }; onKeyDown(e) { const { codeMirror } = this.state.editor; const { key, target } = e; const codeWrapper = codeMirror.getWrapperElement(); const textArea = codeWrapper.querySelector("textArea"); if (key === "Escape" && target == textArea) { e.stopPropagation(); e.preventDefault(); codeWrapper.focus(); } else if (key === "Enter" && target == codeWrapper) { e.preventDefault(); // Focus into editor's text area textArea.focus(); } } /* * The default Esc command is overridden in the CodeMirror keymap to allow * the Esc keypress event to be catched by the toolbox and trigger the * split console. Restore it here, but preventDefault if and only if there * is a multiselection. */ onEscape = e => { if (!this.state.editor) { return; } const { codeMirror } = this.state.editor; if (codeMirror.listSelections().length > 1) { codeMirror.execCommand("singleSelection"); e.preventDefault(); } }; openMenu(event) { event.stopPropagation(); event.preventDefault(); const { cx, selectedSource, selectedSourceTextContent, breakpointActions, editorActions, isPaused, conditionalPanelLocation, closeConditionalPanel, isSourceOnIgnoreList, blackboxedRanges, } = this.props; const { editor } = this.state; if (!selectedSource || !editor) { return; } // only allow one conditionalPanel location. if (conditionalPanelLocation) { closeConditionalPanel(); } const target = event.target; const { id: sourceId } = selectedSource; const line = lineAtHeight(editor, sourceId, event); if (typeof line != "number") { return; } const location = createLocation({ line, column: undefined, source: selectedSource, }); if (target.classList.contains("CodeMirror-linenumber")) { const lineText = getLineText( sourceId, selectedSourceTextContent, line ).trim(); showMenu(event, [ ...createBreakpointItems(cx, location, breakpointActions, lineText), { type: "separator" }, continueToHereItem(cx, location, isPaused, editorActions), { type: "separator" }, blackBoxLineMenuItem( cx, selectedSource, editorActions, editor, blackboxedRanges, isSourceOnIgnoreList, line ), ]); return; } if (target.getAttribute("id") === "columnmarker") { return; } this.setState({ contextMenu: event }); } clearContextMenu = () => { this.setState({ contextMenu: null }); }; onGutterClick = (cm, line, gutter, ev) => { const { cx, selectedSource, conditionalPanelLocation, closeConditionalPanel, addBreakpointAtLine, continueToHere, breakableLines, blackboxedRanges, isSourceOnIgnoreList, } = this.props; // ignore right clicks in the gutter if (isSecondary(ev) || ev.button === 2 || !selectedSource) { return; } if (conditionalPanelLocation) { closeConditionalPanel(); return; } if (gutter === "CodeMirror-foldgutter") { return; } const sourceLine = toSourceLine(selectedSource.id, line); if (typeof sourceLine !== "number") { return; } // ignore clicks on a non-breakable line if (!breakableLines.has(sourceLine)) { return; } if (isCmd(ev)) { continueToHere( cx, createLocation({ line: sourceLine, column: undefined, source: selectedSource, }) ); return; } addBreakpointAtLine( cx, sourceLine, ev.altKey, ev.shiftKey || isLineBlackboxed( blackboxedRanges[selectedSource.url], sourceLine, isSourceOnIgnoreList ) ); }; onGutterContextMenu = event => { this.openMenu(event); }; onClick(e) { const { cx, selectedSource, updateCursorPosition, jumpToMappedLocation } = this.props; if (selectedSource) { const sourceLocation = getSourceLocationFromMouseEvent( this.state.editor, selectedSource, e ); if (e.metaKey && e.altKey) { jumpToMappedLocation(cx, sourceLocation); } updateCursorPosition(sourceLocation); } } shouldScrollToLocation(nextProps, editor) { const { selectedLocation, selectedSource, selectedSourceTextContent } = this.props; if ( !editor || !nextProps.selectedSource || !nextProps.selectedLocation || !nextProps.selectedLocation.line || !nextProps.selectedSourceTextContent ) { return false; } const isFirstLoad = (!selectedSource || !selectedSourceTextContent) && nextProps.selectedSourceTextContent; const locationChanged = selectedLocation !== nextProps.selectedLocation; const symbolsChanged = nextProps.symbols != this.props.symbols; return isFirstLoad || locationChanged || symbolsChanged; } scrollToLocation(nextProps, editor) { const { selectedLocation, selectedSource } = nextProps; let { line, column } = toEditorPosition(selectedLocation); if (selectedSource && hasDocument(selectedSource.id)) { const doc = getDocument(selectedSource.id); const lineText = doc.getLine(line); column = Math.max(column, getIndentation(lineText)); } scrollToColumn(editor.codeMirror, line, column); } setText(props, editor) { const { selectedSource, selectedSourceTextContent, symbols } = props; if (!editor) { return; } // check if we previously had a selected source if (!selectedSource) { this.clearEditor(); return; } if (!selectedSourceTextContent?.value) { showLoading(editor); return; } if (selectedSourceTextContent.state === "rejected") { let { value } = selectedSourceTextContent; if (typeof value !== "string") { value = "Unexpected source error"; } this.showErrorMessage(value); return; } showSourceText(editor, selectedSource, selectedSourceTextContent, symbols); } clearEditor() { const { editor } = this.state; if (!editor) { return; } clearEditor(editor); } showErrorMessage(msg) { const { editor } = this.state; if (!editor) { return; } showErrorMessage(editor, msg); } getInlineEditorStyles() { const { searchInFileEnabled } = this.props; if (searchInFileEnabled) { return { height: `calc(100% - ${cssVars.searchbarHeight})`, }; } return { height: "100%", }; } renderItems() { const { cx, selectedSource, conditionalPanelLocation, isPaused, inlinePreviewEnabled, editorWrappingEnabled, highlightedLineRange, blackboxedRanges, isSourceOnIgnoreList, selectedSourceIsBlackBoxed, } = this.props; const { editor, contextMenu } = this.state; if (!selectedSource || !editor || !getDocument(selectedSource.id)) { return null; } return (
{highlightedLineRange ? ( ) : null} {isSourceOnIgnoreList || selectedSourceIsBlackBoxed ? ( ) : null} {conditionalPanelLocation ? : null} {isPaused && inlinePreviewEnabled ? ( ) : null}
); } renderSearchInFileBar() { if (!this.props.selectedSource) { return null; } return ; } render() { const { selectedSourceIsBlackBoxed, skipPausing } = this.props; return (
(this.$editorWrapper = c)} >
{this.renderSearchInFileBar()} {this.renderItems()}
); } } Editor.contextTypes = { shortcuts: PropTypes.object, }; const mapStateToProps = state => { const selectedSource = getSelectedSource(state); const selectedLocation = getSelectedLocation(state); return { cx: getThreadContext(state), selectedLocation, selectedSource, selectedSourceTextContent: getSelectedSourceTextContent(state), selectedSourceIsBlackBoxed: selectedSource ? isSourceBlackBoxed(state, selectedSource) : null, isSourceOnIgnoreList: isSourceMapIgnoreListEnabled(state) && isSourceOnSourceMapIgnoreList(state, selectedSource), searchInFileEnabled: getActiveSearch(state) === "file", conditionalPanelLocation: getConditionalPanelLocation(state), symbols: getSymbols(state, selectedLocation), isPaused: getIsCurrentThreadPaused(state), skipPausing: getSkipPausing(state), inlinePreviewEnabled: getInlinePreview(state), editorWrappingEnabled: getEditorWrapping(state), highlightedCalls: getHighlightedCalls(state, getCurrentThread(state)), blackboxedRanges: getBlackBoxRanges(state), breakableLines: getSelectedBreakableLines(state), highlightedLineRange: getHighlightedLineRangeForSelectedSource(state), }; }; const mapDispatchToProps = dispatch => ({ ...bindActionCreators( { openConditionalPanel: actions.openConditionalPanel, closeConditionalPanel: actions.closeConditionalPanel, continueToHere: actions.continueToHere, toggleBreakpointAtLine: actions.toggleBreakpointAtLine, addBreakpointAtLine: actions.addBreakpointAtLine, jumpToMappedLocation: actions.jumpToMappedLocation, updateViewport: actions.updateViewport, updateCursorPosition: actions.updateCursorPosition, closeTab: actions.closeTab, toggleBlackBox: actions.toggleBlackBox, highlightCalls: actions.highlightCalls, unhighlightCalls: actions.unhighlightCalls, }, dispatch ), breakpointActions: breakpointItemActions(dispatch), editorActions: editorItemActions(dispatch), }); export default connect(mapStateToProps, mapDispatchToProps)(Editor);