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, + }; +}