diff options
Diffstat (limited to 'devtools/client/debugger/src/utils/editor')
17 files changed, 2324 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/editor/create-editor.js b/devtools/client/debugger/src/utils/editor/create-editor.js new file mode 100644 index 0000000000..3f44ddaa67 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/create-editor.js @@ -0,0 +1,44 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import SourceEditor from "./source-editor"; +import { features, prefs } from "../prefs"; + +export function createEditor() { + const gutters = ["breakpoints", "hit-markers", "CodeMirror-linenumbers"]; + + if (features.codeFolding) { + gutters.push("CodeMirror-foldgutter"); + } + + return new SourceEditor({ + mode: "javascript", + foldGutter: features.codeFolding, + enableCodeFolding: features.codeFolding, + readOnly: true, + lineNumbers: true, + theme: "mozilla", + styleActiveLine: false, + lineWrapping: prefs.editorWrapping, + matchBrackets: true, + showAnnotationRuler: true, + gutters, + value: " ", + extraKeys: { + // Override code mirror keymap to avoid conflicts with split console. + Esc: false, + "Cmd-F": false, + "Ctrl-F": false, + "Cmd-G": false, + "Ctrl-G": false, + }, + cursorBlinkRate: prefs.cursorBlinkRate, + }); +} + +export function createHeadlessEditor() { + const editor = createEditor(); + editor.appendToLocalElement(document.createElement("div")); + return editor; +} diff --git a/devtools/client/debugger/src/utils/editor/get-expression.js b/devtools/client/debugger/src/utils/editor/get-expression.js new file mode 100644 index 0000000000..c664f163c3 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/get-expression.js @@ -0,0 +1,54 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +export function tokenAtTextPosition(cm, { line, column }) { + if (line < 0 || line >= cm.lineCount()) { + return null; + } + + const token = cm.getTokenAt({ line: line - 1, ch: column }); + if (!token) { + return null; + } + + return { startColumn: token.start, endColumn: token.end, type: token.type }; +} + +// The strategy of querying codeMirror tokens was borrowed +// from Chrome's inital implementation in JavaScriptSourceFrame.js#L414 +export function getExpressionFromCoords(cm, coord) { + const token = tokenAtTextPosition(cm, coord); + if (!token) { + return null; + } + + let startHighlight = token.startColumn; + const endHighlight = token.endColumn; + const lineNumber = coord.line; + const line = cm.doc.getLine(coord.line - 1); + while (startHighlight > 1 && line.charAt(startHighlight - 1) === ".") { + const tokenBefore = tokenAtTextPosition(cm, { + line: coord.line, + column: startHighlight - 2, + }); + + if (!tokenBefore || !tokenBefore.type) { + return null; + } + + startHighlight = tokenBefore.startColumn; + } + + const expression = line.substring(startHighlight, endHighlight) || ""; + + if (!expression) { + return null; + } + + const location = { + start: { line: lineNumber, column: startHighlight }, + end: { line: lineNumber, column: endHighlight }, + }; + return { expression, location }; +} diff --git a/devtools/client/debugger/src/utils/editor/get-token-location.js b/devtools/client/debugger/src/utils/editor/get-token-location.js new file mode 100644 index 0000000000..2642066051 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/get-token-location.js @@ -0,0 +1,16 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +export function getTokenLocation(codeMirror, tokenEl) { + const { left, top, width, height } = tokenEl.getBoundingClientRect(); + const { line, ch } = codeMirror.coordsChar({ + left: left + width / 2, + top: top + height / 2, + }); + + return { + line: line + 1, + column: ch, + }; +} diff --git a/devtools/client/debugger/src/utils/editor/index.js b/devtools/client/debugger/src/utils/editor/index.js new file mode 100644 index 0000000000..ce1875bd35 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/index.js @@ -0,0 +1,230 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +export * from "./source-documents"; +export * from "./get-token-location"; +export * from "./source-search"; +export * from "../ui"; +export { onMouseOver } from "./token-events"; + +import { createEditor } from "./create-editor"; + +import { isWasm, lineToWasmOffset, wasmOffsetToLine } from "../wasm"; +import { createLocation } from "../location"; + +let editor; + +export function getEditor() { + if (editor) { + return editor; + } + + editor = createEditor(); + return editor; +} + +export function removeEditor() { + editor = null; +} + +function getCodeMirror() { + return editor && editor.hasCodeMirror ? editor.codeMirror : null; +} + +export function startOperation() { + const codeMirror = getCodeMirror(); + if (!codeMirror) { + return; + } + + codeMirror.startOperation(); +} + +export function endOperation() { + const codeMirror = getCodeMirror(); + if (!codeMirror) { + return; + } + + codeMirror.endOperation(); +} + +export function toEditorLine(sourceId, lineOrOffset) { + if (isWasm(sourceId)) { + // TODO ensure offset is always "mappable" to edit line. + return wasmOffsetToLine(sourceId, lineOrOffset) || 0; + } + + return lineOrOffset ? lineOrOffset - 1 : 1; +} + +export function fromEditorLine(sourceId, line, sourceIsWasm) { + if (sourceIsWasm) { + return lineToWasmOffset(sourceId, line) || 0; + } + + return line + 1; +} + +export function toEditorPosition(location) { + return { + line: toEditorLine(location.sourceId, location.line), + column: isWasm(location.sourceId) || !location.column ? 0 : location.column, + }; +} + +export function toEditorRange(sourceId, location) { + const { start, end } = location; + return { + start: toEditorPosition({ ...start, sourceId }), + end: toEditorPosition({ ...end, sourceId }), + }; +} + +export function toSourceLine(sourceId, line) { + return isWasm(sourceId) ? lineToWasmOffset(sourceId, line) : line + 1; +} + +export function scrollToColumn(codeMirror, line, column) { + const { top, left } = codeMirror.charCoords({ line, ch: column }, "local"); + + if (!isVisible(codeMirror, top, left)) { + const scroller = codeMirror.getScrollerElement(); + const centeredX = Math.max(left - scroller.offsetWidth / 2, 0); + const centeredY = Math.max(top - scroller.offsetHeight / 2, 0); + + codeMirror.scrollTo(centeredX, centeredY); + } +} + +function isVisible(codeMirror, top, left) { + function withinBounds(x, min, max) { + return x >= min && x <= max; + } + + const scrollArea = codeMirror.getScrollInfo(); + const charWidth = codeMirror.defaultCharWidth(); + const fontHeight = codeMirror.defaultTextHeight(); + const { scrollTop, scrollLeft } = codeMirror.doc; + + const inXView = withinBounds( + left, + scrollLeft, + scrollLeft + (scrollArea.clientWidth - 30) - charWidth + ); + + const inYView = withinBounds( + top, + scrollTop, + scrollTop + scrollArea.clientHeight - fontHeight + ); + + return inXView && inYView; +} + +export function getLocationsInViewport( + { codeMirror }, + // Offset represents an allowance of characters or lines offscreen to improve + // perceived performance of column breakpoint rendering + offsetHorizontalCharacters = 100, + offsetVerticalLines = 20 +) { + // Get scroll position + if (!codeMirror) { + return { + start: { line: 0, column: 0 }, + end: { line: 0, column: 0 }, + }; + } + const charWidth = codeMirror.defaultCharWidth(); + const scrollArea = codeMirror.getScrollInfo(); + const { scrollLeft } = codeMirror.doc; + const rect = codeMirror.getWrapperElement().getBoundingClientRect(); + const topVisibleLine = + codeMirror.lineAtHeight(rect.top, "window") - offsetVerticalLines; + const bottomVisibleLine = + codeMirror.lineAtHeight(rect.bottom, "window") + offsetVerticalLines; + + const leftColumn = Math.floor( + scrollLeft > 0 ? scrollLeft / charWidth - offsetHorizontalCharacters : 0 + ); + const rightPosition = scrollLeft + (scrollArea.clientWidth - 30); + const rightCharacter = + Math.floor(rightPosition / charWidth) + offsetHorizontalCharacters; + + return { + start: { + line: topVisibleLine || 0, + column: leftColumn || 0, + }, + end: { + line: bottomVisibleLine || 0, + column: rightCharacter, + }, + }; +} + +export function markText({ codeMirror }, className, { start, end }) { + return codeMirror.markText( + { ch: start.column, line: start.line }, + { ch: end.column, line: end.line }, + { className } + ); +} + +export function lineAtHeight({ codeMirror }, sourceId, event) { + const _editorLine = codeMirror.lineAtHeight(event.clientY); + return toSourceLine(sourceId, _editorLine); +} + +export function getSourceLocationFromMouseEvent({ codeMirror }, source, e) { + const { line, ch } = codeMirror.coordsChar({ + left: e.clientX, + top: e.clientY, + }); + + return createLocation({ + source, + line: fromEditorLine(source.id, line, isWasm(source.id)), + column: isWasm(source.id) ? 0 : ch + 1, + }); +} + +export function forEachLine(codeMirror, iter) { + codeMirror.operation(() => { + codeMirror.doc.iter(0, codeMirror.lineCount(), iter); + }); +} + +export function removeLineClass(codeMirror, line, className) { + codeMirror.removeLineClass(line, "wrap", className); +} + +export function clearLineClass(codeMirror, className) { + forEachLine(codeMirror, line => { + removeLineClass(codeMirror, line, className); + }); +} + +export function getTextForLine(codeMirror, line) { + return codeMirror.getLine(line - 1).trim(); +} + +export function getCursorLine(codeMirror) { + return codeMirror.getCursor().line; +} + +export function getCursorColumn(codeMirror) { + return codeMirror.getCursor().ch; +} + +export function getTokenEnd(codeMirror, line, column) { + const token = codeMirror.getTokenAt({ + line, + ch: column + 1, + }); + const tokenString = token.string; + + return tokenString === "{" || tokenString === "[" ? null : token.end; +} diff --git a/devtools/client/debugger/src/utils/editor/moz.build b/devtools/client/debugger/src/utils/editor/moz.build new file mode 100644 index 0000000000..655c0dae43 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# 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 http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "create-editor.js", + "get-expression.js", + "get-token-location.js", + "index.js", + "source-documents.js", + "source-editor.js", + "source-search.js", + "token-events.js", +) diff --git a/devtools/client/debugger/src/utils/editor/source-documents.js b/devtools/client/debugger/src/utils/editor/source-documents.js new file mode 100644 index 0000000000..25040eb7cd --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-documents.js @@ -0,0 +1,249 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import { isWasm, getWasmLineNumberFormatter, renderWasmText } from "../wasm"; +import { isMinified } from "../isMinified"; +import { resizeBreakpointGutter, resizeToggleButton } from "../ui"; +import { javascriptLikeExtensions } from "../source"; + +let sourceDocs = {}; + +export function getDocument(key) { + return sourceDocs[key]; +} + +export function hasDocument(key) { + return !!getDocument(key); +} + +export function setDocument(key, doc) { + sourceDocs[key] = doc; +} + +export function removeDocument(key) { + delete sourceDocs[key]; +} + +export function clearDocuments() { + sourceDocs = {}; +} + +function resetLineNumberFormat(editor) { + const cm = editor.codeMirror; + cm.setOption("lineNumberFormatter", number => number); + resizeBreakpointGutter(cm); + resizeToggleButton(cm); +} + +function updateLineNumberFormat(editor, sourceId) { + if (!isWasm(sourceId)) { + resetLineNumberFormat(editor); + return; + } + const cm = editor.codeMirror; + const lineNumberFormatter = getWasmLineNumberFormatter(sourceId); + cm.setOption("lineNumberFormatter", lineNumberFormatter); + resizeBreakpointGutter(cm); + resizeToggleButton(cm); +} + +export function updateDocument(editor, source) { + if (!source) { + return; + } + + const sourceId = source.id; + const doc = getDocument(sourceId) || editor.createDocument(); + editor.replaceDocument(doc); + + updateLineNumberFormat(editor, sourceId); +} + +/* used to apply the context menu wrap line option change to all the docs */ +export function updateDocuments(updater) { + for (const key in sourceDocs) { + if (sourceDocs[key].cm == null) { + continue; + } else { + updater(sourceDocs[key]); + } + } +} + +export function clearEditor(editor) { + const doc = editor.createDocument("", { name: "text" }); + editor.replaceDocument(doc); + resetLineNumberFormat(editor); +} + +export function showLoading(editor) { + let doc = getDocument("loading"); + + if (doc) { + editor.replaceDocument(doc); + } else { + doc = editor.createDocument(L10N.getStr("loadingText"), { name: "text" }); + setDocument("loading", doc); + } +} + +export function showErrorMessage(editor, msg) { + let error; + if (msg.includes("WebAssembly binary source is not available")) { + error = L10N.getStr("wasmIsNotAvailable"); + } else { + error = L10N.getFormatStr("errorLoadingText3", msg); + } + const doc = editor.createDocument(error, { name: "text" }); + editor.replaceDocument(doc); + resetLineNumberFormat(editor); +} + +const contentTypeModeMap = new Map([ + ["text/javascript", { name: "javascript" }], + ["text/typescript", { name: "javascript", typescript: true }], + ["text/coffeescript", { name: "coffeescript" }], + [ + "text/typescript-jsx", + { + name: "jsx", + base: { name: "javascript", typescript: true }, + }, + ], + ["text/jsx", { name: "jsx" }], + ["text/x-elm", { name: "elm" }], + ["text/x-clojure", { name: "clojure" }], + ["text/x-clojurescript", { name: "clojure" }], + ["text/wasm", { name: "text" }], + ["text/html", { name: "htmlmixed" }], + ["text/plain", { name: "text" }], +]); + +const nonJSLanguageExtensionMap = new Map([ + ["c", { name: "text/x-csrc" }], + ["kt", { name: "text/x-kotlin" }], + ["cpp", { name: "text/x-c++src" }], + ["m", { name: "text/x-objectivec" }], + ["rs", { name: "text/x-rustsrc" }], + ["hx", { name: "text/x-haxe" }], +]); + +/** + * Returns Code Mirror mode for source content type + */ +// eslint-disable-next-line complexity +export function getMode(source, sourceTextContent, symbols) { + const content = sourceTextContent.value; + // Disable modes for minified files with 1+ million characters (See Bug 1569829). + if ( + content.type === "text" && + isMinified(source, sourceTextContent) && + content.value.length > 1000000 + ) { + return contentTypeModeMap.get("text/plain"); + } + + if (content.type !== "text") { + return contentTypeModeMap.get("text/plain"); + } + + const extension = source.displayURL.fileExtension; + if (extension === "jsx" || (symbols && symbols.hasJsx)) { + if (symbols && symbols.hasTypes) { + return contentTypeModeMap.get("text/typescript-jsx"); + } + return contentTypeModeMap.get("text/jsx"); + } + + if (symbols && symbols.hasTypes) { + if (symbols.hasJsx) { + return contentTypeModeMap.get("text/typescript-jsx"); + } + + return contentTypeModeMap.get("text/typescript"); + } + + // check for C and other non JS languages + if (nonJSLanguageExtensionMap.has(extension)) { + return nonJSLanguageExtensionMap.get(extension); + } + + // if the url ends with a known Javascript-like URL, provide JavaScript mode. + if (javascriptLikeExtensions.has(extension)) { + return contentTypeModeMap.get("text/javascript"); + } + + const { contentType, value: text } = content; + // Use HTML mode for files in which the first non whitespace + // character is `<` regardless of extension. + const isHTMLLike = () => text.match(/^\s*</); + if (!contentType) { + if (isHTMLLike()) { + return contentTypeModeMap.get("text/html"); + } + return contentTypeModeMap.get("text/plain"); + } + + // // @flow or /* @flow */ + if (text.match(/^\s*(\/\/ @flow|\/\* @flow \*\/)/)) { + return contentTypeModeMap.get("text/typescript"); + } + + if (contentTypeModeMap.has(contentType)) { + return contentTypeModeMap.get(contentType); + } + + if (isHTMLLike()) { + return contentTypeModeMap.get("text/html"); + } + + return contentTypeModeMap.get("text/plain"); +} + +function setMode(editor, source, sourceTextContent, symbols) { + const mode = getMode(source, sourceTextContent, symbols); + const currentMode = editor.codeMirror.getOption("mode"); + if (!currentMode || currentMode.name != mode.name) { + editor.setMode(mode); + } +} + +/** + * Handle getting the source document or creating a new + * document with the correct mode and text. + */ +export function showSourceText(editor, source, sourceTextContent, symbols) { + if (hasDocument(source.id)) { + const doc = getDocument(source.id); + if (editor.codeMirror.doc === doc) { + setMode(editor, source, sourceTextContent, symbols); + return; + } + + editor.replaceDocument(doc); + updateLineNumberFormat(editor, source.id); + setMode(editor, source, sourceTextContent, symbols); + return; + } + + const content = sourceTextContent.value; + + const doc = editor.createDocument( + // We can set wasm text content directly from the constructor, so we pass an empty string + // here, and set the text after replacing the document. + content.type !== "wasm" ? content.value : "", + getMode(source, sourceTextContent, symbols) + ); + + setDocument(source.id, doc); + editor.replaceDocument(doc); + + if (content.type === "wasm") { + const wasmLines = renderWasmText(source.id, content); + // cm will try to split into lines anyway, saving memory + editor.setText({ split: () => wasmLines, match: () => false }); + } + + updateLineNumberFormat(editor, source.id); +} diff --git a/devtools/client/debugger/src/utils/editor/source-editor.css b/devtools/client/debugger/src/utils/editor/source-editor.css new file mode 100644 index 0000000000..b2ae305657 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-editor.css @@ -0,0 +1,271 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +:root { + --breakpoint-active-color: rgba(44, 187, 15, 0.2); + --breakpoint-active-color-hover: rgba(44, 187, 15, 0.5); + --debug-line-background: rgba(226, 236, 247, 0.5); + --debug-line-border: rgb(145, 188, 219); +} + +.theme-dark:root { + --debug-line-background: rgb(73, 82, 103); + --debug-line-border: rgb(119, 134, 162); + --breakpoint-active-color: rgba(45, 210, 158, 0.5); + --breakpoint-active-color-hover: rgba(0, 255, 175, 0.7); +} + +.CodeMirror .errors { + width: 16px; +} + +.CodeMirror .error { + display: inline-block; + margin-left: 5px; + width: 12px; + height: 12px; + opacity: 0.75; +} + +.CodeMirror .hit-counts { + width: 6px; +} + +.CodeMirror .hit-count { + display: inline-block; + height: 12px; + border: solid rgba(0, 0, 0, 0.2); + border-width: 1px 1px 1px 0; + border-radius: 0 3px 3px 0; + padding: 0 3px; + font-size: 10px; + pointer-events: none; +} + +.theme-dark .debug-line .CodeMirror-linenumber { + color: #c0c0c0; +} + +.debug-line .CodeMirror-line { + background-color: var(--debug-line-background) !important; + outline: var(--debug-line-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.debug-line .CodeMirror-activeline-background { + display: none; +} + +.CodeMirror { + cursor: text; + height: 100%; +} + +.CodeMirror-gutters { + cursor: default; +} + +/* This is to avoid the fake horizontal scrollbar div of codemirror to go 0 +height when floating scrollbars are active. Make sure that this value is equal +to the maximum of `min-height` specific to the `scrollbar[orient="horizontal"]` +selector in floating-scrollbar-light.css across all platforms. */ +.CodeMirror-hscrollbar { + min-height: 10px; +} + +/* This is to avoid the fake vertical scrollbar div of codemirror to go 0 +width when floating scrollbars are active. Make sure that this value is equal +to the maximum of `min-width` specific to the `scrollbar[orient="vertical"]` +selector in floating-scrollbar-light.css across all platforms. */ +.CodeMirror-vscrollbar { + min-width: 10px; +} + +.cm-trailingspace { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QUXCToH00Y1UgAAACFJREFUCNdjPMDBUc/AwNDAAAFMTAwMDA0OP34wQgX/AQBYgwYEx4f9lQAAAABJRU5ErkJggg=="); + opacity: 0.75; + background-position: left bottom; + background-repeat: repeat-x; +} + +/* CodeMirror dialogs styling */ + +.CodeMirror-dialog { + padding: 4px 3px; +} + +.CodeMirror-dialog, +.CodeMirror-dialog input { + font: message-box; +} + +/* Fold addon */ + +.CodeMirror-foldmarker { + color: blue; + text-shadow: #b9f 1px 1px 2px, #b9f -1px -1px 2px, #b9f 1px -1px 2px, + #b9f -1px 1px 2px; + font-family: sans-serif; + line-height: 0.3; + cursor: pointer; +} + +.CodeMirror-foldgutter { + width: 10px; +} + +.CodeMirror-foldgutter-open, +.CodeMirror-foldgutter-folded { + color: #555; + cursor: pointer; + line-height: 1; + padding: 0 1px; +} + +.CodeMirror-foldgutter-open::after, +.CodeMirror-foldgutter-open::before, +.CodeMirror-foldgutter-folded::after, +.CodeMirror-foldgutter-folded::before { + content: ""; + height: 0; + width: 0; + position: absolute; + border: 4px solid transparent; +} + +.CodeMirror-foldgutter-open::after { + border-top-color: var(--theme-codemirror-gutter-background); + top: 4px; +} + +.CodeMirror-foldgutter-open::before { + border-top-color: var(--theme-body-color); + top: 5px; +} + +.new-breakpoint .CodeMirror-foldgutter-open::after { + border-top-color: var(--theme-selection-background); +} + +.new-breakpoint .CodeMirror-foldgutter-open::before { + border-top-color: white; +} + +.CodeMirror-foldgutter-folded::after { + border-left-color: var(--theme-codemirror-gutter-background); + left: 3px; + top: 3px; +} + +.CodeMirror-foldgutter-folded::before { + border-left-color: var(--theme-body-color); + left: 4px; + top: 3px; +} + +.new-breakpoint .CodeMirror-foldgutter-folded::after { + border-left-color: var(--theme-selection-background); +} + +.new-breakpoint .CodeMirror-foldgutter-folded::before { + border-left-color: white; +} + +.CodeMirror-hints { + position: absolute; + z-index: 10; + overflow: hidden; + list-style: none; + margin: 0; + padding: 2px; + border-radius: 3px; + font-size: 90%; + max-height: 20em; + overflow-y: auto; +} + +.CodeMirror-hint { + margin: 0; + padding: 0 4px; + border-radius: 2px; + max-width: 19em; + overflow: hidden; + white-space: pre; + cursor: pointer; +} + +.CodeMirror-Tern-completion { + padding-inline-start: 22px; + position: relative; + line-height: 18px; +} + +.CodeMirror-Tern-completion:before { + position: absolute; + left: 2px; + bottom: 2px; + border-radius: 50%; + font-size: 12px; + font-weight: bold; + height: 15px; + width: 15px; + line-height: 16px; + text-align: center; + color: #ffffff; + box-sizing: border-box; +} + +.CodeMirror-Tern-completion-unknown:before { + content: "?"; +} + +.CodeMirror-Tern-completion-object:before { + content: "O"; +} + +.CodeMirror-Tern-completion-fn:before { + content: "F"; +} + +.CodeMirror-Tern-completion-array:before { + content: "A"; +} + +.CodeMirror-Tern-completion-number:before { + content: "N"; +} + +.CodeMirror-Tern-completion-string:before { + content: "S"; +} + +.CodeMirror-Tern-completion-bool:before { + content: "B"; +} + +.CodeMirror-Tern-completion-guess { + color: #999; +} + +.CodeMirror-Tern-tooltip { + border-radius: 3px; + padding: 2px 5px; + white-space: pre-wrap; + max-width: 40em; + position: absolute; + z-index: 10; +} + +.CodeMirror-Tern-hint-doc { + max-width: 25em; +} + +.CodeMirror-Tern-farg-current { + text-decoration: underline; +} + +.CodeMirror-Tern-fhint-guess { + opacity: 0.7; +} diff --git a/devtools/client/debugger/src/utils/editor/source-editor.js b/devtools/client/debugger/src/utils/editor/source-editor.js new file mode 100644 index 0000000000..8501e22a55 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-editor.js @@ -0,0 +1,145 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +/** + * CodeMirror source editor utils + * @module utils/source-editor + */ + +const CodeMirror = require("codemirror"); + +require("raw!chrome://devtools/content/shared/sourceeditor/codemirror/lib/codemirror.css"); +require("codemirror/mode/javascript/javascript"); +require("codemirror/mode/htmlmixed/htmlmixed"); +require("codemirror/mode/coffeescript/coffeescript"); +require("codemirror/mode/jsx/jsx"); +require("codemirror/mode/elm/elm"); +require("codemirror/mode/clojure/clojure"); +require("codemirror/mode/haxe/haxe"); +require("codemirror/addon/search/searchcursor"); +require("codemirror/addon/fold/foldcode"); +require("codemirror/addon/fold/brace-fold"); +require("codemirror/addon/fold/indent-fold"); +require("codemirror/addon/fold/foldgutter"); +require("codemirror/addon/runmode/runmode"); +require("codemirror/addon/selection/active-line"); +require("codemirror/addon/edit/matchbrackets"); +require("codemirror/addon/display/placeholder"); +require("codemirror/mode/clike/clike"); +require("codemirror/mode/rust/rust"); + +require("raw!chrome://devtools/content/debugger/src/utils/editor/source-editor.css"); + +// NOTE: we should eventually use debugger-html context type mode + +// Maximum allowed margin (in number of lines) from top or bottom of the editor +// while shifting to a line which was initially out of view. +const MAX_VERTICAL_OFFSET = 3; + +export default class SourceEditor { + opts; + editor; + + constructor(opts) { + this.opts = opts; + } + + appendToLocalElement(node) { + this.editor = CodeMirror(node, this.opts); + } + + destroy() { + // Unlink the current document. + if (this.editor.doc) { + this.editor.doc.cm = null; + } + } + + get codeMirror() { + return this.editor; + } + + get CodeMirror() { + return CodeMirror; + } + + setText(str) { + this.editor.setValue(str); + } + + getText() { + return this.editor.getValue(); + } + + setMode(value) { + this.editor.setOption("mode", value); + } + + /** + * Replaces the current document with a new source document + * @memberof utils/source-editor + */ + replaceDocument(doc) { + this.editor.swapDoc(doc); + } + + /** + * Creates a CodeMirror Document + * @returns CodeMirror.Doc + * @memberof utils/source-editor + */ + createDocument() { + return new CodeMirror.Doc(""); + } + + /** + * Aligns the provided line to either "top", "center" or "bottom" of the + * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or + * bottom. + * @memberof utils/source-editor + */ + alignLine(line, align = "top") { + const cm = this.editor; + const editorClientRect = cm.getWrapperElement().getBoundingClientRect(); + + const from = cm.lineAtHeight(editorClientRect.top, "page"); + const to = cm.lineAtHeight( + editorClientRect.height + editorClientRect.top, + "page" + ); + + const linesVisible = to - from; + const halfVisible = Math.round(linesVisible / 2); + + // If the target line is in view, skip the vertical alignment part. + if (line <= to && line >= from) { + return; + } + + // Setting the offset so that the line always falls in the upper half + // of visible lines (lower half for bottom aligned). + // MAX_VERTICAL_OFFSET is the maximum allowed value. + const offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET); + + let topLine = + { + center: Math.max(line - halfVisible, 0), + bottom: Math.max(line - linesVisible + offset, 0), + top: Math.max(line - offset, 0), + }[align || "top"] || offset; + + // Bringing down the topLine to total lines in the editor if exceeding. + topLine = Math.min(topLine, cm.lineCount()); + this.setFirstVisibleLine(topLine); + } + + /** + * Scrolls the view such that the given line number is the first visible line. + * @memberof utils/source-editor + */ + setFirstVisibleLine(line) { + const { top } = this.editor.charCoords({ line, ch: 0 }, "local"); + this.editor.scrollTo(0, top); + } +} diff --git a/devtools/client/debugger/src/utils/editor/source-search.js b/devtools/client/debugger/src/utils/editor/source-search.js new file mode 100644 index 0000000000..92097377ba --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/source-search.js @@ -0,0 +1,327 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import buildQuery from "../build-query"; + +/** + * @memberof utils/source-search + * @static + */ +function getSearchCursor(cm, query, pos, modifiers) { + const regexQuery = buildQuery(query, modifiers, { isGlobal: true }); + return cm.getSearchCursor(regexQuery, pos); +} + +/** + * @memberof utils/source-search + * @static + */ +function SearchState() { + this.posFrom = this.posTo = this.query = null; + this.overlay = null; + this.results = []; +} + +/** + * @memberof utils/source-search + * @static + */ +function getSearchState(cm, query) { + const state = cm.state.search || (cm.state.search = new SearchState()); + return state; +} + +function isWhitespace(query) { + return !query.match(/\S/); +} + +/** + * This returns a mode object used by CodeMirror's addOverlay function + * to parse and style tokens in the file. + * The mode object contains a tokenizer function (token) which takes + * a character stream as input, advances it a character at a time, + * and returns style(s) for that token. For more details see + * https://codemirror.net/5/doc/manual.html#modeapi + * + * @memberof utils/source-search + * @static + */ +function searchOverlay(query, modifiers) { + const regexQuery = buildQuery(query, modifiers, { + ignoreSpaces: true, + // regex must be global for the overlay + isGlobal: true, + }); + + return { + token: function (stream, state) { + // set the last index to be the current stream position + // this acts as an offset + regexQuery.lastIndex = stream.pos; + const match = regexQuery.exec(stream.string); + if (match && match.index === stream.pos) { + // if we have a match at the current stream position + // set the class for a match + stream.pos += match[0].length || 1; + return "highlight highlight-full"; + } + + if (match) { + // if we have a match somewhere in the line, go to that point in the + // stream + stream.pos = match.index; + } else { + // if we have no matches in this line, skip to the end of the line + stream.skipToEnd(); + } + + return null; + }, + }; +} + +/** + * @memberof utils/source-search + * @static + */ +function updateOverlay(cm, state, query, modifiers) { + cm.removeOverlay(state.overlay); + state.overlay = searchOverlay(query, modifiers); + cm.addOverlay(state.overlay, { opaque: false }); +} + +function updateCursor(cm, state, keepSelection) { + state.posTo = cm.getCursor("anchor"); + state.posFrom = cm.getCursor("head"); + + if (!keepSelection) { + state.posTo = { line: 0, ch: 0 }; + state.posFrom = { line: 0, ch: 0 }; + } +} + +export function getMatchIndex(count, currentIndex, rev) { + if (!rev) { + if (currentIndex == count - 1) { + return 0; + } + + return currentIndex + 1; + } + + if (currentIndex == 0) { + return count - 1; + } + + return currentIndex - 1; +} + +/** + * If there's a saved search, selects the next results. + * Otherwise, creates a new search and selects the first + * result. + * + * @memberof utils/source-search + * @static + */ +function doSearch( + ctx, + rev, + query, + keepSelection, + modifiers, + focusFirstResult = true +) { + const { cm, ed } = ctx; + if (!cm) { + return null; + } + const defaultIndex = { line: -1, ch: -1 }; + + return cm.operation(function () { + if (!query || isWhitespace(query)) { + clearSearch(cm, query); + return null; + } + + const state = getSearchState(cm, query); + const isNewQuery = state.query !== query; + state.query = query; + + updateOverlay(cm, state, query, modifiers); + updateCursor(cm, state, keepSelection); + const searchLocation = searchNext(ctx, rev, query, isNewQuery, modifiers); + + // We don't want to jump the editor + // when we're selecting text + if (!cm.state.selectingText && searchLocation && focusFirstResult) { + ed.alignLine(searchLocation.from.line, "center"); + cm.setSelection(searchLocation.from, searchLocation.to); + } + + return searchLocation ? searchLocation.from : defaultIndex; + }); +} + +export function searchSourceForHighlight( + ctx, + rev, + query, + keepSelection, + modifiers, + line, + ch +) { + const { cm } = ctx; + if (!cm) { + return; + } + + cm.operation(function () { + const state = getSearchState(cm, query); + const isNewQuery = state.query !== query; + state.query = query; + + updateOverlay(cm, state, query, modifiers); + updateCursor(cm, state, keepSelection); + findNextOnLine(ctx, rev, query, isNewQuery, modifiers, line, ch); + }); +} + +function getCursorPos(newQuery, rev, state) { + if (newQuery) { + return rev ? state.posFrom : state.posTo; + } + + return rev ? state.posTo : state.posFrom; +} + +/** + * Selects the next result of a saved search. + * + * @memberof utils/source-search + * @static + */ +function searchNext(ctx, rev, query, newQuery, modifiers) { + const { cm } = ctx; + let nextMatch; + cm.operation(function () { + const state = getSearchState(cm, query); + const pos = getCursorPos(newQuery, rev, state); + + if (!state.query) { + return; + } + + let cursor = getSearchCursor(cm, state.query, pos, modifiers); + + const location = rev + ? { line: cm.lastLine(), ch: null } + : { line: cm.firstLine(), ch: 0 }; + + if (!cursor.find(rev) && state.query) { + cursor = getSearchCursor(cm, state.query, location, modifiers); + if (!cursor.find(rev)) { + return; + } + } + + nextMatch = { from: cursor.from(), to: cursor.to() }; + }); + + return nextMatch; +} + +function findNextOnLine(ctx, rev, query, newQuery, modifiers, line, ch) { + const { cm, ed } = ctx; + cm.operation(function () { + const pos = { line: line - 1, ch }; + let cursor = getSearchCursor(cm, query, pos, modifiers); + + if (!cursor.find(rev) && query) { + cursor = getSearchCursor(cm, query, pos, modifiers); + if (!cursor.find(rev)) { + return; + } + } + + // We don't want to jump the editor + // when we're selecting text + if (!cm.state.selectingText) { + ed.alignLine(cursor.from().line, "center"); + cm.setSelection(cursor.from(), cursor.to()); + } + }); +} + +/** + * Remove overlay. + * + * @memberof utils/source-search + * @static + */ +export function removeOverlay(ctx, query) { + const state = getSearchState(ctx.cm, query); + ctx.cm.removeOverlay(state.overlay); + const { line, ch } = ctx.cm.getCursor(); + ctx.cm.doc.setSelection({ line, ch }, { line, ch }, { scroll: false }); +} + +/** + * Clears the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function clearSearch(cm, query) { + const state = getSearchState(cm, query); + + state.results = []; + + if (!state.query) { + return; + } + cm.removeOverlay(state.overlay); + state.query = null; +} + +/** + * Starts a new search. + * + * @memberof utils/source-search + * @static + */ +export function find(ctx, query, keepSelection, modifiers, focusFirstResult) { + clearSearch(ctx.cm, query); + return doSearch( + ctx, + false, + query, + keepSelection, + modifiers, + focusFirstResult + ); +} + +/** + * Finds the next item based on the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function findNext(ctx, query, keepSelection, modifiers) { + return doSearch(ctx, false, query, keepSelection, modifiers); +} + +/** + * Finds the previous item based on the currently saved search. + * + * @memberof utils/source-search + * @static + */ +export function findPrev(ctx, query, keepSelection, modifiers) { + return doSearch(ctx, true, query, keepSelection, modifiers); +} + +export { buildQuery }; diff --git a/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap new file mode 100644 index 0000000000..f5bba6cd3e --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createEditor Adds codeFolding 1`] = ` +Object { + "cursorBlinkRate": 530, + "enableCodeFolding": true, + "extraKeys": Object { + "Cmd-F": false, + "Cmd-G": false, + "Ctrl-F": false, + "Ctrl-G": false, + "Esc": false, + }, + "foldGutter": true, + "gutters": Array [ + "breakpoints", + "hit-markers", + "CodeMirror-linenumbers", + "CodeMirror-foldgutter", + ], + "lineNumbers": true, + "lineWrapping": false, + "matchBrackets": true, + "mode": "javascript", + "readOnly": true, + "showAnnotationRuler": true, + "styleActiveLine": false, + "theme": "mozilla", + "value": " ", +} +`; + +exports[`createEditor Returns a SourceEditor 1`] = ` +Object { + "cursorBlinkRate": 530, + "enableCodeFolding": false, + "extraKeys": Object { + "Cmd-F": false, + "Cmd-G": false, + "Ctrl-F": false, + "Ctrl-G": false, + "Esc": false, + }, + "foldGutter": false, + "gutters": Array [ + "breakpoints", + "hit-markers", + "CodeMirror-linenumbers", + ], + "lineNumbers": true, + "lineWrapping": false, + "matchBrackets": true, + "mode": "javascript", + "readOnly": true, + "showAnnotationRuler": true, + "styleActiveLine": false, + "theme": "mozilla", + "value": " ", +} +`; diff --git a/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js new file mode 100644 index 0000000000..38e7241b2e --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js @@ -0,0 +1,25 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import { createEditor } from "../create-editor"; +import SourceEditor from "../source-editor"; + +import { features } from "../../prefs"; + +describe("createEditor", () => { + test("Returns a SourceEditor", () => { + const editor = createEditor(); + expect(editor).toBeInstanceOf(SourceEditor); + expect(editor.opts).toMatchSnapshot(); + expect(editor.opts.gutters).not.toContain("CodeMirror-foldgutter"); + }); + + test("Adds codeFolding", () => { + features.codeFolding = true; + const editor = createEditor(); + expect(editor).toBeInstanceOf(SourceEditor); + expect(editor.opts).toMatchSnapshot(); + expect(editor.opts.gutters).toContain("CodeMirror-foldgutter"); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/editor.spec.js b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js new file mode 100644 index 0000000000..d657437b19 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/editor.spec.js @@ -0,0 +1,203 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import { + toEditorLine, + toEditorPosition, + toEditorRange, + toSourceLine, + scrollToColumn, + markText, + lineAtHeight, + getSourceLocationFromMouseEvent, + forEachLine, + removeLineClass, + clearLineClass, + getTextForLine, + getCursorLine, +} from "../index"; + +import { makeMockSource } from "../../test-mockup"; + +describe("toEditorLine", () => { + it("returns an editor line", () => { + const testId = "test-123"; + const line = 30; + expect(toEditorLine(testId, line)).toEqual(29); + }); +}); + +describe("toEditorPosition", () => { + it("returns an editor position", () => { + const loc = { source: { id: "source" }, line: 100, column: 25 }; + expect(toEditorPosition(loc)).toEqual({ + line: 99, + column: 25, + }); + }); +}); + +describe("toEditorRange", () => { + it("returns an editor range", () => { + const testId = "test-123"; + const loc = { + start: { source: { id: testId }, line: 100, column: 25 }, + end: { source: { id: testId }, line: 200, column: 0 }, + }; + expect(toEditorRange(testId, loc)).toEqual({ + start: { line: 99, column: 25 }, + end: { line: 199, column: 0 }, + }); + }); +}); + +describe("toSourceLine", () => { + it("returns a source line", () => { + const testId = "test-123"; + const line = 30; + expect(toSourceLine(testId, line)).toEqual(31); + }); +}); + +const codeMirror = { + doc: { + iter: jest.fn((_, __, cb) => cb()), + }, + lineCount: jest.fn(() => 100), + getLine: jest.fn(() => "something"), + getCursor: jest.fn(() => ({ line: 3 })), + getScrollerElement: jest.fn(() => ({ + offsetWidth: 100, + offsetHeight: 100, + })), + getScrollInfo: () => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + clientHeight: 100, + clientWidth: 100, + }), + removeLineClass: jest.fn(), + operation: jest.fn(cb => cb()), + charCoords: jest.fn(() => ({ + top: 100, + right: 50, + bottom: 100, + left: 50, + })), + coordsChar: jest.fn(() => ({ line: 6, ch: 30 })), + lineAtHeight: jest.fn(() => 300), + markText: jest.fn(), + scrollTo: jest.fn(), + defaultCharWidth: jest.fn(() => 8), + defaultTextHeight: jest.fn(() => 16), +}; + +const editor = { codeMirror }; + +describe("scrollToColumn", () => { + it("calls codemirror APIs charCoords, getScrollerElement, scrollTo", () => { + scrollToColumn(codeMirror, 60, 123); + expect(codeMirror.charCoords).toHaveBeenCalledWith( + { line: 60, ch: 123 }, + "local" + ); + expect(codeMirror.scrollTo).toHaveBeenCalledWith(0, 50); + }); +}); + +describe("markText", () => { + it("calls codemirror API markText & returns marker", () => { + const loc = { + start: { line: 10, column: 0 }, + end: { line: 30, column: 50 }, + }; + markText(editor, "test-123", loc); + expect(codeMirror.markText).toHaveBeenCalledWith( + { ch: loc.start.column, line: loc.start.line }, + { ch: loc.end.column, line: loc.end.line }, + { className: "test-123" } + ); + }); +}); + +describe("lineAtHeight", () => { + it("calls codemirror API lineAtHeight", () => { + const e = { clientX: 30, clientY: 60 }; + expect(lineAtHeight(editor, "test-123", e)).toEqual(301); + expect(editor.codeMirror.lineAtHeight).toHaveBeenCalledWith(e.clientY); + }); +}); + +describe("getSourceLocationFromMouseEvent", () => { + it("calls codemirror API coordsChar & returns location", () => { + const source = makeMockSource(undefined, "test-123"); + const e = { clientX: 30, clientY: 60 }; + expect(getSourceLocationFromMouseEvent(editor, source, e)).toEqual({ + source, + sourceId: source.id, + line: 7, + column: 31, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(editor.codeMirror.coordsChar).toHaveBeenCalledWith({ + left: 30, + top: 60, + }); + }); +}); + +describe("forEachLine", () => { + it("calls codemirror API operation && doc.iter across a doc", () => { + const test = jest.fn(); + forEachLine(codeMirror, test); + expect(codeMirror.operation).toHaveBeenCalled(); + expect(codeMirror.doc.iter).toHaveBeenCalledWith(0, 100, test); + }); +}); + +describe("removeLineClass", () => { + it("calls codemirror API removeLineClass", () => { + const line = 3; + const className = "test-class"; + removeLineClass(codeMirror, line, className); + expect(codeMirror.removeLineClass).toHaveBeenCalledWith( + line, + "wrap", + className + ); + }); +}); + +describe("clearLineClass", () => { + it("Uses forEachLine & removeLineClass to clear class on all lines", () => { + codeMirror.operation.mockClear(); + codeMirror.doc.iter.mockClear(); + codeMirror.removeLineClass.mockClear(); + clearLineClass(codeMirror, "test-class"); + expect(codeMirror.operation).toHaveBeenCalled(); + expect(codeMirror.doc.iter).toHaveBeenCalledWith( + 0, + 100, + expect.any(Function) + ); + expect(codeMirror.removeLineClass).toHaveBeenCalled(); + }); +}); + +describe("getTextForLine", () => { + it("calls codemirror API getLine & returns line text", () => { + getTextForLine(codeMirror, 3); + expect(codeMirror.getLine).toHaveBeenCalledWith(2); + }); +}); +describe("getCursorLine", () => { + it("calls codemirror API getCursor & returns line number", () => { + getCursorLine(codeMirror); + expect(codeMirror.getCursor).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js new file mode 100644 index 0000000000..65ab5152f6 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js @@ -0,0 +1,160 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import CodeMirror from "codemirror"; +import { getExpressionFromCoords } from "../get-expression"; + +describe("get-expression", () => { + let isCreateTextRangeDefined; + + beforeAll(() => { + if (document.body.createTextRange) { + isCreateTextRangeDefined = true; + } else { + isCreateTextRangeDefined = false; + // CodeMirror needs createTextRange + // https://discuss.codemirror.net/t/working-in-jsdom-or-node-js-natively/138/5 + document.body.createTextRange = () => ({ + getBoundingClientRect: jest.fn(), + getClientRects: () => ({}), + }); + } + }); + + afterAll(() => { + if (!isCreateTextRangeDefined) { + delete document.body.createTextRange; + } + }); + + describe("getExpressionFromCoords", () => { + it("returns null when location.line is greater than the lineCount", () => { + const cm = CodeMirror(document.body, { + value: "let Line1;\n" + "let Line2;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 3, + column: 1, + }); + expect(result).toBeNull(); + }); + + it("gets the expression using CodeMirror.getTokenAt", () => { + const codemirrorMock = { + lineCount: () => 100, + getTokenAt: jest.fn(() => ({ start: 0, end: 0 })), + doc: { + getLine: () => "", + }, + }; + getExpressionFromCoords(codemirrorMock, { line: 1, column: 1 }); + expect(codemirrorMock.getTokenAt).toHaveBeenCalled(); + }); + + it("requests the correct line and column from codeMirror", () => { + const codemirrorMock = { + lineCount: () => 100, + getTokenAt: jest.fn(() => ({ start: 0, end: 1 })), + doc: { + getLine: jest.fn(() => ""), + }, + }; + getExpressionFromCoords(codemirrorMock, { line: 20, column: 5 }); + // getExpressionsFromCoords uses one based line indexing + // CodeMirror uses zero based line indexing + expect(codemirrorMock.getTokenAt).toHaveBeenCalledWith({ + line: 19, + ch: 5, + }); + expect(codemirrorMock.doc.getLine).toHaveBeenCalledWith(19); + }); + + it("when called with column 0 returns null", () => { + const cm = CodeMirror(document.body, { + value: "foo bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 0, + }); + expect(result).toBeNull(); + }); + + it("gets the expression when first token on the line", () => { + const cm = CodeMirror(document.body, { + value: "foo bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 1, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 3 }); + }); + + it("includes previous tokens in the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo.bar;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 5, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo.bar"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 7 }); + }); + + it("includes multiple previous tokens in the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo.bar.baz;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 10, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("foo.bar.baz"); + expect(result.location.start).toEqual({ line: 1, column: 0 }); + expect(result.location.end).toEqual({ line: 1, column: 11 }); + }); + + it("does not include tokens not part of the expression", () => { + const cm = CodeMirror(document.body, { + value: "foo bar.baz;\n", + mode: "javascript", + }); + + const result = getExpressionFromCoords(cm, { + line: 1, + column: 10, + }); + if (!result) { + throw new Error("no result"); + } + expect(result.expression).toEqual("bar.baz"); + expect(result.location.start).toEqual({ line: 1, column: 4 }); + expect(result.location.end).toEqual({ line: 1, column: 11 }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js new file mode 100644 index 0000000000..c4aa277f26 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js @@ -0,0 +1,31 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import { getTokenLocation } from "../get-token-location"; + +describe("getTokenLocation", () => { + const codemirror = { + coordsChar: jest.fn(() => ({ + line: 1, + ch: "C", + })), + }; + const token = { + getBoundingClientRect() { + return { + left: 10, + top: 20, + width: 10, + height: 10, + }; + }, + }; + it("calls into codeMirror", () => { + getTokenLocation(codemirror, token); + expect(codemirror.coordsChar).toHaveBeenCalledWith({ + left: 15, + top: 25, + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js new file mode 100644 index 0000000000..9d4b42e263 --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js @@ -0,0 +1,215 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import { getMode } from "../source-documents.js"; + +import { + makeMockSourceWithContent, + makeMockWasmSourceWithContent, +} from "../../test-mockup"; + +const defaultSymbolDeclarations = { + classes: [], + functions: [], + memberExpressions: [], + callExpressions: [], + objectProperties: [], + identifiers: [], + imports: [], + comments: [], + literals: [], + hasJsx: false, + hasTypes: false, + framework: undefined, +}; + +describe("source-documents", () => { + describe("getMode", () => { + it("// ", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/javascript", + "// @flow" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("/* @flow */", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/javascript", + " /* @flow */" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("mixed html", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "", + " <html" + ); + expect(getMode(source, source.content)).toEqual({ name: "htmlmixed" }); + }); + + it("elm", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/x-elm", + 'main = text "Hello, World!"' + ); + expect(getMode(source, source.content)).toEqual({ name: "elm" }); + }); + + it("returns jsx if contentType jsx is given", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/jsx", + "<h1></h1>" + ); + expect(getMode(source, source.content)).toEqual({ name: "jsx" }); + }); + + it("returns jsx if sourceMetaData says it's a react component", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "", + "<h1></h1>" + ); + expect( + getMode(source, source.content, { + ...defaultSymbolDeclarations, + hasJsx: true, + }) + ).toEqual({ name: "jsx" }); + }); + + it("returns jsx if the fileExtension is .jsx", () => { + const source = makeMockSourceWithContent( + "myComponent.jsx", + undefined, + "", + "<h1></h1>" + ); + expect(getMode(source, source.content)).toEqual({ name: "jsx" }); + }); + + it("returns text/x-haxe if the file extension is .hx", () => { + const source = makeMockSourceWithContent( + "myComponent.hx", + undefined, + "", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "text/x-haxe" }); + }); + + it("typescript", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/typescript", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("typescript-jsx", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/typescript-jsx", + "<h1></h1>" + ); + expect(getMode(source, source.content).base).toEqual({ + name: "javascript", + typescript: true, + }); + }); + + it("cross-platform clojure(script) with reader conditionals", () => { + const source = makeMockSourceWithContent( + "my-clojurescript-source-with-reader-conditionals.cljc", + undefined, + "text/x-clojure", + "(defn str->int [s] " + + " #?(:clj (java.lang.Integer/parseInt s) " + + " :cljs (js/parseInt s)))" + ); + expect(getMode(source, source.content)).toEqual({ name: "clojure" }); + }); + + it("clojurescript", () => { + const source = makeMockSourceWithContent( + "my-clojurescript-source.cljs", + undefined, + "text/x-clojurescript", + "(+ 1 2 3)" + ); + expect(getMode(source, source.content)).toEqual({ name: "clojure" }); + }); + + it("coffeescript", () => { + const source = makeMockSourceWithContent( + undefined, + undefined, + "text/coffeescript", + "x = (a) -> 3" + ); + expect(getMode(source, source.content)).toEqual({ name: "coffeescript" }); + }); + + it("wasm", () => { + const source = makeMockWasmSourceWithContent({ + binary: "\x00asm\x01\x00\x00\x00", + }); + expect(getMode(source, source.content.value)).toEqual({ name: "text" }); + }); + + it("marko", () => { + const source = makeMockSourceWithContent( + "http://localhost.com:7999/increment/sometestfile.marko", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + + it("es6", () => { + const source = makeMockSourceWithContent( + "http://localhost.com:7999/increment/sometestfile.es6", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + + it("vue", () => { + const source = makeMockSourceWithContent( + "http://localhost.com:7999/increment/sometestfile.vue?query=string", + undefined, + "does not matter", + "function foo(){}" + ); + expect(getMode(source, source.content)).toEqual({ name: "javascript" }); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js new file mode 100644 index 0000000000..33f479766a --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/tests/source-search.spec.js @@ -0,0 +1,182 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import { + find, + searchSourceForHighlight, + getMatchIndex, + removeOverlay, +} from "../source-search"; + +const getCursor = jest.fn(() => ({ line: 90, ch: 54 })); +const cursor = { + find: jest.fn(), + from: jest.fn(), + to: jest.fn(), +}; +const getSearchCursor = jest.fn(() => cursor); +const modifiers = { + caseSensitive: false, + regexMatch: false, + wholeWord: false, +}; + +const getCM = () => ({ + operation: jest.fn(cb => cb()), + addOverlay: jest.fn(), + removeOverlay: jest.fn(), + getCursor, + getSearchCursor, + firstLine: jest.fn(), + state: {}, +}); + +describe("source-search", () => { + describe("find", () => { + it("calls into CodeMirror APIs via clearSearch & doSearch", () => { + const ctx = { cm: getCM() }; + expect(ctx.cm.state).toEqual({}); + find(ctx, "test", false, modifiers); + // First we check the APIs called via clearSearch + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + // Next those via doSearch + expect(ctx.cm.operation).toHaveBeenCalled(); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + expect(ctx.cm.addOverlay).toHaveBeenCalledWith( + { token: expect.any(Function) }, + { opaque: false } + ); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor"); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("head"); + const search = { + query: "test", + posTo: { line: 0, ch: 0 }, + posFrom: { line: 0, ch: 0 }, + overlay: { token: expect.any(Function) }, + results: [], + }; + expect(ctx.cm.state).toEqual({ search }); + }); + + it("clears a previous overlay", () => { + const ctx = { cm: getCM() }; + ctx.cm.state.search = { + query: "foo", + posTo: null, + posFrom: null, + overlay: { token: expect.any(Function) }, + results: [], + }; + find(ctx, "test", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith({ + token: expect.any(Function), + }); + }); + + it("clears for empty queries", () => { + const ctx = { cm: getCM() }; + ctx.cm.state.search = { + query: "foo", + posTo: null, + posFrom: null, + overlay: null, + results: [], + }; + find(ctx, "", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + ctx.cm.removeOverlay.mockClear(); + ctx.cm.state.search.query = "bar"; + find(ctx, "", true, modifiers); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + }); + }); + + describe("searchSourceForHighlight", () => { + it("calls into CodeMirror APIs and sets the correct selection", () => { + const line = 15; + const from = { line, ch: 1 }; + const to = { line, ch: 5 }; + const cm = { + ...getCM(), + setSelection: jest.fn(), + getSearchCursor: () => ({ + find: () => true, + from: () => from, + to: () => to, + }), + }; + const ed = { alignLine: jest.fn() }; + const ctx = { cm, ed }; + + expect(ctx.cm.state).toEqual({}); + searchSourceForHighlight(ctx, false, "test", false, modifiers, line, 1); + + expect(ctx.cm.operation).toHaveBeenCalled(); + expect(ctx.cm.removeOverlay).toHaveBeenCalledWith(null); + expect(ctx.cm.addOverlay).toHaveBeenCalledWith( + { token: expect.any(Function) }, + { opaque: false } + ); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("anchor"); + expect(ctx.cm.getCursor).toHaveBeenCalledWith("head"); + expect(ed.alignLine).toHaveBeenCalledWith(line, "center"); + expect(cm.setSelection).toHaveBeenCalledWith(from, to); + }); + }); + + describe("findNext", () => {}); + + describe("findPrev", () => {}); + + describe("getMatchIndex", () => { + it("iterates in the matches", () => { + const count = 3; + + // reverse 2, 1, 0, 2 + + let matchIndex = getMatchIndex(count, 2, true); + expect(matchIndex).toBe(1); + + matchIndex = getMatchIndex(count, 1, true); + expect(matchIndex).toBe(0); + + matchIndex = getMatchIndex(count, 0, true); + expect(matchIndex).toBe(2); + + // forward 1, 2, 0, 1 + + matchIndex = getMatchIndex(count, 1, false); + expect(matchIndex).toBe(2); + + matchIndex = getMatchIndex(count, 2, false); + expect(matchIndex).toBe(0); + + matchIndex = getMatchIndex(count, 0, false); + expect(matchIndex).toBe(1); + }); + }); + + describe("removeOverlay", () => { + it("calls CodeMirror APIs: removeOverlay, getCursor & setSelection", () => { + const ctx = { + cm: { + removeOverlay: jest.fn(), + getCursor, + state: {}, + doc: { + setSelection: jest.fn(), + }, + }, + }; + removeOverlay(ctx, "test"); + expect(ctx.cm.removeOverlay).toHaveBeenCalled(); + expect(ctx.cm.getCursor).toHaveBeenCalled(); + expect(ctx.cm.doc.setSelection).toHaveBeenCalledWith( + { line: 90, ch: 54 }, + { line: 90, ch: 54 }, + { scroll: false } + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/utils/editor/token-events.js b/devtools/client/debugger/src/utils/editor/token-events.js new file mode 100644 index 0000000000..aba5b8c94b --- /dev/null +++ b/devtools/client/debugger/src/utils/editor/token-events.js @@ -0,0 +1,95 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +import { getTokenLocation } from "."; + +function isInvalidTarget(target) { + if (!target || !target.innerText) { + return true; + } + + const tokenText = target.innerText.trim(); + const cursorPos = target.getBoundingClientRect(); + + // exclude literal tokens where it does not make sense to show a preview + const invalidType = ["cm-atom", ""].includes(target.className); + + // exclude syntax where the expression would be a syntax error + const invalidToken = + tokenText === "" || tokenText.match(/^[(){}\|&%,.;=<>\+-/\*\s](?=)/); + + // exclude codemirror elements that are not tokens + const invalidTarget = + (target.parentElement && + !target.parentElement.closest(".CodeMirror-line")) || + cursorPos.top == 0; + + const invalidClasses = ["editor-mount"]; + if (invalidClasses.some(className => target.classList.contains(className))) { + return true; + } + + if (target.closest(".popover")) { + return true; + } + + return !!(invalidTarget || invalidToken || invalidType); +} + +function dispatch(codeMirror, eventName, data) { + codeMirror.constructor.signal(codeMirror, eventName, data); +} + +function invalidLeaveTarget(target) { + if (!target || target.closest(".popover")) { + return true; + } + + return false; +} + +export function onMouseOver(codeMirror) { + let prevTokenPos = null; + + function onMouseLeave(event) { + if (invalidLeaveTarget(event.relatedTarget)) { + addMouseLeave(event.target); + return; + } + + prevTokenPos = null; + dispatch(codeMirror, "tokenleave", event); + } + + function addMouseLeave(target) { + target.addEventListener("mouseleave", onMouseLeave, { + capture: true, + once: true, + }); + } + + return enterEvent => { + const { target } = enterEvent; + + if (isInvalidTarget(target)) { + return; + } + + const tokenPos = getTokenLocation(codeMirror, target); + + if ( + prevTokenPos?.line !== tokenPos?.line || + prevTokenPos?.column !== tokenPos?.column + ) { + addMouseLeave(target); + + dispatch(codeMirror, "tokenenter", { + event: enterEvent, + target, + tokenPos, + }); + prevTokenPos = tokenPos; + } + }; +} |