summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/utils/editor
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/utils/editor')
-rw-r--r--devtools/client/debugger/src/utils/editor/create-editor.js44
-rw-r--r--devtools/client/debugger/src/utils/editor/get-expression.js54
-rw-r--r--devtools/client/debugger/src/utils/editor/get-token-location.js16
-rw-r--r--devtools/client/debugger/src/utils/editor/index.js230
-rw-r--r--devtools/client/debugger/src/utils/editor/moz.build17
-rw-r--r--devtools/client/debugger/src/utils/editor/source-documents.js249
-rw-r--r--devtools/client/debugger/src/utils/editor/source-editor.css271
-rw-r--r--devtools/client/debugger/src/utils/editor/source-editor.js145
-rw-r--r--devtools/client/debugger/src/utils/editor/source-search.js327
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/__snapshots__/create-editor.spec.js.snap60
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/create-editor.spec.js25
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/editor.spec.js203
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/get-expression.spec.js160
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/get-token-location.spec.js31
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/source-documents.spec.js215
-rw-r--r--devtools/client/debugger/src/utils/editor/tests/source-search.spec.js182
-rw-r--r--devtools/client/debugger/src/utils/editor/token-events.js95
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;
+ }
+ };
+}