diff --git a/docs/guides/editor_features.md b/docs/guides/editor_features.md
index 9a3b75181d..082dcf393e 100644
--- a/docs/guides/editor_features.md
+++ b/docs/guides/editor_features.md
@@ -28,10 +28,12 @@ marimo ships with the following IDE-like panels that help provide an overview
of your notebook:
1. **errors**: view errors in each cell;
-2. **variables**: explore variable values, see where they are defined and used;
-3. **dependency graph**: view dependencies between cells;
+2. **variables**: explore variable values, see where they are defined and used, with go-to-definition;
+3. **dependency graph**: view dependencies between cells, drill-down on nodes and edges;
4. **table of contents**: corresponding to your markdown;
5. **logs**: a continuous stream of stdout and stderr.
+6. **snippets** - searchable snippets to copy directly into your notebook
+7. **documentation** - move your text cursor over a symbol to see its documentation
@@ -55,6 +57,12 @@ through the cell context menu.
+## Go-to-definition
+
+- Click on a variable in the editor to see where it's defined and used
+- `Cmd/Ctrl-Click` on a variable to jump to its definition
+- Right-click on a variable to see a context menu with options to jump to its definition
+
## Keyboard shortcuts
We've kept some well-known keyboard shortcuts for notebooks (`Ctrl-Enter`,
@@ -127,6 +135,7 @@ A non-exhausted list of settings:
- Formatting rules
- GitHub Copilot
- Autoreloading/Hot-reloading
+- Outputs above or below code cells
## Send feedback
diff --git a/frontend/src/components/dependency-graph/panels.tsx b/frontend/src/components/dependency-graph/panels.tsx
index 61010ed8ea..7560679c43 100644
--- a/frontend/src/components/dependency-graph/panels.tsx
+++ b/frontend/src/components/dependency-graph/panels.tsx
@@ -1,6 +1,6 @@
/* Copyright 2024 Marimo. All rights reserved. */
import React, { memo } from "react";
-import { Edge, Panel } from "reactflow";
+import { type Edge, Panel } from "reactflow";
import { Button } from "../ui/button";
import {
Rows3Icon,
@@ -13,18 +13,18 @@ import {
SettingsIcon,
MoreVerticalIcon,
} from "lucide-react";
-import { GraphLayoutView, GraphSelection, GraphSettings } from "./types";
+import type { GraphLayoutView, GraphSelection, GraphSettings } from "./types";
import { CellLink } from "../editor/links/cell-link";
import { CellLinkList } from "../editor/links/cell-link-list";
import { VariableName } from "../variables/common";
-import { Variable, Variables } from "@/core/variables/types";
+import type { Variable, Variables } from "@/core/variables/types";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Checkbox } from "../ui/checkbox";
import { Label } from "../ui/label";
import { ConnectionCellActionsDropdown } from "../editor/cell/cell-actions";
import { getCellEditorView } from "@/core/cells/cells";
-import { CellId } from "@/core/cells/ids";
-import { goToDefinition } from "@/core/codemirror/find-replace/search-highlight";
+import type { CellId } from "@/core/cells/ids";
+import { goToVariableDefinition } from "@/core/codemirror/go-to-definition/commands";
interface Props {
view: GraphLayoutView;
@@ -120,7 +120,7 @@ export const GraphSelectionPanel: React.FC<{
const highlightInCell = (cellId: CellId, variableName: string) => {
const editorView = getCellEditorView(cellId);
if (editorView) {
- goToDefinition(editorView, variableName);
+ goToVariableDefinition(editorView, variableName);
}
};
diff --git a/frontend/src/components/editor/cell/cell-context-menu.tsx b/frontend/src/components/editor/cell/cell-context-menu.tsx
index 48063c18d1..8b67045843 100644
--- a/frontend/src/components/editor/cell/cell-context-menu.tsx
+++ b/frontend/src/components/editor/cell/cell-context-menu.tsx
@@ -1,7 +1,7 @@
/* Copyright 2024 Marimo. All rights reserved. */
import React, { Fragment } from "react";
import {
- CellActionButtonProps,
+ type CellActionButtonProps,
useCellActionButtons,
} from "../actions/useCellActionButton";
import {
@@ -12,7 +12,7 @@ import {
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { renderMinimalShortcut } from "@/components/shortcuts/renderShortcut";
-import { ActionButton } from "../actions/types";
+import type { ActionButton } from "../actions/types";
import {
ClipboardPasteIcon,
CopyIcon,
@@ -20,7 +20,7 @@ import {
ScissorsIcon,
SearchIcon,
} from "lucide-react";
-import { goToDefinition } from "@/core/codemirror/go-to-definition";
+import { goToDefinitionAtCursorPosition } from "@/core/codemirror/go-to-definition/utils";
interface Props extends CellActionButtonProps {
children: React.ReactNode;
@@ -90,7 +90,7 @@ export const CellActionsContextMenu = ({ children, ...props }: Props) => {
const { getEditorView } = props;
const editorView = getEditorView();
if (editorView) {
- goToDefinition(editorView);
+ goToDefinitionAtCursorPosition(editorView);
}
},
},
diff --git a/frontend/src/components/variables/variables-table.tsx b/frontend/src/components/variables/variables-table.tsx
index a0b695d151..203cb669f4 100644
--- a/frontend/src/components/variables/variables-table.tsx
+++ b/frontend/src/components/variables/variables-table.tsx
@@ -8,8 +8,8 @@ import {
TableCell,
Table,
} from "../ui/table";
-import { Variable, Variables } from "@/core/variables/types";
-import { CellId } from "@/core/cells/ids";
+import type { Variable, Variables } from "@/core/variables/types";
+import type { CellId } from "@/core/cells/ids";
import { CellLink } from "@/components/editor/links/cell-link";
import { cn } from "@/utils/cn";
import { SquareEqualIcon, WorkflowIcon } from "lucide-react";
@@ -17,16 +17,16 @@ import {
useReactTable,
getCoreRowModel,
flexRender,
- ColumnDef,
- SortingState,
+ type ColumnDef,
+ type SortingState,
getSortedRowModel,
- ColumnSort,
+ type ColumnSort,
getFilteredRowModel,
} from "@tanstack/react-table";
import { DataTableColumnHeader } from "../data-table/column-header";
import { sortBy } from "lodash-es";
import { getCellEditorView } from "@/core/cells/cells";
-import { goToDefinition } from "@/core/codemirror/find-replace/search-highlight";
+import { goToVariableDefinition } from "@/core/codemirror/go-to-definition/commands";
import { SearchInput } from "../ui/input";
import { CellLinkList } from "../editor/links/cell-link-list";
import { VariableName } from "./common";
@@ -122,7 +122,7 @@ const COLUMNS = [
const highlightInCell = (cellId: CellId) => {
const editorView = getCellEditorView(cellId);
if (editorView) {
- goToDefinition(editorView, name);
+ goToVariableDefinition(editorView, name);
}
};
diff --git a/frontend/src/core/cells/cells.ts b/frontend/src/core/cells/cells.ts
index da947abc45..6619356118 100644
--- a/frontend/src/core/cells/cells.ts
+++ b/frontend/src/core/cells/cells.ts
@@ -34,7 +34,7 @@ import { clamp } from "@/utils/math";
import type { LayoutState } from "../layout/layout";
import { notebookIsRunning } from "./utils";
import {
- getEditorCodeAsPython,
+ splitEditor,
updateEditorCodeFromPython,
} from "../codemirror/language/utils";
@@ -627,38 +627,18 @@ const {
cellLogs: [],
};
},
- splitCell: (state, action: { cellId: CellId; cursorPos: number }) => {
- const { cellId, cursorPos } = action;
+ splitCell: (state, action: { cellId: CellId }) => {
+ const { cellId } = action;
const index = state.cellIds.indexOf(cellId);
const cell = state.cellData[cellId];
const cellHandle = state.cellHandles[cellId].current;
if (cellHandle?.editorView == null) {
- // TODO: Because of this we can't do the reducer tests like for the other functions
return state;
}
- // Figure out if we're at the start or end of a line to adjust the cursor positions
- const isCursorAtLineStart =
- cell.code.length > 0 && cell.code[cursorPos - 1] === "\n";
- const isCursorAtLineEnd =
- cell.code.length > 0 && cell.code[cursorPos] === "\n";
-
- const beforeAdjustedCursorPos = isCursorAtLineStart
- ? cursorPos - 1
- : cursorPos;
- const afterAdjustedCursorPos = isCursorAtLineEnd
- ? cursorPos + 1
- : cursorPos;
-
- const beforeCursorCode = getEditorCodeAsPython(
- cellHandle.editorView,
- 0,
- beforeAdjustedCursorPos,
- );
- const afterCursorCode = getEditorCodeAsPython(
+ const { beforeCursorCode, afterCursorCode } = splitEditor(
cellHandle.editorView,
- afterAdjustedCursorPos,
);
updateEditorCodeFromPython(cellHandle.editorView, beforeCursorCode);
@@ -673,7 +653,9 @@ const {
[cellId]: {
...cell,
code: beforeCursorCode,
- edited: beforeCursorCode.trim() !== cell.lastCodeRun,
+ edited:
+ Boolean(beforeCursorCode) &&
+ beforeCursorCode.trim() !== cell.lastCodeRun?.trim(),
},
[newCellId]: createCell({
id: newCellId,
diff --git a/frontend/src/core/cells/scrollCellIntoView.ts b/frontend/src/core/cells/scrollCellIntoView.ts
index 4ebc3f2cee..756ffa5da7 100644
--- a/frontend/src/core/cells/scrollCellIntoView.ts
+++ b/frontend/src/core/cells/scrollCellIntoView.ts
@@ -1,10 +1,10 @@
/* Copyright 2024 Marimo. All rights reserved. */
-import { RefObject } from "react";
+import type { RefObject } from "react";
import { Logger } from "../../utils/Logger";
-import { CellId, HTMLCellId } from "./ids";
-import { CellHandle } from "@/components/editor/Cell";
-import { CellConfig } from "./types";
-import { goToDefinition } from "../codemirror/find-replace/search-highlight";
+import { type CellId, HTMLCellId } from "./ids";
+import type { CellHandle } from "@/components/editor/Cell";
+import type { CellConfig } from "./types";
+import { goToVariableDefinition } from "../codemirror/go-to-definition/commands";
export function focusAndScrollCellIntoView({
cellId,
@@ -57,7 +57,7 @@ export function focusAndScrollCellIntoView({
},
});
} else if (variableName) {
- goToDefinition(editor, variableName);
+ goToVariableDefinition(editor, variableName);
}
}
diff --git a/frontend/src/core/codemirror/cells/extensions.ts b/frontend/src/core/codemirror/cells/extensions.ts
index 550d14fb1f..daeb01e5e6 100644
--- a/frontend/src/core/codemirror/cells/extensions.ts
+++ b/frontend/src/core/codemirror/cells/extensions.ts
@@ -9,7 +9,7 @@ import { getEditorCodeAsPython } from "../language/utils";
import { formattingChangeEffect } from "../format";
import { closeCompletion, completionStatus } from "@codemirror/autocomplete";
import { isAtEndOfEditor, isAtStartOfEditor } from "../utils";
-import { goToDefinition } from "../go-to-definition";
+import { goToDefinitionAtCursorPosition } from "../go-to-definition/utils";
export interface MovementCallbacks
extends Pick<
@@ -246,7 +246,7 @@ export function cellMovementBundle(
preventDefault: true,
stopPropagation: true,
run: (ev) => {
- goToDefinition(ev);
+ goToDefinitionAtCursorPosition(ev);
return true;
},
},
@@ -255,8 +255,7 @@ export function cellMovementBundle(
preventDefault: true,
stopPropagation: true,
run: (ev) => {
- const cursorPos = ev.state.selection.main.head;
- splitCell({ cellId, cursorPos });
+ splitCell({ cellId });
requestAnimationFrame(() => {
ev.contentDOM.blur();
moveToNextCell({ cellId, before: false }); // focus new cell
diff --git a/frontend/src/core/codemirror/cm.ts b/frontend/src/core/codemirror/cm.ts
index bc676fe931..b48ada3bae 100644
--- a/frontend/src/core/codemirror/cm.ts
+++ b/frontend/src/core/codemirror/cm.ts
@@ -34,20 +34,20 @@ import {
EditorView,
} from "@codemirror/view";
-import { EditorState, Extension, Prec } from "@codemirror/state";
+import { EditorState, type Extension, Prec } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
-import { CompletionConfig, KeymapConfig } from "../config/config-schema";
-import { Theme } from "../../theme/useTheme";
+import type { CompletionConfig, KeymapConfig } from "../config/config-schema";
+import type { Theme } from "../../theme/useTheme";
import { findReplaceBundle } from "./find-replace/extension";
import {
- CodeCallbacks,
- MovementCallbacks,
+ type CodeCallbacks,
+ type MovementCallbacks,
cellCodeEditingBundle,
cellMovementBundle,
} from "./cells/extensions";
-import { CellId } from "../cells/ids";
+import type { CellId } from "../cells/ids";
import { keymapBundle } from "./keymaps/keymaps";
import { scrollActiveLineIntoView } from "./extensions";
import { copilotBundle } from "./copilot/extension";
@@ -58,6 +58,7 @@ import {
clickablePlaceholderExtension,
smartPlaceholderExtension,
} from "./placeholder/extensions";
+import { goToDefinitionBundle } from "./go-to-definition/extension";
export interface CodeMirrorSetupOpts {
cellId: CellId;
@@ -91,6 +92,8 @@ export const setupCodeMirror = ({
cellCodeEditingBundle(cellId, cellCodeCallbacks),
// Comes last so that it can be overridden
basicBundle(completionConfig, theme),
+ // Underline cmd+clickable placeholder
+ goToDefinitionBundle(),
showPlaceholder
? Prec.highest(smartPlaceholderExtension("import marimo as mo"))
: enableAI
diff --git a/frontend/src/core/codemirror/find-replace/search-highlight.ts b/frontend/src/core/codemirror/find-replace/search-highlight.ts
index 8f31fb1bf2..e7d2044a04 100644
--- a/frontend/src/core/codemirror/find-replace/search-highlight.ts
+++ b/frontend/src/core/codemirror/find-replace/search-highlight.ts
@@ -4,15 +4,14 @@ import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
import {
Decoration,
ViewPlugin,
- DecorationSet,
+ type DecorationSet,
EditorView,
- ViewUpdate,
+ type ViewUpdate,
} from "@codemirror/view";
-import { QueryType, asQueryCreator } from "./query";
+import { type QueryType, asQueryCreator } from "./query";
import { store } from "@/core/state/jotai";
import { findReplaceAtom } from "./state";
import { getAllEditorViews } from "@/core/cells/cells";
-import { syntaxTree } from "@codemirror/language";
const setSearchQuery = StateEffect.define();
@@ -147,57 +146,3 @@ export const highlightTheme = EditorView.baseTheme({
backgroundColor: "#6199ff88 !important",
},
});
-
-/**
- * This function will select the first occurrence of the given variable name.
- */
-export function goToDefinition(
- view: EditorView,
- variableName: string,
-): boolean {
- const state = view.state;
- const tree = syntaxTree(state);
-
- let found = false;
- let from = 0;
-
- tree.iterate({
- enter: (node) => {
- if (found) {
- return false;
- } // Stop traversal if found
-
- // Check if the node is an identifier and matches the variable name
- if (
- node.name === "VariableName" &&
- state.doc.sliceString(node.from, node.to) === variableName
- ) {
- from = node.from;
- found = true;
- return false; // Stop traversal
- }
-
- // Skip comments and strings
- if (node.name === "Comment" || node.name === "String") {
- return false;
- }
- },
- });
-
- if (found) {
- view.focus();
- view.dispatch({
- selection: {
- anchor: from,
- head: from,
- },
- // Unfortunately, EditorView.scrollIntoView does
- // not support smooth scrolling.
- effects: EditorView.scrollIntoView(from, {
- y: "center",
- }),
- });
- return true;
- }
- return false;
-}
diff --git a/frontend/src/core/codemirror/go-to-definition.ts b/frontend/src/core/codemirror/go-to-definition.ts
deleted file mode 100644
index cfbb4186f2..0000000000
--- a/frontend/src/core/codemirror/go-to-definition.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/* Copyright 2024 Marimo. All rights reserved. */
-import { EditorState } from "@codemirror/state";
-import { EditorView } from "@codemirror/view";
-import { getPositionAtWordBounds } from "../codemirror/completion/hints";
-import { VariableName, Variables } from "../variables/types";
-import { focusAndScrollCellIntoView } from "../cells/scrollCellIntoView";
-import { store } from "../state/jotai";
-import { notebookAtom } from "../cells/cells";
-import { variablesAtom } from "../variables/state";
-
-const getWordUnderCursor = (state: EditorState) => {
- const { from, to } = state.selection.main;
- let variableName: string;
-
- if (from === to) {
- const { startToken, endToken } = getPositionAtWordBounds(state.doc, from);
- variableName = state.doc.sliceString(startToken, endToken);
- } else {
- variableName = state.doc.sliceString(from, to);
- }
-
- return variableName;
-};
-
-const getCellIdOfDefinition = (variables: Variables, variableName: string) => {
- const variable = variables[variableName as VariableName];
- if (!variable || variable.declaredBy.length === 0) {
- return null;
- }
- const focusCellId = variable.declaredBy[0];
- return focusCellId;
-};
-
-export function goToDefinition(view: EditorView) {
- const state = view.state;
- const variables = store.get(variablesAtom);
- const variableName = getWordUnderCursor(state);
- const focusCellId = getCellIdOfDefinition(variables, variableName);
-
- if (focusCellId) {
- const notebookState = store.get(notebookAtom);
- focusAndScrollCellIntoView({
- cellId: focusCellId,
- cell: notebookState.cellHandles[focusCellId],
- config: notebookState.cellData[focusCellId].config,
- codeFocus: undefined,
- variableName,
- });
- }
-}
diff --git a/frontend/src/core/codemirror/go-to-definition/__tests__/commands.test.ts b/frontend/src/core/codemirror/go-to-definition/__tests__/commands.test.ts
new file mode 100644
index 0000000000..e9c619503b
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/__tests__/commands.test.ts
@@ -0,0 +1,59 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { EditorState } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { describe, afterEach, test, expect } from "vitest";
+import { goToVariableDefinition } from "../commands";
+import { python } from "@codemirror/lang-python";
+
+function createEditor(content: string) {
+ const state = EditorState.create({
+ doc: content,
+ extensions: [python()],
+ });
+
+ const view = new EditorView({
+ state,
+ parent: document.body,
+ });
+
+ return view;
+}
+
+let view: EditorView | null = null;
+
+afterEach(() => {
+ if (view) {
+ view.destroy();
+ view = null;
+ }
+});
+
+describe("goToVariableDefinition", () => {
+ test("selects the variable when it exists", async () => {
+ view = createEditor("#comment\nmyVar = 10\nprint(myVar)");
+ const result = goToVariableDefinition(view, "myVar");
+
+ expect(result).toBe(true);
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ expect(view.state.selection.main.from).toBe(9);
+ expect(view.state.selection.main.to).toBe(9);
+ });
+
+ test("does not select the variable when it does not exist", () => {
+ view = createEditor("#comment\nmyVar = 10;\nprint(myVar)");
+ const result = goToVariableDefinition(view, "nonExistentVar");
+
+ expect(result).toBe(false);
+ expect(view.state.selection.main.from).toBe(0);
+ expect(view.state.selection.main.to).toBe(0);
+ });
+
+ test("does not select the variable when it exists in a comment or string", () => {
+ view = createEditor("#comment\n# myVar\nprint('myVar')");
+ const result = goToVariableDefinition(view, "myVar");
+
+ expect(result).toBe(false);
+ expect(view.state.selection.main.from).toBe(0);
+ expect(view.state.selection.main.to).toBe(0);
+ });
+});
diff --git a/frontend/src/core/codemirror/go-to-definition/commands.ts b/frontend/src/core/codemirror/go-to-definition/commands.ts
new file mode 100644
index 0000000000..a556c7bb96
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/commands.ts
@@ -0,0 +1,64 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { EditorView } from "@codemirror/view";
+import { syntaxTree } from "@codemirror/language";
+
+/**
+ * This function will select the first occurrence of the given variable name,
+ * for a given editor view.
+ * @param view The editor view which contains the variable name.
+ * @param variableName The name of the variable to select, if found in the editor.
+ */
+export function goToVariableDefinition(
+ view: EditorView,
+ variableName: string,
+): boolean {
+ const { state } = view;
+ const tree = syntaxTree(state);
+
+ let found = false;
+ let from = 0;
+
+ tree.iterate({
+ enter: (node) => {
+ if (found) {
+ return false;
+ } // Stop traversal if found
+
+ // Check if the node is an identifier and matches the variable name
+ if (
+ node.name === "VariableName" &&
+ state.doc.sliceString(node.from, node.to) === variableName
+ ) {
+ from = node.from;
+ found = true;
+ return false; // Stop traversal
+ }
+
+ // Skip comments and strings
+ if (node.name === "Comment" || node.name === "String") {
+ return false;
+ }
+ },
+ });
+
+ if (found) {
+ view.focus();
+ // Wait for the next frame, otherwise codemirror will
+ // add a cursor from a pointer click.
+ requestAnimationFrame(() => {
+ view.dispatch({
+ selection: {
+ anchor: from,
+ head: from,
+ },
+ // Unfortunately, EditorView.scrollIntoView does
+ // not support smooth scrolling.
+ effects: EditorView.scrollIntoView(from, {
+ y: "center",
+ }),
+ });
+ });
+ return true;
+ }
+ return false;
+}
diff --git a/frontend/src/core/codemirror/go-to-definition/extension.ts b/frontend/src/core/codemirror/go-to-definition/extension.ts
new file mode 100644
index 0000000000..5724a88a04
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/extension.ts
@@ -0,0 +1,23 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { EditorView } from "@codemirror/view";
+import { underlineField, createUnderlinePlugin } from "./underline";
+import { goToDefinition } from "./utils";
+
+/**
+ * Create a go-to-definition extension.
+ */
+export function goToDefinitionBundle() {
+ return [
+ underlineField,
+ createUnderlinePlugin((view, variableName) => {
+ goToDefinition(view, variableName);
+ }),
+ EditorView.baseTheme({
+ ".underline": {
+ textDecoration: "underline",
+ cursor: "pointer",
+ color: "hsl(var(--link))",
+ },
+ }),
+ ];
+}
diff --git a/frontend/src/core/codemirror/go-to-definition/underline.ts b/frontend/src/core/codemirror/go-to-definition/underline.ts
new file mode 100644
index 0000000000..4256a5ce23
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/underline.ts
@@ -0,0 +1,156 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import {
+ EditorView,
+ Decoration,
+ type DecorationSet,
+ ViewPlugin,
+ type ViewUpdate,
+} from "@codemirror/view";
+import { StateField, StateEffect } from "@codemirror/state";
+import { syntaxTree } from "@codemirror/language";
+import type { TreeCursor } from "@lezer/common";
+
+// Decoration
+const underlineDecoration = Decoration.mark({ class: "underline" });
+
+// State Effects
+const addUnderline = StateEffect.define<{ from: number; to: number }>();
+const removeUnderline = StateEffect.define<{ from: number; to: number }>();
+
+// Underline Field
+export const underlineField = StateField.define({
+ create() {
+ return Decoration.none;
+ },
+ update(underlines, tr) {
+ let newUnderlines = underlines.map(tr.changes);
+ for (const effect of tr.effects) {
+ if (effect.is(addUnderline)) {
+ newUnderlines = underlines.update({
+ add: [underlineDecoration.range(effect.value.from, effect.value.to)],
+ });
+ } else if (effect.is(removeUnderline)) {
+ newUnderlines = underlines.update({
+ filter: (from, to) =>
+ from !== effect.value.from || to !== effect.value.to,
+ });
+ }
+ }
+ return newUnderlines;
+ },
+ provide: (f) => EditorView.decorations.from(f),
+});
+
+// When meta is pressed, underline the variable name under the cursor
+class MetaUnderlineVariablePlugin {
+ private view: EditorView;
+ private commandKey: boolean;
+ private hoveredRange: { from: number; to: number; position: number } | null;
+ private onClick: (view: EditorView, variableName: string) => void;
+
+ constructor(
+ view: EditorView,
+ onClick: (view: EditorView, variableName: string) => void,
+ ) {
+ this.view = view;
+ this.commandKey = false;
+ this.hoveredRange = null;
+ this.onClick = onClick;
+
+ window.addEventListener("mousemove", this.mousemove);
+ window.addEventListener("keydown", this.keydown);
+ window.addEventListener("keyup", this.keyup);
+ this.view.dom.addEventListener("click", this.click);
+ }
+
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.clearUnderline();
+ }
+ }
+
+ destroy() {
+ window.removeEventListener("mousemove", this.mousemove);
+ window.removeEventListener("keydown", this.keydown);
+ window.removeEventListener("keyup", this.keyup);
+ }
+
+ private mousemove = (event: MouseEvent) => {
+ if (!this.commandKey) {
+ this.clearUnderline();
+ return;
+ }
+
+ const pos = this.view.posAtCoords({
+ x: event.clientX,
+ y: event.clientY,
+ });
+ if (pos == null) {
+ this.clearUnderline();
+ return;
+ }
+
+ const tree = syntaxTree(this.view.state);
+ const cursor: TreeCursor = tree.cursorAt(pos);
+
+ if (cursor.name === "VariableName") {
+ const { from, to } = cursor;
+ if (
+ this.hoveredRange &&
+ this.hoveredRange.from === from &&
+ this.hoveredRange.to === to
+ ) {
+ return;
+ }
+ this.clearUnderline();
+ this.hoveredRange = { from, to, position: pos };
+ this.view.dispatch({ effects: addUnderline.of(this.hoveredRange) });
+ } else {
+ this.clearUnderline();
+ }
+ };
+
+ private keydown = (event: KeyboardEvent) => {
+ if (event.key === "Meta") {
+ this.commandKey = true;
+ }
+ };
+
+ private click = (event: MouseEvent) => {
+ if (this.hoveredRange) {
+ const variableName = this.view.state.doc.sliceString(
+ this.hoveredRange.from,
+ this.hoveredRange.to,
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ this.onClick(this.view, variableName);
+ // Move the cursor to the clicked position
+ this.view.dispatch({
+ selection: {
+ head: this.hoveredRange.position,
+ anchor: this.hoveredRange.position,
+ },
+ });
+ }
+ };
+
+ private keyup = (event: KeyboardEvent) => {
+ if (event.key === "Meta") {
+ this.commandKey = false;
+ this.clearUnderline();
+ }
+ };
+
+ private clearUnderline() {
+ if (this.hoveredRange) {
+ this.view.dispatch({ effects: removeUnderline.of(this.hoveredRange) });
+ this.hoveredRange = null;
+ }
+ }
+}
+
+export const createUnderlinePlugin = (
+ onClick: (view: EditorView, variableName: string) => void,
+) =>
+ ViewPlugin.define((view) => new MetaUnderlineVariablePlugin(view, onClick));
diff --git a/frontend/src/core/codemirror/go-to-definition/utils.ts b/frontend/src/core/codemirror/go-to-definition/utils.ts
new file mode 100644
index 0000000000..fa095763e6
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/utils.ts
@@ -0,0 +1,103 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import type { EditorState } from "@codemirror/state";
+import { closeHoverTooltips, type EditorView } from "@codemirror/view";
+import { getPositionAtWordBounds } from "../completion/hints";
+import type { VariableName, Variables } from "../../variables/types";
+import { store } from "../../state/jotai";
+import { notebookAtom } from "../../cells/cells";
+import { variablesAtom } from "../../variables/state";
+import type { CellId } from "@/core/cells/ids";
+import { goToVariableDefinition } from "./commands";
+import { closeCompletion } from "@codemirror/autocomplete";
+
+/**
+ * Get the word under the cursor.
+ */
+function getWordUnderCursor(state: EditorState) {
+ const { from, to } = state.selection.main;
+ if (from === to) {
+ const { startToken, endToken } = getPositionAtWordBounds(state.doc, from);
+ return state.doc.sliceString(startToken, endToken);
+ }
+
+ return state.doc.sliceString(from, to);
+}
+
+/**
+ * Get the cell id of the definition of the given variable.
+ */
+function getCellIdOfDefinition(
+ variables: Variables,
+ variableName: string,
+): CellId | null {
+ if (!variableName) {
+ return null;
+ }
+ const variable = variables[variableName as VariableName];
+ if (!variable || variable.declaredBy.length === 0) {
+ return null;
+ }
+ return variable.declaredBy[0];
+}
+
+function isPrivateVariable(variableName: string) {
+ return variableName.startsWith("_");
+}
+
+/**
+ * Go to the definition of the variable under the cursor.
+ * @param view The editor view at which the command was invoked.
+ */
+export function goToDefinitionAtCursorPosition(view: EditorView): boolean {
+ const { state } = view;
+ const variableName = getWordUnderCursor(state);
+ if (!variableName) {
+ return false;
+ }
+ // Close popovers/tooltips
+ closeCompletion(view);
+ view.dispatch({ effects: closeHoverTooltips });
+
+ return goToDefinition(view, variableName);
+}
+
+/**
+ * Go to the definition of the variable under the cursor.
+ * @param view The editor view at which the command was invoked.
+ */
+export function goToDefinition(
+ view: EditorView,
+ variableName: string,
+): boolean {
+ // The variable may exist in another cell
+ const editorWithVariable = getEditorForVariable(view, variableName);
+ if (!editorWithVariable) {
+ return false;
+ }
+ return goToVariableDefinition(editorWithVariable, variableName);
+}
+
+/**
+ * @param editor The editor view at which the command was invoked.
+ * @param variableName The name of the variable to go to.
+ */
+function getEditorForVariable(
+ editor: EditorView,
+ variableName: string,
+): EditorView | null {
+ // If it's a private variable, we only want to go to the
+ // definition if it's in the same cell
+ if (isPrivateVariable(variableName)) {
+ return editor;
+ }
+
+ const variables = store.get(variablesAtom);
+
+ const cellId = getCellIdOfDefinition(variables, variableName);
+ if (cellId) {
+ const notebookState = store.get(notebookAtom);
+ return notebookState.cellHandles[cellId].current?.editorView ?? null;
+ }
+
+ return null;
+}
diff --git a/frontend/src/core/codemirror/language/__tests__/utils.test.ts b/frontend/src/core/codemirror/language/__tests__/utils.test.ts
new file mode 100644
index 0000000000..2e26a27f80
--- /dev/null
+++ b/frontend/src/core/codemirror/language/__tests__/utils.test.ts
@@ -0,0 +1,133 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import {
+ getEditorCodeAsPython,
+ updateEditorCodeFromPython,
+ splitEditor,
+} from "../utils";
+import { EditorState } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { describe, it, expect } from "vitest";
+import { adaptiveLanguageConfiguration, switchLanguage } from "../extension";
+
+function createEditor(doc: string) {
+ return new EditorView({
+ state: EditorState.create({
+ doc,
+ extensions: [
+ adaptiveLanguageConfiguration({
+ activate_on_typing: true,
+ copilot: false,
+ }),
+ ],
+ }),
+ });
+}
+
+describe("getEditorCodeAsPython", () => {
+ it("should return the entire editor text when no positions are specified", () => {
+ const mockEditor = createEditor("print('Hello, World!')");
+ const result = getEditorCodeAsPython(mockEditor);
+ expect(result).toEqual("print('Hello, World!')");
+ });
+
+ it("should return a slice of the editor text when positions are specified", () => {
+ const mockEditor = createEditor("print('Hello, World!')");
+ const result = getEditorCodeAsPython(mockEditor, 0, 5);
+ expect(result).toEqual("print");
+ });
+});
+
+describe("updateEditorCodeFromPython", () => {
+ it("should update the editor code with the provided Python code", () => {
+ const mockEditor = createEditor("");
+ const pythonCode = "print('Hello, World!')";
+ const result = updateEditorCodeFromPython(mockEditor, pythonCode);
+ expect(result).toEqual(pythonCode);
+ expect(mockEditor.state.doc.toString()).toEqual(pythonCode);
+ });
+});
+
+describe("splitEditor", () => {
+ it("should handle the cursor being at the start of the line", () => {
+ const mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+ mockEditor.dispatch({
+ selection: {
+ anchor: "print('Hello')\n".length,
+ },
+ });
+ const result = splitEditor(mockEditor);
+ expect(result.beforeCursorCode).toEqual("print('Hello')");
+ expect(result.afterCursorCode).toEqual("print('Goodbye')");
+ });
+
+ it("should handle the cursor being at the end of the line", () => {
+ const mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+ mockEditor.dispatch({
+ selection: {
+ anchor: "print('Hello')".length,
+ },
+ });
+ const result = splitEditor(mockEditor);
+ expect(result.beforeCursorCode).toEqual("print('Hello')");
+ expect(result.afterCursorCode).toEqual("print('Goodbye')");
+ });
+
+ it("should split the editor code into two parts at the cursor position", () => {
+ const mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+ mockEditor.dispatch({
+ selection: {
+ anchor: 2,
+ },
+ });
+ const result = splitEditor(mockEditor);
+ expect(result.beforeCursorCode).toEqual("pr");
+ expect(result.afterCursorCode).toEqual("int('Hello')\nprint('Goodbye')");
+ });
+
+ it("handle start and end of the docs", () => {
+ let mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+ mockEditor.dispatch({
+ selection: { anchor: 0 },
+ });
+ const result = splitEditor(mockEditor);
+ expect(result.beforeCursorCode).toEqual("");
+ expect(result.afterCursorCode).toEqual("print('Hello')\nprint('Goodbye')");
+
+ mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+ mockEditor.dispatch({
+ selection: { anchor: mockEditor.state.doc.length },
+ });
+
+ const result2 = splitEditor(mockEditor);
+ expect(result2.beforeCursorCode).toEqual(
+ "print('Hello')\nprint('Goodbye')",
+ );
+ expect(result2.afterCursorCode).toEqual("");
+ });
+
+ it("handles markdown", () => {
+ const mockEditor = createEditor("mo.md('Hello, World!')");
+ // Set to markdown
+ switchLanguage(mockEditor, "markdown");
+ // Set cursor position
+ mockEditor.dispatch({
+ selection: { anchor: "Hello,".length },
+ });
+ const result = splitEditor(mockEditor);
+ expect(result.beforeCursorCode).toEqual('mo.md("Hello,")');
+ expect(result.afterCursorCode).toEqual('mo.md(" World!")');
+ });
+
+ it("handles markdown with variables", () => {
+ const mockEditor = createEditor('mo.md(f"""{a}\n{b}!""")');
+ // Set to markdown
+ switchLanguage(mockEditor, "markdown");
+ // Set cursor position
+ mockEditor.dispatch({
+ selection: { anchor: "{a}\n".length },
+ });
+ const result = splitEditor(mockEditor);
+ expect(result.beforeCursorCode).toEqual('mo.md(f"{a}")');
+ expect(result.afterCursorCode).toEqual('mo.md(f"{b}!")');
+ });
+});
diff --git a/frontend/src/core/codemirror/language/utils.ts b/frontend/src/core/codemirror/language/utils.ts
index 14a36365b4..b316165779 100644
--- a/frontend/src/core/codemirror/language/utils.ts
+++ b/frontend/src/core/codemirror/language/utils.ts
@@ -1,7 +1,7 @@
/* Copyright 2024 Marimo. All rights reserved. */
-import { EditorView } from "@codemirror/view";
+import type { EditorView } from "@codemirror/view";
import { languageAdapterState } from "./extension";
-import { EditorState } from "@codemirror/state";
+import type { EditorState } from "@codemirror/state";
/**
* Get the editor code as Python
@@ -20,6 +20,10 @@ export function getEditorCodeAsPython(
return languageAdapter.transformOut(editorText)[0];
}
+/**
+ * Update the editor code from Python code
+ * Handles when the editor is showing a different language (e.g. markdown)
+ */
export function updateEditorCodeFromPython(
editor: EditorView,
pythonCode: string,
@@ -35,3 +39,33 @@ export function updateEditorCodeFromPython(
});
return code;
}
+
+/**
+ * Split the editor code into two parts at the cursor position
+ */
+export function splitEditor(editor: EditorView) {
+ const cursorPos = editor.state.selection.main.head;
+ const editorCode = editor.state.doc.toString();
+
+ const isCursorAtLineStart =
+ editorCode.length > 0 && editorCode[cursorPos - 1] === "\n";
+ const isCursorAtLineEnd =
+ editorCode.length > 0 && editorCode[cursorPos] === "\n";
+
+ const beforeAdjustedCursorPos = isCursorAtLineStart
+ ? cursorPos - 1
+ : cursorPos;
+ const afterAdjustedCursorPos = isCursorAtLineEnd ? cursorPos + 1 : cursorPos;
+
+ const beforeCursorCode = getEditorCodeAsPython(
+ editor,
+ 0,
+ beforeAdjustedCursorPos,
+ );
+ const afterCursorCode = getEditorCodeAsPython(editor, afterAdjustedCursorPos);
+
+ return {
+ beforeCursorCode,
+ afterCursorCode,
+ };
+}