/* 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 . */ // @flow 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 classnames from "classnames"; import { debounce } from "lodash"; import { getLineText } from "./../../utils/source"; 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 } from "./menus/editor"; import type { BreakpointItemActions } from "./menus/breakpoints"; import type { EditorItemActions } from "./menus/editor"; import { getActiveSearch, getSelectedLocation, getSelectedSourceWithContent, getConditionalPanelLocation, getSymbols, getIsPaused, getCurrentThread, getThreadContext, getSkipPausing, getInlinePreview, getEditorWrapping, getHighlightedCalls, } from "../../selectors"; // Redux actions import actions from "../../actions"; import SearchBar from "./SearchBar"; 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 { 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"; import Services from "devtools-services"; 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"; import type SourceEditor from "../../utils/editor/source-editor"; import type { SymbolDeclarations } from "../../workers/parser"; import type { SourceLocation, SourceWithContent, ThreadContext, HighlightedCalls as highlightedCallsType, } from "../../types"; const cssVars = { searchbarHeight: "var(--editor-searchbar-height)", }; type OwnProps = {| startPanelSize: number, endPanelSize: number, |}; export type Props = { cx: ThreadContext, selectedLocation: ?SourceLocation, selectedSource: ?SourceWithContent, searchOn: boolean, startPanelSize: number, endPanelSize: number, conditionalPanelLocation: SourceLocation, symbols: SymbolDeclarations, isPaused: boolean, skipPausing: boolean, inlinePreviewEnabled: boolean, editorWrappingEnabled: boolean, highlightedCalls: ?highlightedCallsType, // Actions openConditionalPanel: typeof actions.openConditionalPanel, closeConditionalPanel: typeof actions.closeConditionalPanel, continueToHere: typeof actions.continueToHere, addBreakpointAtLine: typeof actions.addBreakpointAtLine, jumpToMappedLocation: typeof actions.jumpToMappedLocation, toggleBreakpointAtLine: typeof actions.toggleBreakpointAtLine, traverseResults: typeof actions.traverseResults, updateViewport: typeof actions.updateViewport, updateCursorPosition: typeof actions.updateCursorPosition, closeTab: typeof actions.closeTab, breakpointActions: BreakpointItemActions, editorActions: EditorItemActions, toggleBlackBox: typeof actions.toggleBlackBox, highlightCalls: typeof actions.highlightCalls, unhighlightCalls: typeof actions.unhighlightCalls, }; type State = { editor: SourceEditor, contextMenu: ?MouseEvent, }; class Editor extends PureComponent { $editorWrapper: ?HTMLDivElement; constructor(props: Props) { super(props); this.state = { highlightedLineRange: null, editor: (null: any), contextMenu: null, }; } componentWillReceiveProps(nextProps: Props) { let { editor } = this.state; if (!editor && nextProps.selectedSource) { editor = this.setupEditor(); } startOperation(); this.setText(nextProps, editor); this.setSize(nextProps, editor); 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 // $FlowIgnore 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: KeyboardEvent) => { 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: any) }); } 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; } const line = getCursorLine(codeMirror); return toSourceLine(selectedSource.id, line); } onToggleBreakpoint = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); const line = this.getCurrentLine(); if (typeof line !== "number") { return; } this.props.toggleBreakpointAtLine(this.props.cx, line); }; onToggleConditionalPanel = (e: KeyboardEvent) => { 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) { return; } if (typeof line !== "number") { return; } return openConditionalPanel( { line, column, sourceId: selectedSource.id, }, false ); }; onEditorScroll = debounce(this.props.updateViewport, 75); commandKeyDown = (e: KeyboardEvent) => { const { key } = e; if (this.props.isPaused && key === "Meta") { const { cx, highlightCalls } = this.props; highlightCalls(cx); } }; commandKeyUp = (e: KeyboardEvent) => { const { key } = e; if (key === "Meta") { const { cx, unhighlightCalls } = this.props; unhighlightCalls(cx); } }; onKeyDown(e: KeyboardEvent) { 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: KeyboardEvent) => { if (!this.state.editor) { return; } const { codeMirror } = this.state.editor; if (codeMirror.listSelections().length > 1) { codeMirror.execCommand("singleSelection"); e.preventDefault(); } }; openMenu(event: MouseEvent) { event.stopPropagation(); event.preventDefault(); const { cx, selectedSource, breakpointActions, editorActions, isPaused, conditionalPanelLocation, closeConditionalPanel, } = this.props; const { editor } = this.state; if (!selectedSource || !editor) { return; } // only allow one conditionalPanel location. if (conditionalPanelLocation) { closeConditionalPanel(); } const target: Element = (event.target: any); const { id: sourceId } = selectedSource; const line = lineAtHeight(editor, sourceId, event); if (typeof line != "number") { return; } const location = { line, column: undefined, sourceId }; if (target.classList.contains("CodeMirror-linenumber")) { const lineText = getLineText( sourceId, selectedSource.content, line ).trim(); return showMenu(event, [ ...createBreakpointItems(cx, location, breakpointActions, lineText), { type: "separator" }, continueToHereItem(cx, location, isPaused, editorActions), ]); } if (target.getAttribute("id") === "columnmarker") { return; } this.setState({ contextMenu: event }); } clearContextMenu = () => { this.setState({ contextMenu: null }); }; onGutterClick = ( cm: Object, line: number, gutter: string, ev: MouseEvent ) => { const { cx, selectedSource, conditionalPanelLocation, closeConditionalPanel, addBreakpointAtLine, continueToHere, toggleBlackBox, } = this.props; // ignore right clicks in the gutter if (isSecondary(ev) || ev.button === 2 || !selectedSource) { return; } // if user clicks gutter to set breakpoint on blackboxed source, un-blackbox the source. if (selectedSource?.isBlackBoxed) { toggleBlackBox(cx, selectedSource); } if (conditionalPanelLocation) { return closeConditionalPanel(); } if (gutter === "CodeMirror-foldgutter") { return; } const sourceLine = toSourceLine(selectedSource.id, line); if (typeof sourceLine !== "number") { return; } if (isCmd(ev)) { return continueToHere(cx, { line: sourceLine, column: undefined, sourceId: selectedSource.id, }); } return addBreakpointAtLine(cx, sourceLine, ev.altKey, ev.shiftKey); }; onGutterContextMenu = (event: MouseEvent) => { return this.openMenu(event); }; onClick(e: MouseEvent) { 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: Props, editor: SourceEditor) { const { selectedLocation, selectedSource } = this.props; if ( !editor || !nextProps.selectedSource || !nextProps.selectedLocation || !nextProps.selectedLocation.line || !nextProps.selectedSource.content ) { return false; } const isFirstLoad = (!selectedSource || !selectedSource.content) && nextProps.selectedSource.content; const locationChanged = selectedLocation !== nextProps.selectedLocation; const symbolsChanged = nextProps.symbols != this.props.symbols; return isFirstLoad || locationChanged || symbolsChanged; } scrollToLocation(nextProps: Props, editor: SourceEditor) { const { selectedLocation, selectedSource } = nextProps; if (selectedLocation && this.shouldScrollToLocation(nextProps, editor)) { let { line, column } = toEditorPosition(selectedLocation); if (selectedSource && hasDocument(selectedSource.id)) { const doc = getDocument(selectedSource.id); const lineText: ?string = doc.getLine(line); column = Math.max(column, getIndentation(lineText)); } scrollToColumn(editor.codeMirror, line, column); } } setSize(nextProps: Props, editor: SourceEditor) { if (!editor) { return; } if ( nextProps.startPanelSize !== this.props.startPanelSize || nextProps.endPanelSize !== this.props.endPanelSize ) { editor.codeMirror.setSize(); } } setText(props: Props, editor: ?SourceEditor) { const { selectedSource, symbols } = props; if (!editor) { return; } // check if we previously had a selected source if (!selectedSource) { return this.clearEditor(); } if (!selectedSource.content) { return showLoading(editor); } if (selectedSource.content.state === "rejected") { let { value } = selectedSource.content; if (typeof value !== "string") { value = "Unexpected source error"; } return this.showErrorMessage(value); } return showSourceText( editor, selectedSource, selectedSource.content.value, symbols ); } clearEditor() { const { editor } = this.state; if (!editor) { return; } clearEditor(editor); } showErrorMessage(msg: string) { const { editor } = this.state; if (!editor) { return; } showErrorMessage(editor, msg); } getInlineEditorStyles() { const { searchOn } = this.props; if (searchOn) { return { height: `calc(100% - ${cssVars.searchbarHeight})`, }; } return { height: "100%", }; } renderItems() { const { cx, selectedSource, conditionalPanelLocation, isPaused, inlinePreviewEnabled, editorWrappingEnabled, } = this.props; const { editor, contextMenu } = this.state; if (!selectedSource || !editor || !getDocument(selectedSource.id)) { return null; } return ( { } {conditionalPanelLocation ? : null} {features.columnBreakpoints ? ( ) : null} {isPaused && inlinePreviewEnabled ? ( ) : null} ); } renderSearchBar() { const { editor } = this.state; if (!this.props.selectedSource) { return null; } return ; } render() { const { selectedSource, skipPausing } = this.props; return ( (this.$editorWrapper = c)} > {this.renderSearchBar()} {this.renderItems()} ); } } Editor.contextTypes = { shortcuts: PropTypes.object, }; const mapStateToProps = state => { const selectedSource = getSelectedSourceWithContent(state); return { cx: getThreadContext(state), selectedLocation: getSelectedLocation(state), selectedSource, searchOn: getActiveSearch(state) === "file", conditionalPanelLocation: getConditionalPanelLocation(state), symbols: getSymbols(state, selectedSource), isPaused: getIsPaused(state, getCurrentThread(state)), skipPausing: getSkipPausing(state), inlinePreviewEnabled: getInlinePreview(state), editorWrappingEnabled: getEditorWrapping(state), highlightedCalls: getHighlightedCalls(state, getCurrentThread(state)), }; }; const mapDispatchToProps = dispatch => ({ ...bindActionCreators( { openConditionalPanel: actions.openConditionalPanel, closeConditionalPanel: actions.closeConditionalPanel, continueToHere: actions.continueToHere, toggleBreakpointAtLine: actions.toggleBreakpointAtLine, addBreakpointAtLine: actions.addBreakpointAtLine, jumpToMappedLocation: actions.jumpToMappedLocation, traverseResults: actions.traverseResults, 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);