diff --git a/README.md b/README.md index 615b3d581f8..ddf6215b28f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Livebook is a web application for writing interactive and collaborative code not * Code notebooks with Markdown support and Code cells where Elixir code is evaluated on demand. - * Rich code editor through [Monaco](https://microsoft.github.io/monaco-editor/): with support for autocompletion, inline documentation, code formatting, etc. + * Rich code editor through [CodeMirror](https://codemirror.net/): with support for autocompletion, inline documentation, code formatting, etc. * Interactive results via [Kino](https://github.com/elixir-nx/kino): display [Vega-Lite charts](https://vega.github.io/vega-lite/), tables, maps, and more. diff --git a/assets/build.js b/assets/build.js index a8ed552a082..70e105b1a22 100644 --- a/assets/build.js +++ b/assets/build.js @@ -15,18 +15,6 @@ const outDir = path.resolve( ); async function main() { - await esbuild.build({ - entryPoints: [ - "./node_modules/monaco-editor/esm/vs/language/json/json.worker.js", - "./node_modules/monaco-editor/esm/vs/editor/editor.worker.js", - ], - outdir: outDir, - bundle: true, - target: "es2017", - format: "iife", - minify: deploy, - }); - const ctx = await esbuild.context({ entryPoints: ["js/app.js"], outdir: outDir, diff --git a/assets/css/app.css b/assets/css/app.css index af724379b73..dcb1bc62284 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -10,5 +10,4 @@ @import "./ansi.css"; @import "./js_interop.css"; @import "./tooltips.css"; -@import "./editor.css"; @import "./notebook.css"; diff --git a/assets/css/editor.css b/assets/css/editor.css deleted file mode 100644 index 63ea53df6aa..00000000000 --- a/assets/css/editor.css +++ /dev/null @@ -1,218 +0,0 @@ -/* === Monaco overrides === */ - -/* -CSS normalization removes the default styles of HTML elements, -so we need to adjust styles of Monaco-rendered Markdown docs. -Also some spacing adjustments. -*/ - -.monaco-hover p, -.suggest-details p, -.parameter-hints-widget p { - @apply my-2 !important; -} - -.suggest-details h1, -.monaco-hover h1, -.parameter-hints-widget h1 { - @apply text-xl font-semibold mt-4 mb-2; -} - -.suggest-details h2, -.monaco-hover h2, -.parameter-hints-widget h2 { - @apply text-lg font-medium mt-4 mb-2; -} - -.suggest-details h3, -.monaco-hover h3, -.parameter-hints-widget h3 { - @apply font-medium mt-4 mb-2; -} - -.suggest-details ul, -.monaco-hover ul, -.parameter-hints-widget ul { - @apply list-disc; -} - -.suggest-details ol, -.monaco-hover ol, -.parameter-hints-widget ol { - @apply list-decimal; -} - -.suggest-details hr, -.monaco-hover hr, -.parameter-hints-widget hr { - @apply my-2 !important; -} - -.suggest-details blockquote, -.monaco-hover blockquote, -.parameter-hints-widget blockquote { - @apply border-l-4 border-gray-200 pl-4 py-0.5 my-2; -} - -/* Add some spacing to code snippets in completion suggestions */ -.suggest-details div.monaco-tokenized-source, -.monaco-hover div.monaco-tokenized-source, -.parameter-hints-widget div.monaco-tokenized-source { - @apply my-2 whitespace-pre-wrap; -} - -/* Use z-index over cell icons */ -.suggest-details, -.monaco-hover, -.parameter-hints-widget { - z-index: 100 !important; -} - -/* Adjust header spacing in completion details */ -.suggest-details .header p { - @apply pb-0 pt-3 !important; -} - -/* Adjust divider in signature help widget */ -.parameter-hints-widget .markdown-docs hr { - border-top: 1px solid rgba(69, 69, 69, 0.5); - margin-right: -8px; - margin-left: -8px; -} - -/* Increase the hover box limits */ -.monaco-hover-content { - max-width: 1000px !important; - max-height: 300px !important; -} - -/* Increase the completion details box limits */ -.suggest-details-container, -.suggest-details { - width: fit-content !important; - height: fit-content !important; - max-width: 420px !important; - max-height: 250px !important; -} - -/* Adjust completion details spacing */ -.suggest-details .header .type { - padding-top: 0 !important; -} - -/* The content already has some padding */ -.docs.markdown-docs { - margin: 0 !important; -} - -/* Command palette height is computed based on editor height, - which is not what we want, since the editor can have just - a single line, hence we override with a fixed height */ -.monaco-editor .quick-input-list .monaco-list { - max-height: 300px !important; -} - -/* === Monaco cursor widget === */ - -.monaco-cursor-widget-container { - pointer-events: none; - z-index: 100; -} - -.monaco-cursor-widget-container .monaco-cursor-widget-cursor { - pointer-events: initial; - width: 2px; -} - -.monaco-cursor-widget-container .monaco-cursor-widget-label { - pointer-events: initial; - transform: translateY(-200%); - white-space: nowrap; - padding: 1px 8px; - font-size: 12px; - color: #f8fafc; - - visibility: hidden; - transition-property: visibility; - transition-duration: 0s; - transition-delay: 1.5s; -} - -.monaco-cursor-widget-container .monaco-cursor-widget-label:hover { - visibility: visible; -} - -.monaco-cursor-widget-container - .monaco-cursor-widget-cursor:hover - + .monaco-cursor-widget-label { - visibility: visible; - transition-delay: 0s; -} - -/* When in the first line, we want to display cursor and label in the same line */ -.monaco-cursor-widget-container.inline { - display: flex !important; -} - -.monaco-cursor-widget-container.inline .monaco-cursor-widget-label { - margin-left: 2px; - transform: none; -} - -/* === Doctest status decoration === */ - -.doctest-status-decoration-running, -.doctest-status-decoration-success, -.doctest-status-decoration-failed { - height: 100%; - position: relative; - --decoration-size: 10px; -} - -@media screen(md) { - .doctest-status-decoration-running, - .doctest-status-decoration-success, - .doctest-status-decoration-failed { - --decoration-size: 12px; - } -} - -.doctest-status-decoration-running::after, -.doctest-status-decoration-success::after, -.doctest-status-decoration-failed::after { - box-sizing: border-box; - border-radius: 2px; - content: ""; - display: block; - height: var(--decoration-size); - width: var(--decoration-size); - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -.doctest-status-decoration-running::after { - @apply bg-gray-400; -} - -.doctest-status-decoration-success::after { - @apply bg-green-bright-400; -} - -.doctest-status-decoration-failed::after { - @apply bg-red-400; -} - -/* === Doctest failure details === */ - -.doctest-details-widget { - @apply font-editor; - white-space: pre-wrap; - background-color: rgba(0, 0, 0, 0.05); - padding-top: 6px; - padding-bottom: 6px; - position: absolute; - width: 100%; - overflow-y: auto; -} diff --git a/assets/css/markdown.css b/assets/css/markdown.css index 4abdbaf8b1a..c25643976e5 100644 --- a/assets/css/markdown.css +++ b/assets/css/markdown.css @@ -119,7 +119,7 @@ } .markdown pre { - @apply flex overflow-hidden; + @apply my-4 flex overflow-hidden; } .markdown pre > code { diff --git a/assets/js/hooks/cell.js b/assets/js/hooks/cell.js index 17dd48e05b3..ad270ffe33f 100644 --- a/assets/js/hooks/cell.js +++ b/assets/js/hooks/cell.js @@ -1,7 +1,11 @@ import { parseHookProps } from "../lib/attribute"; import Markdown from "../lib/markdown"; -import { globalPubSub } from "../lib/pub_sub"; -import { md5Base64, smoothlyScrollToElement } from "../lib/utils"; +import { globalPubsub } from "../lib/pubsub"; +import { + md5Base64, + smoothlyScrollToElement, + waitUntilInViewport, +} from "../lib/utils"; import scrollIntoView from "scroll-into-view-if-needed"; import { isEvaluable } from "../lib/notebook"; @@ -83,19 +87,17 @@ const Cell = { this.el.removeAttribute("data-js-hover"); }); - this.unsubscribeFromNavigationEvents = globalPubSub.subscribe( - "navigation", - (event) => this.handleNavigationEvent(event) - ); - - this.unsubscribeFromCellsEvents = globalPubSub.subscribe("cells", (event) => - this.handleCellsEvent(event) - ); - - this.unsubscribeFromCellEvents = globalPubSub.subscribe( - `cells:${this.props.cellId}`, - (event) => this.handleCellEvent(event) - ); + this.subscriptions = [ + globalPubsub.subscribe( + "navigation", + this.handleNavigationEvent.bind(this) + ), + globalPubsub.subscribe("cells", this.handleCellsEvent.bind(this)), + globalPubsub.subscribe( + `cells:${this.props.cellId}`, + this.handleCellEvent.bind(this) + ), + ]; // DOM events @@ -112,9 +114,7 @@ const Cell = { }, destroyed() { - this.unsubscribeFromNavigationEvents(); - this.unsubscribeFromCellsEvents(); - this.unsubscribeFromCellEvents(); + this.subscriptions.forEach((subscription) => subscription.destroy()); window.visualViewport.removeEventListener( "resize", @@ -147,16 +147,12 @@ const Cell = { this.handleElementFocused(event.focusableId, event.scroll); } else if (event.type === "insert_mode_changed") { this.handleInsertModeChanged(event.enabled); - } else if (event.type === "location_report") { - this.handleLocationReport(event.client, event.report); } }, handleCellsEvent(event) { if (event.type === "cell_moved") { this.handleCellMoved(event.cellId); - } else if (event.type === "cell_upload") { - this.handleCellUpload(event.cellId, event.url); } }, @@ -184,21 +180,9 @@ const Cell = { this.updateInsertModeAvailability(); - if (this.props.type !== "markdown") { - // For markdown cells the editor is mounted lazily when needed, - // for other cells we mount the editor eagerly, however mounting - // is a synchronous operation and is relatively expensive, so we - // defer it to run after the current event handlers - setTimeout(() => { - if (!liveEditor.isMounted()) { - liveEditor.mount(); - } - }, 0); - } - if (liveEditor === this.currentEditor()) { // Once the editor is created, reflect the current insert mode state - this.maybeFocusCurrentEditor(true); + this.maybeFocusCurrentEditor(); } liveEditor.onBlur(() => { @@ -209,8 +193,14 @@ const Cell = { } }); - liveEditor.onCursorSelectionChange((selection) => { - this.broadcastSelection(selection); + liveEditor.onFocus(() => { + // Prevent from focusing unless the state changes. The editor + // uses a contenteditable element, and it may accidentally get + // focus. In Chrome clicking on the right-side of the editor + // gives it focus + if (!this.isFocused || !this.insertMode) { + this.currentEditor().blur(); + } }); if (tag === "primary") { @@ -260,7 +250,7 @@ const Cell = { this.handleEvent( `doctest_report:${this.props.cellId}`, (doctestReport) => { - liveEditor.updateDoctest(doctestReport); + liveEditor.updateDoctests([doctestReport]); } ); @@ -278,7 +268,7 @@ const Cell = { handleViewportResize() { if (this.isFocused) { - this.scrollActiveElementIntoView(); + this.scrollEditorCursorIntoViewIfNeeded(); } }, @@ -302,17 +292,9 @@ const Cell = { ); }, - maybeFocusCurrentEditor(scroll = false) { + maybeFocusCurrentEditor() { if (this.isFocused && this.insertMode) { this.currentEditor().focus(); - - if (scroll) { - // If the element is being scrolled to, focus interrupts it, - // so ensure the scrolling continues. - smoothlyScrollToElement(this.el); - } - - this.broadcastSelection(); } }, @@ -335,15 +317,7 @@ const Cell = { if (this.currentEditor()) { this.currentEditor().focus(); - - // The insert mode may be enabled as a result of clicking the editor, - // in which case we want to wait until editor handles the click and - // sets new cursor position. To achieve this, we simply put this task - // at the end of event loop, ensuring the editor mousedown handler is - // executed first - setTimeout(this.scrollActiveElementIntoView.bind(this), 0); - - this.broadcastSelection(); + this.scrollEditorCursorIntoViewIfNeeded(); } } else if (this.insertMode && !insertMode) { this.insertMode = insertMode; @@ -360,23 +334,10 @@ const Cell = { } }, - handleCellUpload(cellId, url) { - const liveEditor = this.liveEditors.primary; - - if (!liveEditor) { - return; - } - - if (this.props.cellId === cellId) { - const markdown = `![](${url})`; - liveEditor.insert(markdown); - } - }, - handleDispatchQueueEvaluation(dispatch) { if (this.props.type === "smart" && this.props.smartCellJsViewRef) { // Ensure the smart cell UI is reflected on the server, before the evaluation - globalPubSub.broadcast(`js_views:${this.props.smartCellJsViewRef}`, { + globalPubsub.broadcast(`js_views:${this.props.smartCellJsViewRef}`, { type: "sync", callback: dispatch, }); @@ -385,41 +346,10 @@ const Cell = { } }, - handleLocationReport(client, report) { - Object.entries(this.liveEditors).forEach(([tag, liveEditor]) => { - if ( - this.props.cellId === report.focusableId && - report.selection && - report.selection.tag === tag - ) { - liveEditor.updateUserSelection( - client, - report.selection.editorSelection - ); - } else { - liveEditor.removeUserSelection(client); - } - }); - }, - - broadcastSelection(editorSelection = null) { - editorSelection = - editorSelection || this.currentEditor().editor.getSelection(); - - const tag = this.currentEditorTag(); - - // Report new selection only if this cell is in insert mode - if (this.isFocused && this.insertMode) { - globalPubSub.broadcast("session", { - type: "cursor_selection_changed", - focusableId: this.props.cellId, - selection: { tag, editorSelection }, - }); - } - }, + scrollEditorCursorIntoViewIfNeeded() { + const element = this.currentEditor().getElementAtCursor(); - scrollActiveElementIntoView() { - scrollIntoView(document.activeElement, { + scrollIntoView(element, { scrollMode: "if-needed", behavior: "smooth", block: "center", diff --git a/assets/js/hooks/cell_editor.js b/assets/js/hooks/cell_editor.js index 65e8cac3652..26fea2f0043 100644 --- a/assets/js/hooks/cell_editor.js +++ b/assets/js/hooks/cell_editor.js @@ -1,5 +1,7 @@ import LiveEditor from "./cell_editor/live_editor"; +import Connection from "./cell_editor/live_editor/connection"; import { parseHookProps } from "../lib/attribute"; +import { waitUntilInViewport } from "../lib/utils"; const CellEditor = { mounted() { @@ -15,25 +17,45 @@ const CellEditor = { const editorEl = document.createElement("div"); editorContainer.appendChild(editorEl); - this.liveEditor = new LiveEditor( + this.connection = new Connection( this, - editorEl, this.props.cellId, - this.props.tag, + this.props.tag + ); + + this.liveEditor = new LiveEditor( + editorEl, + this.connection, source, revision, this.props.language, this.props.intellisense, - this.props.readOnly, - code_markers, - doctest_reports + this.props.readOnly + ); + + this.liveEditor.setCodeMarkers(code_markers); + this.liveEditor.updateDoctests(doctest_reports); + + const skeletonEl = editorContainer.querySelector(`[data-el-skeleton]`); + + // Replace the skeleton with initial source, so that the + // whole page is still searchable, before the editors are + // lazily mounted. This also ensures the scroll has an + // accurate length right away. + const sourceEl = document.createElement("div"); + + sourceEl.classList.add( + "whitespace-pre", + "text-editor", + "font-editor", + "px-12" ); + sourceEl.textContent = source; + skeletonEl.replaceChildren(sourceEl); this.liveEditor.onMount(() => { // Remove the content placeholder - const skeletonEl = - editorContainer.querySelector(`[data-el-skeleton]`); - skeletonEl && skeletonEl.remove(); + skeletonEl.remove(); }); this.el.dispatchEvent( @@ -42,6 +64,15 @@ const CellEditor = { bubbles: true, }) ); + + this.visibility = waitUntilInViewport(this.el); + + // We mount the editor lazily once it enters the viewport + this.visibility.promise.then(() => { + if (!this.liveEditor.isMounted()) { + this.liveEditor.mount(); + } + }); } ); }, @@ -55,6 +86,10 @@ const CellEditor = { }, destroyed() { + if (this.connection) { + this.connection.destroy(); + } + if (this.liveEditor) { this.el.dispatchEvent( new CustomEvent("lb:cell:editor_removed", { @@ -62,7 +97,11 @@ const CellEditor = { bubbles: true, }) ); - this.liveEditor.dispose(); + this.liveEditor.destroy(); + } + + if (this.visibility) { + this.visibility.cancel(); } }, diff --git a/assets/js/hooks/cell_editor/live_editor.js b/assets/js/hooks/cell_editor/live_editor.js index 496d4ac67c8..6018cd709a5 100644 --- a/assets/js/hooks/cell_editor/live_editor.js +++ b/assets/js/hooks/cell_editor/live_editor.js @@ -1,64 +1,120 @@ -import renderMathInElement from "katex/contrib/auto-render"; - -import monaco from "./live_editor/monaco"; -import EditorClient from "./live_editor/editor_client"; -import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter"; -import HookServerAdapter from "./live_editor/hook_server_adapter"; -import RemoteUser from "./live_editor/remote_user"; +import { + EditorView, + hoverTooltip, + keymap, + highlightSpecialChars, + drawSelection, + highlightActiveLine, + dropCursor, + rectangularSelection, + crosshairCursor, + lineNumbers, + highlightActiveLineGutter, +} from "@codemirror/view"; +import { EditorState } from "@codemirror/state"; +import { + indentOnInput, + bracketMatching, + foldGutter, + LanguageDescription, + codeFolding, +} from "@codemirror/language"; +import { history } from "@codemirror/commands"; +import { highlightSelectionMatches } from "@codemirror/search"; +import { + autocompletion, + closeBrackets, + snippetCompletion, +} from "@codemirror/autocomplete"; +import { setDiagnostics } from "@codemirror/lint"; +import { vscodeKeymap } from "@replit/codemirror-vscode-keymap"; +import { vim } from "@replit/codemirror-vim"; +import { emacs } from "@replit/codemirror-emacs"; + +import { collab, deltaToChanges } from "./live_editor/codemirror/collab"; +import { collabMarkers } from "./live_editor/codemirror/collab_markers"; +import { theme, lightTheme } from "./live_editor/codemirror/theme"; +import { + clearDoctests, + updateDoctests, +} from "./live_editor/codemirror/doctests"; +import { signature } from "./live_editor/codemirror/signature"; +import { formatter } from "./live_editor/codemirror/formatter"; import { replacedSuffixLength } from "../../lib/text_utils"; import { settingsStore } from "../../lib/settings"; -import Doctest from "./live_editor/doctest"; -import { initVimMode, VimMode } from "monaco-vim"; -import { EmacsExtension, unregisterKey } from "monaco-emacs"; - -// Expose the Vim extension for customization -window.unstable_monacoExtensions = { VimMode }; +import Delta from "../../lib/delta"; +import Markdown from "../../lib/markdown"; +import { readOnlyHint } from "./live_editor/codemirror/read_only_hint"; +import { wait } from "../../lib/utils"; +import Emitter from "../../lib/emitter"; +import CollabClient from "./live_editor/collab_client"; +import { languages } from "./live_editor/codemirror/languages"; +import { exitMulticursor } from "./live_editor/codemirror/commands"; +import { highlight } from "./live_editor/highlight"; /** * Mounts cell source editor with real-time collaboration mechanism. + * + * The actual editor must be mounted explicitly by calling either of + * the `mount` or `focus` methods, but it can be done at any point. + * Even when not mounted, the editor consumes collaborative updates + * and invokes change listeners. */ -class LiveEditor { +export default class LiveEditor { + /** @private */ + _onMount = new Emitter(); + + /** + * Registers a callback called when the editor is mounted in DOM. + */ + onMount = this._onMount.event; + + /** @private */ + _onChange = new Emitter(); + + /** + * Registers a callback called with a new cell content whenever it changes. + */ + onChange = this._onChange.event; + + /** @private */ + _onBlur = new Emitter(); + + /** + * Registers a callback called whenever the editor loses focus. + */ + onBlur = this._onBlur.event; + + /** @private */ + _onFocus = new Emitter(); + + /** + * Registers a callback called whenever the editor gains focus. + */ + onFocus = this._onFocus.event; + constructor( - hook, container, - cellId, - tag, + connection, source, revision, language, intellisense, - readOnly, - codeMarkers, - doctestReports + readOnly ) { - this.hook = hook; this.container = container; - this.cellId = cellId; this.source = source; this.language = language; this.intellisense = intellisense; this.readOnly = readOnly; - this._onMount = []; - this._onChange = []; - this._onBlur = []; - this._onCursorSelectionChange = []; - this._remoteUserByClientId = {}; - this._doctestByLine = {}; - - this._initializeWidgets = () => { - this.setCodeMarkers(codeMarkers); - - doctestReports.forEach((doctestReport) => { - this.updateDoctest(doctestReport); - }); - }; + this.initialWidgets = {}; - const serverAdapter = new HookServerAdapter(hook, cellId, tag); - this.editorClient = new EditorClient(serverAdapter, revision); + this.connection = connection; + this.collabClient = new CollabClient(connection, revision); - this.editorClient.onDelta((delta) => { + this.deltaSubscription = this.collabClient.onDelta((delta, info) => { this.source = delta.applyToString(this.source); - this._onChange.forEach((callback) => callback(this.source)); + this._onChange.dispatch(this.source); }); } @@ -66,7 +122,7 @@ class LiveEditor { * Checks if an editor instance has been mounted in the DOM. */ isMounted() { - return !!this.editor; + return !!this.view; } /** @@ -77,36 +133,11 @@ class LiveEditor { throw new Error("The editor is already mounted"); } - this._mountEditor(); - - if (this.intellisense) { - this._setupIntellisense(); - } - - this.editorClient.setEditorAdapter(new MonacoEditorAdapter(this.editor)); - - this.editor.onDidFocusEditorWidget(() => { - this.editor.updateOptions({ matchBrackets: "always" }); - }); - - this.editor.onDidBlurEditorWidget(() => { - this.editor.updateOptions({ matchBrackets: "never" }); - this._onBlur.forEach((callback) => callback()); - }); - - this.editor.onDidChangeCursorSelection((event) => { - this._onCursorSelectionChange.forEach((callback) => - callback(event.selection) - ); - }); + this.mountEditor(); - this._onMount.forEach((callback) => callback()); - } + this.setInitialWidgets(); - _ensureMounted() { - if (!this.isMounted()) { - this.mount(); - } + this._onMount.dispatch(); } /** @@ -117,115 +148,66 @@ class LiveEditor { } /** - * Registers a callback called with the editor is mounted in DOM. + * Returns an element closest to the current main cursor position. */ - onMount(callback) { - this._onMount.push(callback); - } - - /** - * Registers a callback called with a new cell content whenever it changes. - */ - onChange(callback) { - this._onChange.push(callback); - } + getElementAtCursor() { + if (!this.isMounted()) { + return this.container; + } - /** - * Registers a callback called with a new cursor selection whenever it changes. - */ - onCursorSelectionChange(callback) { - this._onCursorSelectionChange.push(callback); + const { node } = this.view.domAtPos(this.view.state.selection.main.head); + if (node instanceof Element) return node; + return node.parentElement; } /** - * Registers a callback called whenever the editor loses focus. + * Focuses the editor. + * + * Note that this forces the editor to be mounted, if it is not already + * mounted. */ - onBlur(callback) { - this._onBlur.push(callback); - } - focus() { - this._ensureMounted(); - - this.editor.focus(); - } - - blur() { - this._ensureMounted(); - - if (this.editor.hasTextFocus()) { - document.activeElement.blur(); + if (!this.isMounted()) { + this.mount(); } - } - - insert(text) { - this._ensureMounted(); - const range = this.editor.getSelection(); - this.editor - .getModel() - .pushEditOperations([], [{ forceMoveMarkers: true, range, text }]); + this.view.focus(); } /** - * Performs necessary cleanup actions. + * Removes focus from the editor. */ - dispose() { - if (this.isMounted()) { - // Explicitly destroy the editor instance and its text model. - this.editor.dispose(); - - const model = this.editor.getModel(); - - if (model) { - model.dispose(); - } + blur() { + if (this.isMounted() && this.view.hasFocus) { + this.view.contentDOM.blur(); } } /** - * Either adds or moves remote user cursor to the new position. + * Performs necessary cleanup actions. */ - updateUserSelection(client, selection) { - this._ensureMounted(); - - if (this._remoteUserByClientId[client.id]) { - this._remoteUserByClientId[client.id].update(selection); - } else { - this._remoteUserByClientId[client.id] = new RemoteUser( - this.editor, - selection, - client.hex_color, - client.name - ); + destroy() { + if (this.isMounted()) { + this.view.destroy(); } - } - - /** - * Removes remote user cursor. - */ - removeUserSelection(client) { - this._ensureMounted(); - if (this._remoteUserByClientId[client.id]) { - this._remoteUserByClientId[client.id].dispose(); - delete this._remoteUserByClientId[client.id]; - } + this.collabClient.destroy(); + this.deltaSubscription.destroy(); } /** * Either adds or updates doctest indicators. */ - updateDoctest(doctestReport) { - this._ensureMounted(); - - if (this._doctestByLine[doctestReport.line]) { - this._doctestByLine[doctestReport.line].update(doctestReport); + updateDoctests(doctestReports) { + if (this.isMounted()) { + updateDoctests(this.view, doctestReports); } else { - this._doctestByLine[doctestReport.line] = new Doctest( - this.editor, - doctestReport - ); + this.initialWidgets.doctestReportsByLine = + this.initialWidgets.doctestReportsByLine || {}; + + for (const report of doctestReports) { + this.initialWidgets.doctestReportsByLine[report.line] = report; + } } } @@ -233,11 +215,11 @@ class LiveEditor { * Removes doctest indicators. */ clearDoctests() { - this._ensureMounted(); - - Object.values(this._doctestByLine).forEach((doctest) => doctest.dispose()); - - this._doctestByLine = {}; + if (this.isMounted()) { + clearDoctests(this.view); + } else { + delete this.initialWidgets.doctestReportsByLine; + } } /** @@ -246,488 +228,323 @@ class LiveEditor { * Passing an empty list clears all markers. */ setCodeMarkers(codeMarkers) { - this._ensureMounted(); - - const owner = "livebook.code-marker"; - - const editorMarkers = codeMarkers.map((codeMarker) => { - const line = this.editor.getModel().getLineContent(codeMarker.line); - const [, leadingWhitespace, trailingWhitespace] = - line.match(/^(\s*).*?(\s*)$/); - - return { - startLineNumber: codeMarker.line, - startColumn: leadingWhitespace.length + 1, - endLineNumber: codeMarker.line, - endColumn: line.length + 1 - trailingWhitespace.length, - message: codeMarker.description, - severity: { - error: monaco.MarkerSeverity.Error, - warning: monaco.MarkerSeverity.Warning, - }[codeMarker.severity], - }; - }); + if (this.isMounted()) { + const doc = this.view.state.doc; + + const diagnostics = codeMarkers.map((marker) => { + const line = doc.lineAt(marker.line); + + const [, leadingWhitespace, trailingWhitespace] = + line.text.match(/^(\s*).*?(\s*)$/); + + const from = line.from + leadingWhitespace.length; + const to = line.to - trailingWhitespace.length; - monaco.editor.setModelMarkers(this.editor.getModel(), owner, editorMarkers); + return { + from, + to, + severity: marker.severity, + message: marker.description, + }; + }); + + this.view.dispatch(setDiagnostics(this.view.state, diagnostics)); + } else { + this.initialWidgets.codeMarkers = codeMarkers; + } } - _mountEditor() { + /** @private */ + mountEditor() { const settings = settingsStore.get(); - this.editor = monaco.editor.create(this.container, { - language: this.language, - value: this.source, - readOnly: this.readOnly, - scrollbar: { - vertical: "hidden", - alwaysConsumeMouseWheel: false, - }, - minimap: { - enabled: false, - }, - overviewRulerLanes: 0, - scrollBeyondLastLine: false, - guides: { - indentation: false, - }, - occurrencesHighlight: false, - renderLineHighlight: "none", - theme: settings.editor_theme, - fontFamily: "JetBrains Mono, Droid Sans Mono, monospace", - fontSize: settings.editor_font_size, - tabIndex: -1, - tabSize: 2, - autoIndent: true, - formatOnType: true, - formatOnPaste: true, - quickSuggestions: this.intellisense && settings.editor_auto_completion, - tabCompletion: "on", - suggestSelection: "first", - // For Elixir word suggestions are confusing at times. - // For example given `defmodule Foo do`, if the - // user opens completion list and then jumps to the end - // of the line we would get "defmodule" as a word completion. - wordBasedSuggestions: !this.intellisense, - parameterHints: this.intellisense && settings.editor_auto_signature, - wordWrap: - this.language === "markdown" && settings.editor_markdown_word_wrap - ? "on" - : "off", - }); - - this._setScreenDependantEditorOptions(); + const formatLineNumber = (number) => number.toString().padStart(3, " "); - this.editor.addAction({ - contextMenuGroupId: "word-wrapping", - id: "enable-word-wrapping", - label: "Enable word wrapping", - precondition: "config.editor.wordWrap == off", - keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyZ], - run: (editor) => editor.updateOptions({ wordWrap: "on" }), - }); + const foldGutterMarkerDOM = (open) => { + const node = document.createElement("i"); + node.classList.add( + open ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line", + open ? "cm-gutterFoldMarker-open" : null + ); + return node; + }; - this.editor.addAction({ - contextMenuGroupId: "word-wrapping", - id: "disable-word-wrapping", - label: "Disable word wrapping", - precondition: "config.editor.wordWrap == on", - keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyZ], - run: (editor) => editor.updateOptions({ wordWrap: "off" }), + const fontSizeTheme = EditorView.theme({ + "&": { fontSize: `${settings.editor_font_size}px` }, }); - // Automatically adjust the editor size to fit the container. - const resizeObserver = new ResizeObserver((entries) => { - entries.forEach((entry) => { - // Ignore hidden container. - if (this.container.offsetHeight > 0) { - this._setScreenDependantEditorOptions(); - this.editor.layout(); - } - }); - }); + const lineWrappingEnabled = + this.language === "markdown" && settings.editor_markdown_word_wrap; - resizeObserver.observe(this.container); + const language = LanguageDescription.matchLanguageName( + languages, + this.language, + false + ); - // Whenever editor content size changes (new line is added/removed) - // update the container height. Thanks to the above observer - // the editor is resized to fill the container. - // Related: https://github.com/microsoft/monaco-editor/issues/794#issuecomment-688959283 - this.editor.onDidContentSizeChange(() => { - const contentHeight = this.editor.getContentHeight(); - this.container.style.height = `${contentHeight}px`; + const customKeymap = [{ key: "Escape", run: exitMulticursor }]; + + this.view = new EditorView({ + parent: this.container, + doc: this.source, + extensions: [ + lineNumbers({ formatNumber: formatLineNumber }), + highlightActiveLine(), + highlightActiveLineGutter(), + highlightSpecialChars(), + highlightSelectionMatches(), + foldGutter({ markerDOM: foldGutterMarkerDOM }), + codeFolding({ placeholderText: "⋯" }), + drawSelection(), + dropCursor(), + rectangularSelection(), + crosshairCursor(), + EditorState.allowMultipleSelections.of(true), + bracketMatching(), + closeBrackets(), + indentOnInput(), + history(), + EditorState.readOnly.of(this.readOnly), + readOnlyHint(), + keymap.of(vscodeKeymap), + keymap.of(customKeymap), + EditorState.tabSize.of(2), + EditorState.lineSeparator.of("\n"), + lineWrappingEnabled ? EditorView.lineWrapping : [], + // We bind tab to actions within the editor, which would trap + // the user if they tabbed into the editor, so we remove it + // from the tab navigation + EditorView.contentAttributes.of({ tabIndex: -1 }), + fontSizeTheme, + settings.editor_theme === "light" ? lightTheme : theme, + collab(this.collabClient), + collabMarkers(this.collabClient), + autocompletion({ + activateOnTyping: settings.editor_auto_completion, + defaultKeymap: false, + }), + this.intellisense + ? [ + autocompletion({ override: [this.completionSource.bind(this)] }), + hoverTooltip(this.docsHoverTooltipSource.bind(this)), + signature(this.signatureSource.bind(this), { + activateOnTyping: settings.editor_auto_signature, + }), + formatter(this.formatterSource.bind(this)), + ] + : [], + settings.editor_mode === "vim" ? [vim()] : [], + settings.editor_mode === "emacs" ? [emacs()] : [], + language && language.support, + EditorView.domEventHandlers({ + keydown: this.handleEditorKeydown.bind(this), + blur: this.handleEditorBlur.bind(this), + focus: this.handleEditorFocus.bind(this), + }), + ], }); + } - /* Overrides */ - - // Move the command palette widget to overflowing widgets container, - // so that it's visible on small editors. - // See: https://github.com/microsoft/monaco-editor/issues/70 - const commandPaletteNode = this.editor.getContribution( - "editor.controller.quickInput" - ).widget.domNode; - commandPaletteNode.remove(); - this.editor._modelData.view._contentWidgets.overflowingContentWidgetsDomNode.domNode.appendChild( - commandPaletteNode - ); + /** @private */ + handleEditorKeydown(event) { + // We dispatch escape event, but only if it is not consumed by any + // registered handler in the editor, such as closing autocompletion + // or escaping Vim insert mode - // Add the widgets that the editor was initialized with - this._initializeWidgets(); + if (event.key === "Escape") { + this.container.dispatchEvent( + new CustomEvent("lb:editor_escape", { bubbles: true }) + ); + } - // Set the editor mode - this._setEditorMode(settings.editor_mode); + return false; } - /** - * Sets Monaco editor options that depend on the current screen's size. - */ - _setScreenDependantEditorOptions() { - if (window.screen.width < 768) { - this.editor.updateOptions({ - folding: false, - lineDecorationsWidth: 16, - lineNumbersMinChars: - Math.floor(Math.log10(this.editor.getModel().getLineCount())) + 3, - }); - } else { - this.editor.updateOptions({ - folding: true, - lineDecorationsWidth: 10, - lineNumbersMinChars: 5, - }); + /** @private */ + handleEditorBlur(event) { + if (!this.container.contains(event.relatedTarget)) { + this._onBlur.dispatch(); } + + return false; } - /** - * Defines cell-specific providers for various editor features. - */ - _setupIntellisense() { + /** @private */ + handleEditorFocus(event) { + this._onFocus.dispatch(); + + return false; + } + + /** @private */ + completionSource(context) { const settings = settingsStore.get(); - this.handlerByRef = {}; - - /** - * Intellisense requests such as completion or formatting are - * handled asynchronously by the runtime. - * - * As an example, let's go through the steps for completion: - * - * * the user opens the completion list, which triggers the global - * completion provider registered in `live_editor/monaco.js` - * - * * the global provider delegates to the cell-specific `__getCompletionItems__` - * defined below. That's a little bit hacky, but this way we make - * completion cell-specific - * - * * then `__getCompletionItems__` sends a completion request to the LV process - * and gets a unique reference, under which it keeps completion callback - * - * * finally the hook receives the "intellisense_response" event with completion - * response, it looks up completion callback for the received reference and calls - * it with the response, which finally returns the completion items to the editor - */ - - this.editor.getModel().__getCompletionItems__ = (model, position) => { - const line = model.getLineContent(position.lineNumber); - const lineUntilCursor = line.slice(0, position.column - 1); - - return this._asyncIntellisenseRequest("completion", { - hint: lineUntilCursor, + // Trigger completion implicitly only for identifiers and members + const triggerBeforeCursor = context.matchBefore(/[\w?!.]$/); + const lineUntilCursor = context.matchBefore(/^.*/); + + if (!triggerBeforeCursor && !context.explicit) { + return null; + } + + return this.connection + .intellisenseRequest("completion", { + hint: lineUntilCursor.text, editor_auto_completion: settings.editor_auto_completion, }) - .then((response) => { - const suggestions = completionItemsToSuggestions( - response.items, - settings - ).map((suggestion) => { - const replaceLength = replacedSuffixLength( - lineUntilCursor, - suggestion.insertText - ); - - const range = new monaco.Range( - position.lineNumber, - position.column - replaceLength, - position.lineNumber, - position.column - ); - - return { ...suggestion, range }; - }); - - return { suggestions }; - }) - .catch(() => null); - }; + .then((response) => { + if (response.items.length === 0) return null; - this.editor.getModel().__getHover__ = (model, position) => { - // On the first hover, we setup a listener to postprocess hover - // content with KaTeX. Prior to that, the hover element is not - // in the DOM + const completions = response.items.map((item, index) => { + const completion = this.completionItemToCompletions(item); - this.hoverContentProcessed = false; + return { + ...completion, + // Keep the ordering from the server + boost: 1 - index / response.items.length, + }; + }); - if (!this.hoverContentEl) { - this.hoverContentEl = this.container.querySelector( - ".monaco-hover-content" + const replaceLength = replacedSuffixLength( + lineUntilCursor.text, + response.items[0].insert_text ); - if (this.hoverContentEl) { - new MutationObserver((event) => { - // We mutate the DOM, so we use a flag to ignore events - // that we triggered ourselves - if (!this.hoverContentProcessed) { - renderMathInElement(this.hoverContentEl, { - delimiters: [ - { left: "$$", right: "$$", display: true }, - { left: "$", right: "$", display: false }, - ], - throwOnError: false, - }); - this.hoverContentProcessed = true; - } - }).observe(this.hoverContentEl, { childList: true }); - } else { - console.warn( - "Could not find an element matching .monaco-hover-content" - ); - } - } - - const line = model.getLineContent(position.lineNumber); - const column = position.column; - - return this._asyncIntellisenseRequest("details", { line, column }) - .then((response) => { - const contents = response.contents.map((content) => ({ - value: content, - isTrusted: true, - })); - - const range = new monaco.Range( - position.lineNumber, - response.range.from, - position.lineNumber, - response.range.to - ); - - return { contents, range }; - }) - .catch(() => null); - }; - - const signatureCache = { - codeUntilLastStop: null, - response: null, - }; - - this.editor.getModel().__getSignatureHelp__ = (model, position) => { - const lines = model.getLinesContent(); - const lineIdx = position.lineNumber - 1; - const prevLines = lines.slice(0, lineIdx); - const lineUntilCursor = lines[lineIdx].slice(0, position.column - 1); - const codeUntilCursor = [...prevLines, lineUntilCursor].join("\n"); - - const codeUntilLastStop = codeUntilCursor - // Remove trailing characters that don't affect the signature - .replace(/[^(),\s]*?$/, "") - // Remove whitespace before delimiter - .replace(/([(),])\s*$/, "$1"); - - // Cache subsequent requests for the same prefix, so that we don't - // make unnecessary requests - if (codeUntilLastStop === signatureCache.codeUntilLastStop) { return { - value: signatureResponseToSignatureHelp(signatureCache.response), - dispose: () => {}, + from: lineUntilCursor.to - replaceLength, + options: completions, + validFor: /^\w*[!?]?$/, }; - } - - return this._asyncIntellisenseRequest("signature", { - hint: codeUntilCursor, }) - .then((response) => { - signatureCache.response = response; - signatureCache.codeUntilLastStop = codeUntilLastStop; - - return { - value: signatureResponseToSignatureHelp(response), - dispose: () => {}, - }; - }) - .catch(() => null); - }; + .catch(() => null); + } - this.editor.getModel().__getDocumentFormattingEdits__ = (model) => { - const content = model.getValue(); - - return this._asyncIntellisenseRequest("format", { code: content }) - .then((response) => { - this.setCodeMarkers(response.code_markers); - - if (response.code) { - /** - * We use a single edit replacing the whole editor content, - * but the editor itself optimises this into a list of edits - * that produce minimal diff using the Myers string difference. - * - * References: - * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L324 - * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/common/services/editorSimpleWorker.ts#L489 - * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/base/common/diff/diff.ts#L227-L231 - * - * Eventually the editor will received the optimised list of edits, - * which we then convert to Delta and send to the server. - * Consequently, the Delta carries only the minimal formatting diff. - * - * Also, if edits are applied to the editor, either by typing - * or receiving remote changes, the formatting is cancelled. - * In other words the formatting changes are actually applied - * only if the editor stays intact. - * - * References: - * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L313 - * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/browser/core/editorState.ts#L137 - * * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L326 - */ - - const replaceEdit = { - range: model.getFullModelRange(), - text: response.code, - }; - - return [replaceEdit]; - } else { - return []; + /** @private */ + completionItemToCompletions(item) { + const completion = { + label: item.label, + type: item.kind, + info: (completion) => { + // The info popup is shown automatically, we delay it a bit + // to not distract the user too much as they are typing + return wait(350).then(() => { + const node = document.createElement("div"); + const detail = document.createElement("div"); + detail.classList.add("cm-completionInfoDetail"); + detail.innerHTML = highlight(item.detail, this.language); + node.appendChild(detail); + + if (item.documentation) { + const docs = document.createElement("div"); + docs.classList.add("cm-completionInfoDocs"); + docs.classList.add("cm-markdown"); + node.appendChild(docs); + new Markdown(docs, item.documentation, { + defaultCodeLanguage: this.language, + }); } - }) - .catch(() => null); + + return node; + }); + }, }; - this.hook.handleEvent("intellisense_response", ({ ref, response }) => { - const handler = this.handlerByRef[ref]; + // Place cursor at the end, if not explicitly specified + const template = item.insert_text.includes("${}") + ? item.insert_text + : item.insert_text + "${}"; - if (handler) { - handler(response); - delete this.handlerByRef[ref]; - } - }); + return snippetCompletion(template, completion); } - /** - * Pushes an intellisense request. - * - * The returned promise is either resolved with a valid - * response or rejected with null. - */ - _asyncIntellisenseRequest(type, props) { - return new Promise((resolve, reject) => { - this.hook.pushEvent( - "intellisense_request", - { cell_id: this.cellId, type, ...props }, - ({ ref }) => { - if (ref) { - this.handlerByRef[ref] = (response) => { - if (response) { - resolve(response); - } else { - reject(null); - } - }; - } else { - reject(null); - } - } - ); - }); + /** @private */ + docsHoverTooltipSource(view, pos, side) { + const line = view.state.doc.lineAt(pos); + const lineLength = line.to - line.from; + + const text = line.text; + // If we are on the right side of the position, we add one to + // convert it to column + const column = pos - line.from + (side === 1 ? 1 : 0); + if (column < 1 || column > lineLength) return null; + + return this.connection + .intellisenseRequest("details", { line: text, column }) + .then((response) => { + // Note: the response range is a right-exclusive column range + + return { + pos: line.from + response.range.from - 1, + end: line.from + response.range.to - 1, + above: true, + create: (view) => { + const dom = document.createElement("div"); + dom.classList.add("cm-hoverDocs"); + + for (const content of response.contents) { + const item = document.createElement("div"); + item.classList.add("cm-hoverDocsContent"); + item.classList.add("cm-markdown"); + dom.appendChild(item); + new Markdown(item, content, { + defaultCodeLanguage: this.language, + }); + } + + return { dom }; + }, + }; + }) + .catch(() => null); } - /** - * Sets Monaco editor mode via monaco-emacs or monaco-vim packages. - */ - _setEditorMode(editorMode) { - if (editorMode == "emacs") { - this.emacsMode = new EmacsExtension(this.editor); - this.emacsMode.start(); - unregisterKey("Tab"); - } else if (editorMode == "vim") { - this.vimMode = initVimMode(this.editor); - this.vimMode.on("vim-mode-change", ({ mode: mode }) => { - this.editor.getDomNode().setAttribute("data-vim-mode", mode); - }); - } + /** @private */ + signatureSource({ state, pos }) { + const textUntilCursor = state.doc.sliceString(0, pos); + + return this.connection + .intellisenseRequest("signature", { + hint: textUntilCursor, + }) + .then((response) => { + return { + activeArgumentIdx: response.active_argument, + items: response.items, + }; + }) + .catch(() => null); } -} -function completionItemsToSuggestions(items, settings) { - return items - .map((item) => parseItem(item, settings)) - .map((suggestion, index) => ({ - ...suggestion, - sortText: numberToSortableString(index, items.length), - })); -} + formatterSource(doc) { + return this.connection + .intellisenseRequest("format", { code: doc.toString() }) + .then((response) => { + this.setCodeMarkers(response.code_markers); -// See `Livebook.Runtime` for completion item definition -function parseItem(item, settings) { - return { - label: item.label, - kind: parseItemKind(item.kind), - detail: item.detail, - documentation: item.documentation && { - value: item.documentation, - isTrusted: true, - }, - insertText: item.insert_text, - insertTextRules: - monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - command: settings.editor_auto_signature - ? { - title: "Trigger Parameter Hint", - id: "editor.action.triggerParameterHints", + if (response.delta) { + const delta = Delta.fromCompressed(response.delta); + return deltaToChanges(delta); + } else { + return null; } - : null, - }; -} - -function parseItemKind(kind) { - switch (kind) { - case "function": - return monaco.languages.CompletionItemKind.Function; - case "module": - return monaco.languages.CompletionItemKind.Module; - case "struct": - return monaco.languages.CompletionItemKind.Struct; - case "interface": - return monaco.languages.CompletionItemKind.Interface; - case "type": - return monaco.languages.CompletionItemKind.Class; - case "variable": - return monaco.languages.CompletionItemKind.Variable; - case "field": - return monaco.languages.CompletionItemKind.Field; - case "keyword": - return monaco.languages.CompletionItemKind.Keyword; - default: - return null; + }) + .catch(() => null); } -} -function numberToSortableString(number, maxNumber) { - return String(number).padStart(maxNumber, "0"); -} + /** @private */ + setInitialWidgets() { + if (this.initialWidgets.doctestReportsByLine) { + const doctestReports = Object.values( + this.initialWidgets.doctestReportsByLine + ); + this.updateDoctests(doctestReports); + } -function signatureResponseToSignatureHelp(response) { - return { - activeSignature: 0, - activeParameter: response.active_argument, - signatures: response.signature_items.map((signature_item) => ({ - label: signature_item.signature, - parameters: signature_item.arguments.map((argument) => ({ - label: argument, - })), - documentation: null, - })), - }; -} + if (this.initialWidgets.codeMarkers) { + this.setCodeMarkers(this.initialWidgets.codeMarkers); + } -export default LiveEditor; + this.initialWidgets = {}; + } +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/collab.js b/assets/js/hooks/cell_editor/live_editor/codemirror/collab.js new file mode 100644 index 00000000000..4311cdb27ec --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/collab.js @@ -0,0 +1,155 @@ +import { ViewPlugin } from "@codemirror/view"; +import { + Annotation, + Transaction, + Facet, + combineConfig, + EditorSelection, +} from "@codemirror/state"; +import Delta, { isDelete, isInsert, isRetain } from "../../../../lib/delta"; + +const collabConfig = Facet.define({ + combine(configs) { + return combineConfig(configs, {}); + }, +}); + +const remoteTransaction = Annotation.define(); + +const collabPlugin = ViewPlugin.fromClass( + class { + constructor(view) { + const { collabClient } = view.state.facet(collabConfig); + + this.collabClient = collabClient; + + this.deltaSubscription = collabClient.onDelta((delta, { remote }) => { + if (!remote) return; + + // Note that we explicitly transform the current selection, + // rather than relying on the implicit editor mapping. It is + // important that we use the same transformation logic as we + // do for transforming remote user selections, so that all + // selections stay consistent. + + return view.dispatch({ + changes: deltaToChanges(delta), + selection: transformSelection(view.state.selection, delta), + annotations: [ + Transaction.addToHistory.of(false), + Transaction.remote.of(true), + remoteTransaction.of(true), + ], + filter: false, + }); + }); + } + + update(update) { + // Skip changes dispatched by ourselves + const isRemoteChange = update.transactions.some((tr) => + tr.annotation(remoteTransaction) + ); + + if (isRemoteChange) return; + + if (update.docChanged) { + const delta = changesToDelta(update.changes); + const selection = currentSelection(update); + this.collabClient.handleClientDelta(delta, selection); + } else if ( + update.focusChanged || + !update.state.selection.eq(update.startState.selection) + ) { + const selection = currentSelection(update); + this.collabClient.handleClientSelection(selection); + } + } + + destroy() { + this.deltaSubscription.destroy(); + } + } +); + +export function deltaToChanges(delta) { + const specs = []; + let index = 0; + + for (const op of delta.ops) { + if (isRetain(op)) { + index += op.retain; + } + + if (isInsert(op)) { + specs.push({ from: index, to: index, insert: op.insert }); + } + + if (isDelete(op)) { + specs.push({ from: index, to: index + op.delete }); + index += op.delete; + } + } + + return specs; +} + +export function changesToDelta(changes) { + const deltas = []; + + changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + const delta = new Delta(); + + if (fromA) { + delta.retain(fromA); + } + + if (fromA !== toA) { + delta.delete(toA - fromA); + } + + const text = inserted.toString(); + + if (text) { + delta.insert(text); + } + + deltas.push(delta); + }); + + // All deltas represent changes to the same text. We want to compose + // them starting from later ranges to earlier ranges, so that the + // accumulated delta does not invalidate the positions in the deltas + // we add next + return deltas.reverse().reduce((delta1, delta2) => delta1.compose(delta2)); +} + +function currentSelection(update) { + if (!update.view.hasFocus) { + return null; + } + + return update.state.selection; +} + +export function transformSelection(selection, delta) { + const ranges = selection.ranges.map((range) => + EditorSelection.range( + delta.transformPosition(range.anchor), + delta.transformPosition(range.head) + ) + ); + + return EditorSelection.create(ranges, selection.mainIndex); +} + +/** + * Returns an extension that attaches the editor to the collaborative + * client implementation. + * + * With this extension, the editor reports own changes and applies + * changes received from the server. + */ +export function collab(collabClient) { + return [collabPlugin, collabConfig.of({ collabClient })]; +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/collab_markers.js b/assets/js/hooks/cell_editor/live_editor/codemirror/collab_markers.js new file mode 100644 index 00000000000..3f8e6b8a72c --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/collab_markers.js @@ -0,0 +1,207 @@ +import { + EditorView, + ViewPlugin, + Decoration, + WidgetType, +} from "@codemirror/view"; +import { RangeSet, combineConfig, Facet } from "@codemirror/state"; + +const baseTheme = EditorView.baseTheme({ + ".cm-peerCursor": { + position: "relative", + display: "inline", + // We set z-index, so that if there are other widgets, the cursor + // is on top and gets the hover event + zIndex: "1", + }, + + ".cm-peerCursorCaret": { + position: "absolute", + left: "0", + top: "0", + bottom: "0", + height: "100%", + width: "2px", + }, + + ".cm-peerCursorLabel": { + position: "absolute", + left: "0", + top: "0", + transform: "translateY(-100%)", + whiteSpace: "nowrap", + padding: "1px 8px", + fontSize: "12px", + color: "#f8fafc", + visibility: "hidden", + transitionProperty: "visibility", + transitionDuration: "0s", + transitionDelay: "1.5s", + }, + + ".cm-peerCursor .cm-peerCursorLabel:hover": { + visibility: "visible", + }, + + ".cm-peerCursor .cm-peerCursorCaret:hover + .cm-peerCursorLabel": { + visibility: "visible", + transitionDelay: "0s", + }, + + // When in the first line, we want to display cursor and label in + // the same line, because it cannot overflow the editor box. Content + // overflowing the editor is hidden, because .cm-scroller uses + // `overflow-x: scroll`, which forces `overflow-y: hidden`. + ".cm-peerCursor.cm-peerCursor-inline .cm-peerCursorLabel": { + marginLeft: "4px", + transform: "none", + }, +}); + +const collabMarkersConfig = Facet.define({ + combine(configs) { + return combineConfig(configs, {}); + }, +}); + +const collabMarkersPlugin = ViewPlugin.fromClass( + class { + constructor(view) { + const { collabClient } = view.state.facet(collabMarkersConfig); + + this.peers = collabClient.getPeers(); + + this.decorations = RangeSet.of( + Object.values(this.peers).flatMap(decorationsForPeer), + true + ); + + this.peersSubscription = collabClient.onPeersChange((peers) => { + const prevPeers = this.peers; + + if (prevPeers === peers) return; + + const keepPeers = new Set(); + const addPeers = []; + + for (const clientId in peers) { + const peer = peers[clientId]; + const prevPeer = prevPeers[clientId]; + + if (prevPeer && peer.eq(prevPeer)) { + keepPeers.add(clientId); + } else { + addPeers.push(peer); + } + } + + this.decorations = this.decorations.update({ + filter: (from, to, decoration) => keepPeers.has(decoration.spec.id), + add: addPeers.flatMap(decorationsForPeer), + sort: true, + }); + + this.peers = peers; + + // Dispatch a view update to re-render the decorations. Note + // that peers may change synchronously as a result of local + // text change (via transformation), so we defer the update + // to the next event cycle to make sure we don't dispatch + // update during an existing update + setTimeout(() => { + view.update([]); + }, 0); + }); + } + + destroy() { + this.peersSubscription.destroy(); + } + }, + { decorations: (plugin) => plugin.decorations } +); + +class CursorWidget extends WidgetType { + constructor(cursorPos, color, label) { + super(); + + this.cursorPos = cursorPos; + this.color = color; + this.label = label; + } + + toDOM(view) { + const node = document.createElement("div"); + node.classList.add("cm-peerCursor"); + + const cursorLineNumber = view.state.doc.lineAt(this.cursorPos).number; + if (cursorLineNumber === 1) { + node.classList.add("cm-peerCursor-inline"); + } + + const cursorNode = document.createElement("div"); + cursorNode.classList.add("cm-peerCursorCaret"); + cursorNode.style.backgroundColor = this.color; + + const labelNode = document.createElement("div"); + labelNode.classList.add("cm-peerCursorLabel"); + labelNode.textContent = this.label; + labelNode.style.backgroundColor = this.color; + + node.appendChild(cursorNode); + node.appendChild(labelNode); + + return node; + } + + eq(other) { + return ( + other.cursorPos === this.cursorPos && + other.color === this.color && + other.label === this.label + ); + } +} + +function decorationsForPeer(peer) { + const { + id, + selection, + meta: { hex_color, name }, + } = peer; + + if (!selection) return []; + + const backgroundDecoration = Decoration.mark({ + class: "cm-peerSelection", + attributes: { style: `background-color: ${hex_color}30` }, + id, + }); + + const selectionDecorationRanges = selection.ranges + .filter((selectionRange) => !selectionRange.empty) + .map(({ from, to }) => backgroundDecoration.range(from, to)); + + const cursorDecorationRanges = selection.ranges.map((selectionRange) => { + const cursorPos = selectionRange.head; + + return Decoration.widget({ + widget: new CursorWidget(cursorPos, hex_color, name), + id, + }).range(cursorPos); + }); + + return selectionDecorationRanges.concat(cursorDecorationRanges); +} + +/** + * Returns an extension that adds cursor and selection markers for + * collaborative peers. + */ +export function collabMarkers(collabClient) { + return [ + collabMarkersPlugin, + collabMarkersConfig.of({ collabClient }), + baseTheme, + ]; +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/commands.js b/assets/js/hooks/cell_editor/live_editor/codemirror/commands.js new file mode 100644 index 00000000000..64b0d788b98 --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/commands.js @@ -0,0 +1,14 @@ +/** + * This command, when multi-cursor is active, collapses the selection + * to the main cursor only. + */ +export function exitMulticursor(view) { + const selection = view.state.selection; + + if (selection.ranges.length > 1) { + view.dispatch({ selection: selection.asSingle() }); + return true; + } + + return false; +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/doctests.js b/assets/js/hooks/cell_editor/live_editor/codemirror/doctests.js new file mode 100644 index 00000000000..836c436a94a --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/doctests.js @@ -0,0 +1,173 @@ +import { EditorView, Decoration, WidgetType } from "@codemirror/view"; +import { StateField, StateEffect } from "@codemirror/state"; + +const baseTheme = EditorView.baseTheme({ + ".cm-doctestDetails": { + paddingTop: "4px", + paddingBottom: "4px", + paddingLeft: "6px", + }, + + ".cm-doctestDetailsContent": { + whiteSpace: "pre-wrap", + border: "1px solid #424857", + paddingTop: "6px", + paddingBottom: "6px", + marginLeft: "-8px", + paddingLeft: "8px", + borderRadius: "4px", + marginRight: "16px", + }, + + "&light .cm-doctestDetailsContent": { + borderColor: "#b6b7b9", + }, + + ".cm-doctestStatus": { + position: "relative", + }, + + ".cm-doctestStatus::before": { + borderRadius: "2px", + + width: "10px", + height: "10px", + display: "block", + content: "''", + + position: "absolute", + top: "50%", + left: "0", + transform: "translate(calc(-100% - 6px), -50%)", + }, + + ".cm-doctestStatus-running::before": { + backgroundColor: "#91a4b7", + }, + + ".cm-doctestStatus-success::before": { + backgroundColor: "#4ade80", + }, + + ".cm-doctestStatus-failed::before": { + backgroundColor: "#e97579", + }, +}); + +const updateDoctestsEffect = StateEffect.define(); +const clearDoctestsEffect = StateEffect.define(); + +const doctestsField = StateField.define({ + create(state) { + return Decoration.none; + }, + + update(decorations, tr) { + decorations = decorations.map(tr.changes); + + for (const effect of tr.effects) { + if (effect.is(updateDoctestsEffect)) { + const reports = effect.value; + + decorations = decorations.update({ + filter: (from, to, decoration) => { + return !reports.some( + (report) => decoration.spec.report.line === report.line + ); + }, + add: reports.flatMap((report) => + decorationsForDoctest(report, tr.state.doc) + ), + sort: true, + }); + } + + if (effect.is(clearDoctestsEffect)) { + decorations = Decoration.none; + } + } + + return decorations; + }, + + provide(field) { + return EditorView.decorations.from(field); + }, +}); + +function decorationsForDoctest(report, doc) { + const pos = doc.line(report.line).from + report.column; + + const decorations = [ + Decoration.mark({ + class: `cm-doctestStatus cm-doctestStatus-${report.status}`, + report, + }).range(pos, pos + 1), + ]; + + if (report.status === "failed") { + const detailsLine = doc.line(report.end_line + 1); + + decorations.push( + Decoration.widget({ + widget: new DoctestDetailsWidget(report), + block: true, + report, + }).range(detailsLine.from) + ); + } + + return decorations; +} + +class DoctestDetailsWidget extends WidgetType { + constructor(report) { + super(); + + this.report = report; + } + + toDOM(view) { + const node = document.createElement("div"); + node.classList.add("cm-doctestDetails"); + + const detailsNode = document.createElement("div"); + detailsNode.classList.add("cm-doctestDetailsContent"); + detailsNode.classList.add("editor-theme-aware-ansi"); + node.style.marginLeft = `${this.report.column}ch`; + detailsNode.innerHTML = this.report.details; + node.appendChild(detailsNode); + + return node; + } + + eq(other) { + return this.report === other.report; + } +} + +/** + * Updates doctest decorations based on the given doctest reports. + * + * Note that doctests not present in the reports are kept as is. + */ +export function updateDoctests(view, reports) { + const effects = [updateDoctestsEffect.of(reports)]; + view.dispatch({ effects: maybeEnableDoctests(view.state, effects) }); +} + +/** + * Clears all doctest decorations. + */ +export function clearDoctests(view) { + const effects = [clearDoctestsEffect.of(null)]; + view.dispatch({ effects: maybeEnableDoctests(view.state, effects) }); +} + +const doctestsExtensions = [doctestsField, baseTheme]; + +function maybeEnableDoctests(state, effects) { + return state.field(doctestsField, false) + ? effects + : effects.concat(StateEffect.appendConfig.of(doctestsExtensions)); +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/formatter.js b/assets/js/hooks/cell_editor/live_editor/codemirror/formatter.js new file mode 100644 index 00000000000..c5d82b3602d --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/formatter.js @@ -0,0 +1,114 @@ +import { ViewPlugin, keymap } from "@codemirror/view"; +import { + StateField, + StateEffect, + Facet, + combineConfig, +} from "@codemirror/state"; + +const formatterConfig = Facet.define({ + combine(configs) { + return combineConfig(configs, {}); + }, +}); + +const startFormatEffect = StateEffect.define(); + +const formatterField = StateField.define({ + create() { + return { doc: null }; + }, + + update({ doc }, tr) { + // Whenever the document changes, we reset the state and ignore + // any pending formats + if (tr.docChanged) { + doc = null; + } + + for (const effect of tr.effects) { + if (effect.is(startFormatEffect)) { + doc = tr.state.doc; + } + } + + return { doc }; + }, +}); + +const formatterPlugin = ViewPlugin.fromClass( + class { + constructor(view) { + this.view = view; + this.query = null; + } + + update(update) { + const formatterState = update.state.field(formatterField); + + if (formatterState.doc !== update.startState.field(formatterField).doc) { + this.maybeAbortQuery(); + + if (formatterState.doc) { + this.requestFormatterChanges(update.state); + } + } + } + + destroy() { + this.maybeAbortQuery(); + } + + requestFormatterChanges(state) { + const { doc } = state.field(formatterField); + const { source } = state.facet(formatterConfig); + + const query = { aborted: false }; + + source(doc).then((changes) => { + if (!query.aborted && changes) { + this.view.dispatch({ changes }); + } + }); + + this.query = query; + } + + maybeAbortQuery() { + if (this.query) { + this.query.aborted = true; + this.query = null; + } + } + } +); + +function startFormat(view) { + if (view.state.readOnly) return false; + view.dispatch({ effects: [startFormatEffect.of(null)] }); + return true; +} + +const formatterKeymap = [ + { + key: "Ctrl-Shift-i", + mac: "Alt-Shift-f", + win: "Alt-Shift-f", + run: startFormat, + }, +]; + +/** + * Returns an extension that enables code formatting. + * + * Expects a formatter source, which given the text document will return + * a change spec (or null if there are no changes). + */ +export function formatter(source) { + return [ + formatterField, + formatterPlugin, + formatterConfig.of({ source }), + keymap.of(formatterKeymap), + ]; +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/languages.js b/assets/js/hooks/cell_editor/live_editor/codemirror/languages.js new file mode 100644 index 00000000000..43b2adedbc6 --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/languages.js @@ -0,0 +1,91 @@ +import { + LanguageDescription, + StreamLanguage, + LanguageSupport, +} from "@codemirror/language"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { sql } from "@codemirror/lang-sql"; +import { json } from "@codemirror/lang-json"; +import { xml } from "@codemirror/lang-xml"; +import { css } from "@codemirror/lang-css"; +import { html } from "@codemirror/lang-html"; +import { javascript } from "@codemirror/lang-javascript"; +import { erlang } from "@codemirror/legacy-modes/mode/erlang"; +import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile"; +import { elixir } from "codemirror-lang-elixir"; + +export const elixirDesc = LanguageDescription.of({ + name: "Elixir", + support: elixir(), +}); + +const erlangDesc = LanguageDescription.of({ + name: "Erlang", + support: new LanguageSupport(StreamLanguage.define(erlang)), +}); + +const sqlDesc = LanguageDescription.of({ + name: "SQL", + support: sql(), +}); + +const jsonDesc = LanguageDescription.of({ + name: "JSON", + support: json(), +}); + +const xmlDesc = LanguageDescription.of({ + name: "XML", + support: xml(), +}); + +const cssDesc = LanguageDescription.of({ + name: "CSS", + support: css(), +}); + +const htmlDesc = LanguageDescription.of({ + name: "HTML", + support: html(), +}); + +const javascriptDesc = LanguageDescription.of({ + name: "JavaScript", + support: javascript(), +}); + +const dockerfileDesc = LanguageDescription.of({ + name: "Dockerfile", + support: new LanguageSupport(StreamLanguage.define(dockerFile)), +}); + +const markdownDesc = LanguageDescription.of({ + name: "Markdown", + support: markdown({ + base: markdownLanguage, + codeLanguages: [ + elixirDesc, + erlangDesc, + sqlDesc, + jsonDesc, + xmlDesc, + cssDesc, + htmlDesc, + javascriptDesc, + dockerfileDesc, + ], + }), +}); + +export const languages = [ + elixirDesc, + erlangDesc, + sqlDesc, + jsonDesc, + xmlDesc, + cssDesc, + htmlDesc, + javascriptDesc, + dockerfileDesc, + markdownDesc, +]; diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/read_only_hint.js b/assets/js/hooks/cell_editor/live_editor/codemirror/read_only_hint.js new file mode 100644 index 00000000000..560bcf538e6 --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/read_only_hint.js @@ -0,0 +1,97 @@ +import { EditorView, ViewPlugin, showTooltip, keymap } from "@codemirror/view"; +import { StateField, StateEffect } from "@codemirror/state"; + +const baseTheme = EditorView.baseTheme({ + ".cm-readOnlyHint": { + padding: "4px", + }, +}); + +const showHintEffect = StateEffect.define(); +const hideHintEffect = StateEffect.define(); + +const hintField = StateField.define({ + create() { + return { tooltip: null }; + }, + + update({ tooltip }, tr) { + if (!tr.state.selection.eq(tr.startState.selection)) { + tooltip = null; + } + + for (const effect of tr.effects) { + if (effect.is(showHintEffect)) { + tooltip = { + pos: tr.state.selection.main.head, + above: true, + create: createTooltip, + }; + } + + if (effect.is(hideHintEffect)) { + tooltip = null; + } + } + + return { tooltip }; + }, + + provide(field) { + return showTooltip.from(field, (value) => value.tooltip); + }, +}); + +function createTooltip(view) { + const dom = document.createElement("div"); + dom.classList.add("cm-readOnlyHint"); + dom.textContent = "This editor is read-only"; + return { dom }; +} + +const hintPlugin = ViewPlugin.fromClass( + class { + constructor(view) { + this.view = view; + } + }, + { + eventHandlers: { + input(event) { + if (!this.view.state.readOnly) return; + + if (!this.view.state.field(hintField).tooltip) { + this.view.dispatch({ effects: showHintEffect.of(null) }); + } + }, + + blur(event) { + if (!this.view.state.readOnly) return; + + if (this.view.state.field(hintField).tooltip) { + setTimeout(() => { + // Dispatch state update in the next event cycle (https://github.com/codemirror/dev/issues/1316) + this.view.dispatch({ effects: [hideHintEffect.of(null)] }); + }, 0); + } + }, + }, + } +); + +function closeHint(view) { + const hintState = view.state.field(hintField, false); + if (!hintState || !hintState.tooltip) return false; + view.dispatch({ effects: [hideHintEffect.of(null)] }); + return true; +} + +const hintKeymap = [{ key: "Escape", run: closeHint }]; + +/** + * Returns an extension that shows a tooltip whenever the user tries + * to type and the read-only mode is enabled. + */ +export function readOnlyHint() { + return [hintField, hintPlugin, keymap.of(hintKeymap), baseTheme]; +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/signature.js b/assets/js/hooks/cell_editor/live_editor/codemirror/signature.js new file mode 100644 index 00000000000..82c0bfee635 --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/signature.js @@ -0,0 +1,397 @@ +import { EditorView, ViewPlugin, showTooltip, keymap } from "@codemirror/view"; +import { + StateField, + StateEffect, + Facet, + combineConfig, + Prec, +} from "@codemirror/state"; + +const baseTheme = EditorView.baseTheme({ + ".cm-signatureHint": { + display: "flex", + }, + + ".cm-signatureHintStepper": { + padding: "4px 8px", + borderRight: "1px solid black", + }, + + ".cm-signatureHintContent": { + padding: "4px", + }, + + ".cm-signatureHintActiveArgument": { + color: "gray", + }, +}); + +const signatureConfig = Facet.define({ + combine(configs) { + return combineConfig(configs, {}); + }, +}); + +const characterSetsConfig = Facet.define({}).from( + signatureConfig, + ({ triggerCharacters, retriggerCharacters }) => ({ + triggerCharacters: new Set(triggerCharacters), + // Note: all trigger characters are also retrigger characters + retriggerCharacters: new Set(triggerCharacters.concat(retriggerCharacters)), + }) +); + +const setSignatureResultEffect = StateEffect.define(); +const startSignatureEffect = StateEffect.define(); +const closeSignatureEffect = StateEffect.define(); +const setSelectedEffect = StateEffect.define(); + +const signatureField = StateField.define({ + create() { + return { + context: null, + hint: null, + }; + }, + + update({ context, hint }, tr) { + hint = hint && hint.setPosition(tr.state.selection.main.head); + + const isOpen = !!hint; + + if (shouldRequestSignature(tr, isOpen)) { + context = getSignatureContext(tr.state); + } + + for (const effect of tr.effects) { + if (effect.is(setSignatureResultEffect)) { + if (effect.value.signatureResult) { + hint = SignatureHint.build(tr.state, effect.value.signatureResult); + } else { + context = null; + hint = null; + } + } + + if (effect.is(startSignatureEffect)) { + context = getSignatureContext(tr.state); + } + + if (effect.is(closeSignatureEffect)) { + context = null; + hint = null; + } + + if (effect.is(setSelectedEffect)) { + hint = hint && hint.setSelected(effect.value); + } + } + + return { context, hint }; + }, + + provide(field) { + return showTooltip.from(field, (value) => value.hint && value.hint.tooltip); + }, +}); + +function shouldRequestSignature(tr, isOpen) { + const { activateOnTyping } = tr.state.facet(signatureConfig); + const { triggerCharacters, retriggerCharacters } = + tr.state.facet(characterSetsConfig); + + const startCursorPos = tr.startState.selection.main.head; + const cursorPos = tr.state.selection.main.head; + const isUserInput = tr.isUserEvent("input"); + + if (tr.docChanged) { + let request = false; + + tr.changes.iterChangedRanges((fromA, toA, fromB, toB) => { + if (request) return; + + if (fromA < toA && fromA <= startCursorPos && startCursorPos <= toA) { + const deleted = tr.startState.doc.sliceString(fromA, toA); + + if (isOpen) { + // When open and main cursor deleted any retrigger character + request = request || includesAnyChar(deleted, retriggerCharacters); + } + } + + if (fromB < toB && fromB <= cursorPos && cursorPos <= toB) { + const inserted = tr.state.doc.sliceString(fromB, cursorPos); + + if (isOpen) { + // When open and main cursor inserted any retrigger character + request = request || includesAnyChar(inserted, retriggerCharacters); + } else if (activateOnTyping && isUserInput) { + // When on-typing is enabled and the main cursor inserted any + // trigger character + request = request || includesAnyChar(inserted, triggerCharacters); + } + } + }); + + return request; + } + + if (!tr.docChanged && startCursorPos !== cursorPos) { + const movedOver = tr.state.doc.sliceString( + Math.min(startCursorPos, cursorPos), + Math.max(startCursorPos, cursorPos) + ); + + if (isOpen) { + // When open and the main cursor moved over any retrigger character + return includesAnyChar(movedOver, retriggerCharacters); + } + } + + return false; +} + +function includesAnyChar(string, charsSet) { + for (const char of string) { + if (charsSet.has(char)) { + return true; + } + } + + return false; +} + +function getSignatureContext(state) { + const pos = state.selection.main.head; + return { state, pos }; +} + +class SignatureHint { + constructor(signatureResult, selectedIdx, tooltip) { + this.signatureResult = signatureResult; + this.selectedIdx = selectedIdx; + this.tooltip = tooltip; + } + + static build(state, signatureResult) { + return new SignatureHint(signatureResult, 0, { + pos: state.selection.main.head, + above: true, + create: (view) => new SignatureTooltip(view), + }); + } + + setSelected(selectedIdx) { + return new SignatureHint(this.signatureResult, selectedIdx, this.tooltip); + } + + setPosition(pos) { + if (pos === this.tooltip.pos) return this; + + return new SignatureHint(this.signatureResult, this.selectedIdx, { + ...this.tooltip, + pos: pos, + }); + } +} + +class SignatureTooltip { + constructor(view) { + this.view = view; + + const { signatureResult } = view.state.field(signatureField).hint; + + this.dom = document.createElement("div"); + this.dom.classList.add("cm-signatureHint"); + + if (signatureResult.items.length > 1) { + this.stepper = document.createElement("div"); + this.stepper.classList.add("cm-signatureHintStepper"); + this.dom.appendChild(this.stepper); + } + + const content = document.createElement("div"); + content.classList.add("cm-signatureHintContent"); + + this.contentLeft = document.createElement("span"); + this.contentActive = document.createElement("span"); + this.contentActive.classList.add("cm-signatureHintActiveArgument"); + this.contentRight = document.createElement("span"); + content.appendChild(this.contentLeft); + content.appendChild(this.contentActive); + content.appendChild(this.contentRight); + + this.dom.appendChild(content); + } + + mount() { + this.updateSelected(); + } + + update(update) { + const startSignatureState = update.startState.field(signatureField); + const signatureState = update.state.field(signatureField); + + if (startSignatureState !== signatureState) { + this.updateSelected(); + } + } + + updateSelected() { + const { signatureResult, selectedIdx } = + this.view.state.field(signatureField).hint; + + const { activeArgumentIdx, items } = signatureResult; + + const item = items[selectedIdx]; + const activeArgument = item.arguments[activeArgumentIdx]; + + if (this.stepper) { + this.stepper.textContent = `${selectedIdx + 1}/${items.length}`; + } + + const idx = item.signature.indexOf(activeArgument); + + this.contentLeft.textContent = item.signature.slice(0, idx); + this.contentActive.textContent = activeArgument; + this.contentRight.textContent = item.signature.slice( + idx + activeArgument.length + ); + } +} + +const signaturePlugin = ViewPlugin.fromClass( + class { + constructor(view) { + this.view = view; + this.query = null; + } + + update(update) { + const signatureState = update.state.field(signatureField); + + if ( + signatureState.context !== + update.startState.field(signatureField).context + ) { + this.maybeAbortQuery(); + + if (signatureState.context) { + this.requestSignature(update.state); + } + } + } + + destroy() { + this.maybeAbortQuery(); + } + + requestSignature(state) { + const { context } = state.field(signatureField); + const { source } = state.facet(signatureConfig); + + const query = { aborted: false }; + + source(context).then((signatureResult) => { + if (!query.aborted) { + this.view.dispatch({ + effects: setSignatureResultEffect.of({ signatureResult }), + }); + } + }); + + this.query = query; + } + + maybeAbortQuery() { + if (this.query) { + this.query.aborted = true; + this.query = null; + } + } + }, + { + eventHandlers: { + blur(event) { + let signatureState = this.view.state.field(signatureField, false); + + if ( + signatureState && + signatureState.hint && + this.view.state.facet(signatureConfig).closeOnBlur + ) { + // Dispatch state update in the next event cycle (https://github.com/codemirror/dev/issues/1316) + setTimeout(() => { + this.view.dispatch({ effects: [closeSignatureEffect.of(null)] }); + }, 0); + } + }, + }, + } +); + +function startSignature(view) { + view.dispatch({ effects: [startSignatureEffect.of(null)] }); + return true; +} + +function closeSignature(view) { + const signatureState = view.state.field(signatureField, false); + if (!signatureState || !signatureState.hint) return false; + view.dispatch({ effects: [closeSignatureEffect.of(null)] }); + return true; +} + +const moveSignatureSelection = (forward) => { + return (view) => { + const signatureState = view.state.field(signatureField, false); + if (!signatureState || !signatureState.hint) return false; + + const { signatureResult, selectedIdx } = signatureState.hint; + if (signatureResult.items.length === 1) return false; + const length = signatureResult.items.length; + + let newSelectedIdx = selectedIdx + (forward ? 1 : -1); + if (newSelectedIdx < 0) newSelectedIdx += length; + if (newSelectedIdx >= length) newSelectedIdx -= length; + + view.dispatch({ effects: [setSelectedEffect.of(newSelectedIdx)] }); + + return true; + }; +}; + +const signatureKeymap = [ + { key: "Mod-Shift-Space", run: startSignature }, + { key: "Escape", run: closeSignature }, + { key: "ArrowDown", run: moveSignatureSelection(true) }, + { key: "ArrowUp", run: moveSignatureSelection(false) }, +]; + +/** + * Returns an extension that enables signature hints. + */ +export function signature( + source, + { + activateOnTyping = true, + closeOnBlur = true, + triggerCharacters = ["(", ","], + retriggerCharacters = [")"], + } = {} +) { + return [ + signatureField, + signaturePlugin, + signatureConfig.of({ + source, + activateOnTyping, + closeOnBlur, + triggerCharacters, + retriggerCharacters, + }), + characterSetsConfig, + Prec.high(keymap.of(signatureKeymap)), + baseTheme, + ]; +} diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/theme.js b/assets/js/hooks/cell_editor/live_editor/codemirror/theme.js new file mode 100644 index 00000000000..c43521cf7e4 --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/theme.js @@ -0,0 +1,514 @@ +import { EditorView } from "@codemirror/view"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { tags as t } from "@lezer/highlight"; + +// Most of the colors for the editor widgets are based on the One Dark +// theme in the Zed editor. The highlighting colors are based on the +// Atom One Dark theme from VS Code, since they are more contrasting. +// The comment color is brightened for AA accessibility. + +function buildEditorTheme(colors, { dark }) { + const fonts = { + sans: "Inter", + mono: "JetBrains Mono, monospace", + }; + + return EditorView.theme( + { + "&": { + color: colors.text, + backgroundColor: colors.background, + fontSize: "14px", + fontFamily: fonts.mono, + fontVariantLigatures: "none", + }, + + "&.cm-focused": { + outline: "none", + }, + + ".cm-scroller": { + fontFamily: "inherit", + }, + + ".cm-content": { + caretColor: colors.cursor, + padding: "0", + }, + + // Scroll + + "*": { + "&::-webkit-scrollbar": { + width: "8px", + height: "8px", + }, + + "&::-webkit-scrollbar-thumb": { + borderRadius: "4px", + background: "transparent", + }, + + "&:hover::-webkit-scrollbar-thumb": { + background: colors.backgroundLightest, + }, + + "&::-webkit-scrollbar-track": { + background: "transparent", + }, + }, + + // Cursor and selection + + ".cm-activeLine": { + backgroundColor: "transparent", + }, + + ".cm-cursor, .cm-dropCursor": { + borderLeft: "1px solid", + borderRight: "1px solid", + marginLeft: "-1px", + marginRight: "-1px", + borderColor: colors.cursor, + }, + + ".cm-selectionBackground": { + backgroundColor: colors.inactiveSelectionBackground, + }, + + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": + { + backgroundColor: colors.selectionBackground, + }, + + ".cm-selectionMatch": { + backgroundColor: colors.selectionMatchBackground, + }, + + // Base components + + ".cm-gutters": { + backgroundColor: colors.background, + color: colors.gutterText, + border: "none", + }, + + ".cm-gutters .cm-activeLineGutter": { + backgroundColor: "transparent", + }, + + ".cm-tooltip": { + backgroundColor: colors.backgroundLighter, + boxShadow: "0 2px 6px 0 rgba(0, 0, 0, 0.2)", + color: colors.text, + borderRadius: "8px", + border: `1px solid ${colors.border}`, + + "& .cm-tooltip-arrow::before": { + borderTopColor: colors.backgroundLighter, + borderBottomColor: colors.backgroundLighter, + }, + }, + + ".cm-panels": { + backgroundColor: colors.background, + color: colors.text, + + "&.cm-panels-top": { + borderBottom: `2px solid ${colors.separator}`, + }, + + "&.cm-panels-bottom": { + borderTop: `2px solid ${colors.separator}`, + }, + }, + + // Line numbers + + ".cm-gutter.cm-lineNumbers": { + "& .cm-gutterElement": { + color: colors.lineNumber, + whiteSpace: "pre", + + "&.cm-activeLineGutter": { + color: colors.lineNumberActive, + }, + }, + }, + + // Folding + + ".cm-gutter.cm-foldGutter": { + "& .cm-gutterElement": { + cursor: "pointer", + }, + + "&:not(:hover) .cm-gutterElement > .cm-gutterFoldMarker-open": { + visibility: "hidden", + }, + }, + + ".cm-foldPlaceholder": { + backgroundColor: "transparent", + border: "none", + color: "unset", + borderRadius: "2px", + "&:hover": { + backgroundColor: colors.selectionBackground, + }, + }, + + // Search + + ".cm-searchMatch": { + backgroundColor: colors.searchMatchBackground, + + "&.cm-searchMatch-selected": { + backgroundColor: colors.searchMatchActiveBackground, + }, + }, + + // Brackets + + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + backgroundColor: colors.selectionBackground, + }, + + // Completion + + ".cm-tooltip.cm-tooltip-autocomplete": { + padding: "4px", + backgroundColor: colors.backgroundLighter, + color: colors.text, + + "& > ul": { + fontFamily: "inherit", + + "& > li": { + padding: "4px", + borderRadius: "4px", + display: "flex", + alignItems: "center", + + "&[aria-selected]": { + background: "none", + backgroundColor: colors.backgroundLightest, + color: "unset", + }, + + "& .cm-completionIcon": { + lineHeight: "1", + + "&:after": { fontVariantLigatures: "normal" }, + "&.cm-completionIcon-function:after": { content: "'ƒ'" }, + "&.cm-completionIcon-module:after": { content: "'m'" }, + "&.cm-completionIcon-struct:after": { content: "'⭘'" }, + "&.cm-completionIcon-interface:after": { content: "'*'" }, + "&.cm-completionIcon-type:after": { content: "'t'" }, + "&.cm-completionIcon-variable:after": { content: "'𝑥'" }, + "&.cm-completionIcon-field:after": { content: "'•'" }, + "&.cm-completionIcon-keyword:after": { content: "'⚡'" }, + }, + }, + }, + + "& .cm-completionMatchedText": { + textDecoration: "none", + color: colors.matchingText, + }, + }, + + ".cm-tooltip.cm-completionInfo": { + borderRadius: "8px", + backgroundColor: colors.backgroundLighter, + color: colors.text, + top: "0 !important", + padding: "0", + + "&.cm-completionInfo-right": { + marginLeft: "4px", + }, + + "&.cm-completionInfo-left": { + marginRight: "4px", + }, + + "& .cm-completionInfoDetail": { + padding: "6px", + }, + + "& .cm-completionInfoDocs": { + borderTop: `1px solid ${colors.separator}`, + padding: "6px", + }, + }, + + // Hover docs + + ".cm-tooltip .cm-hoverDocs": { + maxWidth: "800px", + maxHeight: "300px", + overflowY: "auto", + padding: "8px", + display: "flex", + flexDirection: "column", + gap: "64px", + }, + + // Signature + + ".cm-tooltip.cm-signatureHint": { + "& .cm-signatureHintStepper": { + borderColor: colors.separator, + }, + + "& .cm-signatureHintActiveArgument": { + color: colors.matchingText, + }, + }, + + // Search + // + // It is possible to build a fully custom panel and hook into the + // search actions, but search is rarely useful, since the notebook + // is broken into cells, so we just do some basic styling + + ".cm-panel.cm-search": { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + padding: "8px 8px 0 8px", + + "& br": { + content: '" "', + display: "block", + width: "100%", + }, + + "& .cm-textfield": { + borderRadius: "4px", + border: `1px solid ${colors.border}`, + }, + + "& .cm-button": { + borderRadius: "4px", + background: colors.backgroundLightest, + border: "none", + }, + + "& label:first-of-type": { + marginLeft: "8px", + }, + + "& label": { + display: "inline-flex", + alignItems: "center", + gap: "4px", + }, + + "& label input": { + appearance: "none", + border: `1px solid ${colors.backgroundLightest}`, + borderRadius: "4px", + width: "18px", + height: "18px", + + "&:checked": { + backgroundImage: `url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")`, + backgroundColor: colors.backgroundLightest, + borderColor: "transparent", + }, + }, + + '& button[name="close"]': { + display: "none", + }, + }, + + // Markdown + + ".cm-markdown": { + "&": { + fontFamily: fonts.sans, + }, + + "& p": { + marginTop: "0.5rem", + marginBottom: "0.5rem", + }, + + "& hr": { + borderTop: `1px solid ${colors.separator}`, + marginTop: "0.5rem", + marginBottom: "0.5rem", + marginRight: "-6px", + marginLeft: "-6px", + }, + + "& a": { + borderBottom: `1px solid ${colors.text}`, + }, + + "& h2": { + marginTop: "0.5rem", + marginBottom: "0.5rem", + fontSize: "1.125em", + fontWeight: "600", + }, + + "& code": { + background: colors.selectionBackground, + borderRadius: "3px", + }, + + "& pre code": { + background: "transparent", + }, + + "& > :first-child": { + marginTop: "0", + }, + + "& > :last-child": { + marginBottom: "0", + }, + }, + }, + { dark } + ); +} + +function buildHighlightStyle({ + base, + lightRed, + blue, + gray, + green, + purple, + red, + teal, + peach, + yellow, +}) { + return HighlightStyle.define([ + { tag: t.keyword, color: purple }, + { tag: t.null, color: blue }, + { tag: t.bool, color: blue }, + { tag: t.number, color: blue }, + { tag: t.string, color: green }, + { tag: t.special(t.string), color: yellow }, + { tag: t.character, color: blue }, + { tag: t.escape, color: blue }, + { tag: t.atom, color: blue }, + { tag: t.variableName, color: base }, + { tag: t.special(t.variableName), color: lightRed }, + { + tag: [t.function(t.variableName), t.function(t.propertyName)], + color: blue, + }, + { tag: t.namespace, color: teal }, + { tag: t.operator, color: peach }, + { tag: t.comment, color: gray }, + { tag: [t.docString, t.docComment], color: gray }, + { + tag: [t.paren, t.squareBracket, t.brace, t.angleBracket, t.separator], + color: base, + }, + { tag: t.special(t.brace), color: red }, + + // Markdown specific + { tag: t.strong, fontWeight: "bold" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.link, color: blue }, + { tag: t.heading, color: lightRed }, + { tag: t.monospace, color: green }, + { tag: t.name, color: purple }, + + // JSON specific + { tag: t.propertyName, color: lightRed }, + + // HTML specific + { tag: t.tagName, color: purple }, + + // CSS specific + { tag: t.className, color: peach }, + ]); +} + +const editorTheme = buildEditorTheme( + { + text: "#c8ccd4", + background: "#282c34", + backgroundLighter: "#2f343e", + backgroundLightest: "#454a56", + border: "#363c46", + cursor: "#73ade8", + selectionBackground: "#394c5f", + inactiveSelectionBackground: "#29333d", + selectionMatchBackground: "#343f4d", + gutterText: "#c8ccd4", + lineNumber: "#60646c", + lineNumberActive: "#c8ccd4", + matchingText: "#73ade8", + searchMatchBackground: "#4c6582", + searchMatchActiveBackground: "#54789e", + separator: "#464b57", + }, + { dark: true } +); + +export const highlightStyle = buildHighlightStyle({ + base: "#c8ccd4", + lightRed: "#e06c75", + blue: "#61afef", + gray: "#8c92a3", + green: "#98c379", + purple: "#c678dd", + red: "#be5046", + teal: "#56b6c2", + peach: "#d19a66", + yellow: "#e5c07b", +}); + +export const theme = [editorTheme, syntaxHighlighting(highlightStyle)]; + +const lightEditorTheme = buildEditorTheme( + { + text: "#383a41", + background: "#fafafa", + backgroundLighter: "#ebebec", + backgroundLightest: "#cacaca", + border: "#dfdfe0", + cursor: "#5c79e2", + selectionBackground: "#d4dbf4", + inactiveSelectionBackground: "#ebeef9", + selectionMatchBackground: "#d3d5e1", + gutterText: "#383a41", + lineNumber: "#b6b7b9", + lineNumberActive: "#383a41", + matchingText: "#73ade8", + searchMatchBackground: "#bbc6f1", + searchMatchActiveBackground: "#9daeec", + separator: "#c9c9ca", + }, + { dark: false } +); + +export const lightHighlightStyle = buildHighlightStyle({ + base: "#304254", + lightRed: "#e45649", + blue: "#4078F2", + gray: "#707177", + green: "#50a14f", + purple: "#a726a4", + red: "#ca1243", + teal: "#0084bc", + peach: "#986801", + yellow: "#c18401", +}); + +export const lightTheme = [ + lightEditorTheme, + syntaxHighlighting(lightHighlightStyle), +]; diff --git a/assets/js/hooks/cell_editor/live_editor/collab_client.js b/assets/js/hooks/cell_editor/live_editor/collab_client.js new file mode 100644 index 00000000000..d8138739e0b --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/collab_client.js @@ -0,0 +1,326 @@ +import Emitter from "../../../lib/emitter"; +import { pop } from "../../../lib/utils"; +import { transformSelection } from "./codemirror/collab"; + +const REVISION_REPORT_TIMEOUT_MS = 5000; + +/** + * Collaborative editing client. + * + * The client subscribes to collaborative updates and exposes APIs for + * an editor to receive and push local changes. Note that updates are + * consumed regardless of whether there is an actual editor attached + * to this client. This makes it possible to mount an editor at any + * later point without getting out of sync, while keeping track of the + * current source (for example, to feed markdown renderer). + * + * If the client receives deltas, but does not produce any events on + * its own, it periodically reports the current revision to the server, + * so that the server can free up the deltas it keeps. This is another + * aspect that makes it important to keep the fully operational client + * separate from the editor plugin. + * + * ## Synchronization flow + * + * When the local editor emits a change, it is converted to a delta, + * and notified to the client. The clients sends it to the server + * and waits for an acknowledgement. Until the acknowledgement comes, + * the client keeps all further edits in a buffer. The server may send + * either an acknowledgement or other client's delta. It's important + * to note that the messages come in what the server believes is + * chronological order, so any delta received before the acknowledgement + * should be treated as if it happened before our unacknowledged delta. + * Other client's delta is transformed against the local unacknowledged + * deltas and applied to the editor. + */ +export default class CollabClient { + /** @private */ + _onDelta = new Emitter(); + + /** + * Registers a callback called with a new delta is emitted, either + * by the client or the server. + * + * The deltas are transformed, such that applying them one by one + * keeps the document in sync. + */ + onDelta = this._onDelta.event; + + /** @private */ + _onPeersChange = new Emitter(); + + /** + * Registers a callback called whenever the clients change. + * + * Clients may change as a result of join, leave, details update or + * a new selection. + */ + onPeersChange = this._onPeersChange.event; + + constructor(connection, revision) { + this.connection = connection; + this.revision = revision; + + this.clientId = connection.getClientId(); + + this.peers = {}; + this.updatePeers(connection.getClients()); + + this.inflightDelta = null; + this.bufferDelta = null; + this.selection = null; + this.selectionChanged = false; + this.revisionReportTimeoutId = null; + + this.subscriptions = [ + connection.onDelta(this.handleServerDelta.bind(this)), + connection.onAcknowledgement(this.handleServerAcknowledgement.bind(this)), + connection.onSelection(this.handleServerSelection.bind(this)), + connection.onClientsUpdate(this.handleServerClientsUpdate.bind(this)), + ]; + } + + destroy() { + this.subscriptions.forEach((subscription) => subscription.destroy()); + } + + /** + * Returns the map with currently connected peers. + */ + getPeers() { + return this.peers; + } + + /** + * Sends a local delta to the server or puts it in the queue. + * + * Should be called by the editor, whenever the content is changed + * by the user. + */ + handleClientDelta(delta, selection) { + this.peers = transformPeerSelections(this.peers, delta); + + this.selection = selection; + + if (!this.inflightDelta) { + this.inflightDelta = delta; + this.sendDelta(); + } else if (!this.bufferDelta) { + this.bufferDelta = delta; + } else { + this.bufferDelta = this.bufferDelta.compose(delta); + } + + this._onDelta.dispatch(delta, { remote: false }); + this._onPeersChange.dispatch(this.peers); + } + + /** + * Sends a local selection to the server or puts it in the queue. + * + * Should be called by the editor, whenever the current selection + * changes. + */ + handleClientSelection(selection) { + this.selection = selection; + + if (!this.inflightDelta) { + this.sendSelection(); + } else { + this.selectionChanged = true; + } + } + + /** @private */ + handleServerDelta(delta, selection, clientId) { + this.revision++; + + // The server dictates the order of the deltas, so we consider the + // incoming delta to have happened first + + let { inflightDelta, bufferDelta } = this; + + if (inflightDelta) { + [delta, inflightDelta] = [ + inflightDelta.transform(delta, "right"), + delta.transform(inflightDelta, "left"), + ]; + + selection = selection && transformSelection(selection, inflightDelta); + } + + if (bufferDelta) { + [delta, bufferDelta] = [ + bufferDelta.transform(delta, "right"), + delta.transform(bufferDelta, "left"), + ]; + + selection = selection && transformSelection(selection, bufferDelta); + } + + this.inflightDelta = inflightDelta; + this.bufferDelta = bufferDelta; + + this.selection = + this.selection && transformSelection(this.selection, delta); + + let [peer, peers] = pop(this.peers, clientId); + peers = transformPeerSelections(peers, delta); + + if (peer) { + peers[clientId] = new Peer(peer.id, peer.meta, selection); + } + + this.peers = peers; + + this._onDelta.dispatch(delta, { remote: true }); + this._onPeersChange.dispatch(this.peers); + + // The client received a new delta, so we schedule a request to + // report the revision, unless the client emits a message soon + this.maybeScheduleRevisionReport(); + } + + /** @private */ + handleServerAcknowledgement() { + this.revision++; + + this.inflightDelta = null; + + if (this.bufferDelta) { + this.inflightDelta = this.bufferDelta; + this.bufferDelta = null; + this.sendDelta(); + } + } + + /** @private */ + handleServerSelection(selection, clientId) { + if (!this.peers.hasOwnProperty(clientId)) return; + + let { inflightDelta, bufferDelta } = this; + + if (inflightDelta) { + selection = selection && transformSelection(selection, inflightDelta); + } + + if (bufferDelta) { + selection = selection && transformSelection(selection, bufferDelta); + } + + const peer = this.peers[clientId]; + + this.peers = { + ...this.peers, + [clientId]: new Peer(peer.id, peer.meta, selection), + }; + + this._onPeersChange.dispatch(this.peers); + } + + /** @private */ + handleServerClientsUpdate(clients) { + this.updatePeers(clients); + this._onPeersChange.dispatch(this.peers); + } + + /** @private */ + sendDelta() { + this.connection.sendDelta( + this.inflightDelta, + this.selection, + this.revision + ); + + this.selectionChanged = false; + + // Cancel the revision report if scheduled, since the client is + // has just sent the revision along with the delta + this.maybeCancelRevisionReport(); + } + + /** @private */ + sendSelection() { + // Only send the selection if there are some peers + if (Object.keys(this.peers).length > 0) { + this.connection.sendSelection(this.selection, this.revision); + } + + this.selectionChanged = false; + } + + /** @private */ + sendRevision() { + this.connection.sendRevision(this.revision); + } + + /** @private */ + updatePeers(clients) { + const peers = {}; + + for (const clientId in clients) { + if (clientId !== this.clientId) { + const currentPeer = this.peers[clientId]; + const meta = clients[clientId]; + const selection = currentPeer ? currentPeer.selection : null; + peers[clientId] = new Peer(clientId, meta, selection); + } + } + + this.peers = peers; + } + + /** @private */ + maybeScheduleRevisionReport() { + if (!this.inflightDelta && !this.revisionReportTimeoutId) { + this.revisionReportTimeoutId = setTimeout(() => { + this.sendRevision(); + this.revisionReportTimeoutId = null; + }, REVISION_REPORT_TIMEOUT_MS); + } + } + + /** @private */ + maybeCancelRevisionReport() { + if (this.revisionReportTimeoutId !== null) { + clearTimeout(this.revisionReportTimeoutId); + this.revisionReportTimeoutId = null; + } + } +} + +/** + * Holds information about a collaborative peer, including their + * selection and details. + */ +class Peer { + constructor(id, meta, selection) { + this.id = id; + this.meta = meta; + this.selection = selection; + } + + eq(other) { + return ( + this.id === other.id && + this.meta === other.meta && + (this.selection === other.selection || + (this.selection && + other.selection && + this.selection.eq(other.selection))) + ); + } +} + +function transformPeerSelections(peers, delta) { + const newPeers = {}; + + for (const clientId in peers) { + const peer = peers[clientId]; + const selection = + peer.selection && transformSelection(peer.selection, delta); + newPeers[clientId] = new Peer(peer.id, peer.meta, selection); + } + + return newPeers; +} diff --git a/assets/js/hooks/cell_editor/live_editor/connection.js b/assets/js/hooks/cell_editor/live_editor/connection.js new file mode 100644 index 00000000000..82a24b64dbe --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/connection.js @@ -0,0 +1,230 @@ +import Delta from "../../../lib/delta"; +import { EditorSelection } from "@codemirror/state"; +import { LiveStore } from "../../../lib/live_store"; +import Emitter from "../../../lib/emitter"; + +/** + * Encapsulates the editor communication with the server. + * + * Uses the given hook instance socket for the communication. + */ +export default class Connection { + /** @private */ + _onDelta = new Emitter(); + + /** + * Registers a callback called whenever a new delta comes from the server. + */ + onDelta = this._onDelta.event; + + /** @private */ + _onAcknowledgement = new Emitter(); + + /** + * Registers a callback called when delta acknowledgement comes from the server. + */ + onAcknowledgement = this._onAcknowledgement.event; + + /** @private */ + _onSelection = new Emitter(); + + /** + * Registers a callback called whenever a new selection report comes from the server. + */ + onSelection = this._onSelection.event; + + /** @private */ + _onClientsUpdate = new Emitter(); + + /** + * Registers a callback called whenever any remote client changes. + * + * The change may be a result of a client leaving, joining or updating + * their details. + */ + onClientsUpdate = this._onClientsUpdate.event; + + constructor(hook, cellId, tag) { + this.hook = hook; + this.cellId = cellId; + this.tag = tag; + + this.sessionStore = LiveStore.getStore("session"); + this.handlerByRef = {}; + + this.setupCollaborationHandlers(); + + this.clientsSubscription = this.sessionStore.watch("clients", (clients) => { + this._onClientsUpdate.dispatch(clients); + }); + + this.setupIntellisenseHandlers(); + } + + destroy() { + this.clientsSubscription.destroy(); + } + + /** + * Returns the list of clients currently connected to the session. + */ + getClients() { + return this.sessionStore.get("clients"); + } + + /** + * Returns the ide of this particular client. + */ + getClientId() { + return this.sessionStore.get("clientId"); + } + + /** + * Sends the given delta to the server. + */ + sendDelta(delta, selection, revision) { + this.hook.pushEvent("apply_cell_delta", { + cell_id: this.cellId, + tag: this.tag, + delta: delta.toCompressed(), + selection: selection && selectionToCompressed(selection), + revision, + }); + } + + /** + * Sends the current client selection to the server along with the + * current revision it is at. + */ + sendSelection(selection, revision) { + this.hook.pushEvent("report_cell_selection", { + cell_id: this.cellId, + tag: this.tag, + selection: selection && selectionToCompressed(selection), + revision, + }); + } + + /** + * Sends an information to the server that the client is at the + * specified revision. + * + * This should be invoked if the client received updates, but is + * not itself sending any delta at the moment. By sending this + * messages we make sure the server doesn't accumulate a huge list + * of deltas unnecessarily. + */ + sendRevision(revision) { + this.hook.pushEvent("report_cell_revision", { + cell_id: this.cellId, + tag: this.tag, + revision, + }); + } + + /** @private */ + setupCollaborationHandlers() { + this.hook.handleEvent( + `cell_delta:${this.cellId}:${this.tag}`, + ({ delta, selection, client_id }) => { + delta = Delta.fromCompressed(delta); + selection = selection && selectionFromCompressed(selection); + this._onDelta.dispatch(delta, selection, client_id); + } + ); + + this.hook.handleEvent( + `cell_acknowledgement:${this.cellId}:${this.tag}`, + () => { + this._onAcknowledgement.dispatch(); + } + ); + + this.hook.handleEvent( + `cell_selection:${this.cellId}:${this.tag}`, + ({ selection, client_id }) => { + selection = selection && selectionFromCompressed(selection); + this._onSelection.dispatch(selection, client_id); + } + ); + } + + /** @private */ + setupIntellisenseHandlers() { + // Intellisense requests such as completion or formatting are + // handled asynchronously by the runtime. The request happens in + // two steps: + // + // * we send an intellisense request to the LV process and get + // a unique reference, under which we store a callback + // + // * once we receive the "intellisense_response" event from the + // server, we look up the callback for that reference to + // either resolve or reject the promise that was returned to + // the caller + + this.hook.handleEvent("intellisense_response", ({ ref, response }) => { + const handler = this.handlerByRef[ref]; + + if (handler) { + handler(response); + delete this.handlerByRef[ref]; + } + }); + } + + /** + * Makes an intellisense request. + * + * The returned promise is either resolved with a valid response or + * rejected with an intellisense error. + */ + intellisenseRequest(type, props) { + return new Promise((resolve, reject) => { + this.hook.pushEvent( + "intellisense_request", + { cell_id: this.cellId, type, ...props }, + ({ ref }) => { + if (ref) { + this.handlerByRef[ref] = (response) => { + if (response) { + resolve(response); + } else { + reject( + new IntellisenseError( + "No relevant intellisense response for the given parameters" + ) + ); + } + }; + } else { + reject( + new IntellisenseError( + "Intellisense request could not be completed" + ) + ); + } + } + ); + }); + } +} + +function selectionToCompressed(selection) { + return selection.ranges.map(({ anchor, head }) => [anchor, head]); +} + +function selectionFromCompressed(list) { + const ranges = list.map(([anchor, head]) => + EditorSelection.range(anchor, head) + ); + + return EditorSelection.create(ranges); +} + +class IntellisenseError extends Error { + constructor(message) { + super(message); + this.name = "IntellisenseError"; + } +} diff --git a/assets/js/hooks/cell_editor/live_editor/doctest.js b/assets/js/hooks/cell_editor/live_editor/doctest.js deleted file mode 100644 index 60e4232535a..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/doctest.js +++ /dev/null @@ -1,136 +0,0 @@ -import monaco from "./monaco"; - -/** - * Doctest visual indicators within the editor. - * - * Consists of a status widget and optional error details. - */ -export default class Doctest { - constructor(editor, doctestReport) { - this._editor = editor; - - this._statusDecoration = new StatusDecoration( - editor, - doctestReport.line, - doctestReport.status - ); - - if (doctestReport.status === "failed") { - this._detailsWidget = new DetailsWidget(editor, doctestReport); - } - } - - /** - * Updates doctest indicator. - */ - update(doctestReport) { - this._statusDecoration.update(doctestReport.status); - - if (doctestReport.status === "failed") { - this._detailsWidget && this._detailsWidget.dispose(); - this._detailsWidget = new DetailsWidget(this._editor, doctestReport); - } - } - - /** - * Performs necessary cleanup actions. - */ - dispose() { - this._statusDecoration.dispose(); - this._detailsWidget && this._detailsWidget.dispose(); - } -} - -class StatusDecoration { - constructor(editor, lineNumber, status) { - this._editor = editor; - this._lineNumber = lineNumber; - this._decorations = []; - - this.update(status); - } - - update(status) { - const newDecorations = [ - { - range: new monaco.Range(this._lineNumber, 1, this._lineNumber, 1), - options: { - isWholeLine: true, - linesDecorationsClassName: `doctest-status-decoration-${status}`, - }, - }, - ]; - - this._decorations = this._editor.deltaDecorations( - this._decorations, - newDecorations - ); - } - - dispose() { - this._editor.deltaDecorations(this._decorations, []); - } -} - -class DetailsWidget { - constructor(editor, doctestReport) { - this._editor = editor; - - const { line, end_line, details, column } = doctestReport; - const detailsHtml = details.join("\n"); - const numberOfLines = details.length; - - const fontSize = this._editor.getOption( - monaco.editor.EditorOption.fontSize - ); - - const lineHeight = this._editor.getOption( - monaco.editor.EditorOption.lineHeight - ); - - const detailsNode = document.createElement("div"); - detailsNode.innerHTML = detailsHtml; - detailsNode.classList.add( - "doctest-details-widget", - "editor-theme-aware-ansi" - ); - detailsNode.style.fontSize = `${fontSize}px`; - detailsNode.style.lineHeight = `${lineHeight}px`; - - this._overlayWidget = { - getId: () => `livebook.doctest.overlay.${line}`, - getDomNode: () => detailsNode, - getPosition: () => null, - }; - - this._editor.addOverlayWidget(this._overlayWidget); - - this._editor.changeViewZones((changeAccessor) => { - this._viewZone = changeAccessor.addZone({ - afterLineNumber: end_line, - // Placeholder for all lines and additional padding - heightInPx: numberOfLines * lineHeight + 12, - domNode: document.createElement("div"), - onDomNodeTop: (top) => { - detailsNode.style.top = `${top}px`; - - const marginWidth = this._editor - .getDomNode() - .querySelector(".margin-view-overlays").offsetWidth; - - detailsNode.style.paddingLeft = `calc(${marginWidth}px + ${column}ch)`; - }, - onComputedHeight: (height) => { - detailsNode.style.height = `${height}px`; - }, - }); - }); - } - - dispose() { - this._editor.removeOverlayWidget(this._overlayWidget); - this._editor.changeViewZones((changeAccessor) => { - changeAccessor.removeZone(this._viewZone); - }); - } -} diff --git a/assets/js/hooks/cell_editor/live_editor/editor_client.js b/assets/js/hooks/cell_editor/live_editor/editor_client.js deleted file mode 100644 index 0bec2275d83..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/editor_client.js +++ /dev/null @@ -1,207 +0,0 @@ -/** - * A manager associated with a particular editor instance, - * which is responsible for controlling client-server communication - * and synchronizing the sent/received changes. - * - * This class uses `serverAdapter` and `editorAdapter` objects - * that encapsulate the logic relevant for each part. - * - * ## Changes synchronization - * - * When the local editor emits a change (represented as delta), - * the client sends this delta to the server and waits for an acknowledgement. - * Until the acknowledgement comes, the client keeps all further - * edits in buffer. - * The server may send either an acknowledgement or other client's delta. - * It's important to note that those messages come in what the server - * believes is chronological order, so any delta received before - * the acknowledgement should be treated as if it happened before - * our unacknowledged delta. - * Other client's delta is transformed against the local unacknowledged - * deltas and applied to the editor. - */ -export default class EditorClient { - constructor(serverAdapter, revision) { - this.serverAdapter = serverAdapter; - this.revision = revision; - this.state = new Synchronized(this); - this._onDelta = null; - - this.serverAdapter.onDelta((delta) => { - this._handleServerDelta(delta); - }); - - this.serverAdapter.onAcknowledgement(() => { - this._handleServerAcknowledgement(); - }); - } - - /** - * Plugs in the editor adapter. - * - * The adapter may be set at a later point after initialization, in - * case the editor is mounted lazily. - */ - setEditorAdapter(editorAdapter) { - this.editorAdapter = editorAdapter; - - this.editorAdapter.onDelta((delta) => { - this._handleClientDelta(delta); - // This delta comes from the editor, so it has already been applied. - this._emitDelta(delta); - }); - } - - /** - * Registers a callback called with a every delta applied to the editor. - * - * These deltas are already transformed such that applying them - * one by one should eventually lead to the same state as on the server. - */ - onDelta(callback) { - this._onDelta = callback; - } - - _emitDelta(delta) { - this._onDelta && this._onDelta(delta); - } - - _handleClientDelta(delta) { - this.state = this.state.onClientDelta(delta); - } - - _handleServerDelta(delta) { - this.revision++; - this.state = this.state.onServerDelta(delta); - } - - _handleServerAcknowledgement() { - this.revision++; - this.state = this.state.onServerAcknowledgement(); - } - - applyDelta(delta) { - this.editorAdapter && this.editorAdapter.applyDelta(delta); - // This delta comes from the server and we have just applied it to the editor. - this._emitDelta(delta); - } - - sendDelta(delta) { - this.serverAdapter.sendDelta(delta, this.revision + 1); - } - - reportCurrentRevision() { - this.serverAdapter.reportRevision(this.revision); - } -} - -/** - * Client is in this state when there is no delta pending acknowledgement - * (the client is fully in sync with the server). - */ -class Synchronized { - constructor(client, reportRevisionTimeout = 5000) { - this.client = client; - this.reportRevisionTimeoutId = null; - this.reportRevisionTimeout = reportRevisionTimeout; - } - - onClientDelta(delta) { - // Cancel the report request if scheduled, - // as the client is about to send the revision - // along with own delta. - if (this.reportRevisionTimeoutId !== null) { - clearTimeout(this.reportRevisionTimeoutId); - this.reportRevisionTimeoutId = null; - } - - this.client.sendDelta(delta); - return new AwaitingAcknowledgement(this.client, delta); - } - - onServerDelta(delta) { - this.client.applyDelta(delta); - - // The client received a new delta, so let's schedule - // a request to report the new revision. - if (this.reportRevisionTimeoutId === null) { - this.reportRevisionTimeoutId = setTimeout(() => { - this.client.reportCurrentRevision(); - this.reportRevisionTimeoutId = null; - }, this.reportRevisionTimeout); - } - - return this; - } - - onServerAcknowledgement() { - throw new Error("Unexpected server acknowledgement."); - } -} - -/** - * Client is in this state when the client sent one delta and waits - * for an acknowledgement, while there are no other deltas in a buffer. - */ -class AwaitingAcknowledgement { - constructor(client, awaitedDelta) { - this.client = client; - this.awaitedDelta = awaitedDelta; - } - - onClientDelta(delta) { - return new AwaitingWithBuffer(this.client, this.awaitedDelta, delta); - } - - onServerDelta(delta) { - // We consider the incoming delta to happen first - // (because that's the case from the server's perspective). - const deltaPrime = this.awaitedDelta.transform(delta, "right"); - this.client.applyDelta(deltaPrime); - const awaitedDeltaPrime = delta.transform(this.awaitedDelta, "left"); - return new AwaitingAcknowledgement(this.client, awaitedDeltaPrime); - } - - onServerAcknowledgement() { - return new Synchronized(this.client); - } -} - -/** - * Client is in this state when the client sent one delta and waits - * for an acknowledgement, while there are more deltas in a buffer. - */ -class AwaitingWithBuffer { - constructor(client, awaitedDelta, buffer) { - this.client = client; - this.awaitedDelta = awaitedDelta; - this.buffer = buffer; - } - - onClientDelta(delta) { - const newBuffer = this.buffer.compose(delta); - return new AwaitingWithBuffer(this.client, this.awaitedDelta, newBuffer); - } - - onServerDelta(delta) { - // We consider the incoming delta to happen first - // (because that's the case from the server's perspective). - - // Delta transformed against awaitedDelta - const deltaPrime = this.awaitedDelta.transform(delta, "right"); - // Delta transformed against both awaitedDelta and the buffer (appropriate for applying to the editor) - const deltaBis = this.buffer.transform(deltaPrime, "right"); - - this.client.applyDelta(deltaBis); - - const awaitedDeltaPrime = delta.transform(this.awaitedDelta, "left"); - const bufferPrime = deltaPrime.transform(this.buffer, "left"); - - return new AwaitingWithBuffer(this.client, awaitedDeltaPrime, bufferPrime); - } - - onServerAcknowledgement() { - this.client.sendDelta(this.buffer); - return new AwaitingAcknowledgement(this.client, this.buffer); - } -} diff --git a/assets/js/hooks/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js b/assets/js/hooks/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js deleted file mode 100644 index 66b19fa13c9..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js +++ /dev/null @@ -1,128 +0,0 @@ -import monaco from "../monaco"; - -/** - * Defines custom auto-formatting behavior for Elixir. - * - * The provider is triggered when the user makes edits - * and it may instruct the editor to apply some additional changes. - */ -const ElixirOnTypeFormattingEditProvider = { - autoFormatTriggerCharacters: ["\n"], - provideOnTypeFormattingEdits(model, position, char, options, token) { - if (char === "\n") { - return closingEndTextEdits(model, position); - } - - return []; - }, -}; - -function closingEndTextEdits(model, position) { - const lines = model.getLinesContent(); - const lineIndex = position.lineNumber - 1; - const line = lines[lineIndex]; - const prevLine = lines[lineIndex - 1]; - const prevIndentation = indentation(prevLine); - - if (shouldInsertClosingEnd(lines, lineIndex)) { - // If this is the last line or the line is not empty, - // we have to insert a newline at the current position. - // Otherwise we prefer to explicitly insert the closing end - // in the next line, as it preserves current cursor position. - const shouldInsertInNextLine = - position.lineNumber < lines.length && isBlank(line); - - // If the next line is not available for inserting, - // we could insert `\nend` but this moves the cursor, - // so for now we just don't insert `end` at all - // For more context see https://github.com/livebook-dev/livebook/issues/152 - if (!shouldInsertInNextLine) { - return []; - } - - const textEdit = insertClosingEndTextEdit( - position, - prevIndentation, - shouldInsertInNextLine - ); - - return [textEdit]; - } - - return []; -} - -function shouldInsertClosingEnd(lines, lineIndex) { - const prevLine = lines[lineIndex - 1]; - const prevIndentation = indentation(prevLine); - const prevTokens = tokens(prevLine); - - if ( - last(prevTokens) === "do" || - (prevTokens.includes("fn") && last(prevTokens) === "->") - ) { - const nextLineWithSameIndentation = lines - .slice(lineIndex + 1) - .filter((line) => !isBlank(line)) - .find((line) => indentation(line) === prevIndentation); - - if (nextLineWithSameIndentation) { - const [firstToken] = tokens(nextLineWithSameIndentation); - - if (["after", "else", "catch", "rescue", "end"].includes(firstToken)) { - return false; - } - } - - return true; - } - - return false; -} - -function insertClosingEndTextEdit( - position, - indentation, - shouldInsertInNextLine -) { - if (shouldInsertInNextLine) { - return { - range: new monaco.Range( - position.lineNumber + 1, - 1, - position.lineNumber + 1, - 1 - ), - text: `${indentation}end\n`, - }; - } else { - return { - range: new monaco.Range( - position.lineNumber, - position.column, - position.lineNumber, - position.column - ), - text: `\n${indentation}end`, - }; - } -} - -function indentation(line) { - const [indentation] = line.match(/^\s*/); - return indentation; -} - -function tokens(line) { - return line.replace(/#.*/, "").match(/->|[\w:]+/g) || []; -} - -function last(list) { - return list[list.length - 1]; -} - -function isBlank(string) { - return string.trim() === ""; -} - -export default ElixirOnTypeFormattingEditProvider; diff --git a/assets/js/hooks/cell_editor/live_editor/erlang/language_configuration.js b/assets/js/hooks/cell_editor/live_editor/erlang/language_configuration.js deleted file mode 100644 index 810386c6345..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/erlang/language_configuration.js +++ /dev/null @@ -1,43 +0,0 @@ -// Adapted from https://github.com/erlang-ls/vscode/blob/0.0.39/language-configuration.json -const ErlangLanguageConfiguration = { - comments: { - lineComment: "%", - }, - brackets: [ - ["(", ")"], - ["[", "]"], - ["{", "}"], - ], - autoClosingPairs: [ - { open: "(", close: ")" }, - { open: "[", close: "]" }, - { open: "{", close: "}" }, - { open: "'", close: "'", notIn: ["string", "comment"] }, - { open: '"', close: '"', notIn: ["string"] }, - { open: '<<"', close: '">>', notIn: ["string"] }, - ], - surroundingPairs: [ - { open: "(", close: ")" }, - { open: "[", close: "]" }, - { open: "{", close: "}" }, - { open: "'", close: "'" }, - { open: '"', close: '"' }, - ], - indentationRules: { - // Indent if a line ends brackets, "->" or most keywords. Also if prefixed - // with "||". This should work with most formatting models. - // The ((?!%).)* is to ensure this doesn't match inside comments. - increaseIndentPattern: - /^((?!%).)*([{([]|->|after|begin|case|catch|fun|if|of|try|when|(\|\|.*))\s*$/, - // Dedent after brackets, end or lone "->". The latter happens in a spec - // with indented types, typically after "when". Only do this if it's _only_ - // preceded by whitespace. - decreaseIndentPattern: /^\s*([)}]]|end|->\s*$)/, - // Indent if after an incomplete map association operator, list - // comprehension and type specifier. But only once, then return to the - // previous indent. - indentNextLinePattern: /^((?!%).)*(::|=>|:=|<-)\s*$/, - }, -}; - -export default ErlangLanguageConfiguration; diff --git a/assets/js/hooks/cell_editor/live_editor/erlang/monarch_language.js b/assets/js/hooks/cell_editor/live_editor/erlang/monarch_language.js deleted file mode 100644 index 081c6ccaf72..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/erlang/monarch_language.js +++ /dev/null @@ -1,156 +0,0 @@ -const ErlangMonarchLanguage = { - // Set defaultToken to invalid to see what you do not tokenize yet - // defaultToken: 'invalid', - - keywords: [ - "case", - "if", - "begin", - "end", - "when", - "of", - "fun", - "maybe", - "else", - "try", - "catch", - "receive", - "after", - ], - - attributes: [ - "-module", - "-record", - "-export", - "-spec", - "-include", - "-include_lib", - "-export", - "-undef", - "-ifdef", - "-ifndef", - "-else", - "-endif", - "-if", - "-elif", - "-define", - ], - - operators: [ - "=", - "==", - "=:=", - "/=", - "=/=", - ">", - "<", - "=<", - ">=", - "+", - "++", - "-", - "--", - "*", - "/", - "!", - "and", - "or", - "not", - "xor", - "andalso", - "orelse", - "bnot", - "div", - "rem", - "band", - "bor", - "bxor", - "bsl", - "bsr", - ":=", - "=>", - "->", - "?=", - "<-", - "||", - ], - - builtins: ["error", "exit"], - - brackets: [ - ["(", ")", "delimiter.parenthesis"], - ["{", "}", "delimiter.curly"], - ["[", "]", "delimiter.square"], - ], - - symbols: /[=><~&|+\-*\/%@#]+/, - - escapes: - /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/, - - tokenizer: { - root: [ - [ - /(-[a-z_]+)/, - { - cases: { - "@attributes": "keyword", - "@default": "identifier", - }, - }, - ], - - [/(\?[a-zA-Z_0-9]+)/, "constant"], - - [/[A-Z_][a-z0-9_]*/, "identifier"], - [ - /[a-z_][\w\-']*/, - { - cases: { - "@builtins": "predefined.identifier", - "@keywords": "keyword", - "@default": "identifier", - }, - }, - ], - - // whitespace - { include: "@whitespace" }, - - // delimiters and operators - [/[()\[\]\{\}]/, "@brackets"], - [ - /@symbols/, - { - cases: { - "@operators": "predefined.operator", - "@default": "operator", - }, - }, - ], - - // numbers - [/\d*\.\d+([eE][\-+]?\d+)?/, "number.float"], - [/16#[0-9a-fA-F]+/, "number.hex"], - [/\d+/, "number"], - - // strings - [/"([^"\\]|\\.)*$/, "string.invalid"], // non-teminated string - [/"/, { token: "string.quote", bracket: "@open", next: "@string" }], - ], - - string: [ - [/[^\\"]+/, "string"], - [/@escapes/, "string.escape"], - [/\\./, "string.escape.invalid"], - [/"/, { token: "string.quote", bracket: "@close", next: "@pop" }], - ], - - whitespace: [ - [/[ \t\r\n]+/, "white"], - [/%.*$/, "comment"], - ], - }, -}; - -export default ErlangMonarchLanguage; diff --git a/assets/js/hooks/cell_editor/live_editor/highlight.js b/assets/js/hooks/cell_editor/live_editor/highlight.js new file mode 100644 index 00000000000..7d1d78f6ac5 --- /dev/null +++ b/assets/js/hooks/cell_editor/live_editor/highlight.js @@ -0,0 +1,43 @@ +import { LanguageDescription } from "@codemirror/language"; +import { highlightCode } from "@lezer/highlight"; +import { languages } from "./codemirror/languages"; +import { highlightStyle, lightHighlightStyle } from "./codemirror/theme"; +import { escapeHtml } from "../../../lib/utils"; +import { settingsStore } from "../../../lib/settings"; + +export function highlight(code, language) { + const languageDesc = LanguageDescription.matchLanguageName( + languages, + language + ); + + if (!languageDesc) { + return escapeHtml(code); + } + + const tree = languageDesc.support.language.parser.parse(code); + + let html = ""; + + highlightCode( + code, + tree, + getHighlightStyle(), + (code, classes) => { + html += `${code}`; + }, + () => { + html += "
"; + } + ); + + return html; +} + +function getHighlightStyle() { + const settings = settingsStore.get(); + + return settings.editor_theme === "light" + ? lightHighlightStyle + : highlightStyle; +} diff --git a/assets/js/hooks/cell_editor/live_editor/hook_server_adapter.js b/assets/js/hooks/cell_editor/live_editor/hook_server_adapter.js deleted file mode 100644 index a05e012a990..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/hook_server_adapter.js +++ /dev/null @@ -1,71 +0,0 @@ -import Delta from "../../../lib/delta"; - -/** - * Encapsulates logic related to sending/receiving messages from the server. - * - * Uses the given hook instance socket for the communication. - */ -export default class HookServerAdapter { - constructor(hook, cellId, tag) { - this.hook = hook; - this.cellId = cellId; - this.tag = tag; - this._onDelta = null; - this._onAcknowledgement = null; - - this.hook.handleEvent( - `cell_delta:${this.cellId}:${this.tag}`, - ({ delta }) => { - this._onDelta && this._onDelta(Delta.fromCompressed(delta)); - } - ); - - this.hook.handleEvent( - `cell_acknowledgement:${this.cellId}:${this.tag}`, - () => { - this._onAcknowledgement && this._onAcknowledgement(); - } - ); - } - - /** - * Registers a callback called whenever a new delta comes from the server. - */ - onDelta(callback) { - this._onDelta = callback; - } - - /** - * Registers a callback called when delta acknowledgement comes from the server. - */ - onAcknowledgement(callback) { - this._onAcknowledgement = callback; - } - - /** - * Sends the given delta to the server. - */ - sendDelta(delta, revision) { - this.hook.pushEvent("apply_cell_delta", { - cell_id: this.cellId, - tag: this.tag, - delta: delta.toCompressed(), - revision, - }); - } - - /** - * Sends an information to the server that the client - * is at the specified revision. - * - * This should be invoked if the client received updates, - * but is not itself sending any delta at the moment. - */ - reportRevision(revision) { - this.hook.pushEvent("report_cell_revision", { - cell_id: this.cellId, - tag: this.tag, - revision, - }); - } -} diff --git a/assets/js/hooks/cell_editor/live_editor/monaco.js b/assets/js/hooks/cell_editor/live_editor/monaco.js deleted file mode 100644 index f2b69dfb954..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/monaco.js +++ /dev/null @@ -1,300 +0,0 @@ -// We need to set window.MonacoEnvironment before importing vs/editor/editor.api, -// because it expects MonacoEnvironment.globalAPI to be set on import -import "./monaco_environment"; - -// For the full list of features see [1] and [2]. The Monaco Webpack -// plugin always ignores certain imports [3], so we ignore these as -// well. On top of that, we ignore some other features which we -// clearly don't use. -// -// [1]: https://github.com/microsoft/vscode/blob/main/src/vs/editor/editor.main.ts -// [2]: https://github.com/microsoft/vscode/blob/main/src/vs/editor/editor.all.ts -// [3]: https://github.com/microsoft/monaco-editor/blob/v0.38.0/build/releaseMetadata.ts#L212-L221 - -// === Monaco features === - -import "monaco-editor/esm/vs/editor/browser/coreCommands"; -// import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget'; -// import 'vs/editor/browser/widget/diffEditor/diffEditor.contribution'; -import "monaco-editor/esm/vs/editor/contrib/anchorSelect/browser/anchorSelect"; -import "monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching"; -import "monaco-editor/esm/vs/editor/contrib/caretOperations/browser/caretOperations"; -import "monaco-editor/esm/vs/editor/contrib/caretOperations/browser/transpose"; -import "monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard"; -import "monaco-editor/esm/vs/editor/contrib/codeAction/browser/codeActionContributions"; -import "monaco-editor/esm/vs/editor/contrib/codelens/browser/codelensController"; -// import 'monaco-editor/esm/vs/editor/contrib/colorPicker/browser/colorContributions'; -// import 'monaco-editor/esm/vs/editor/contrib/colorPicker/browser/standaloneColorPickerActions'; -import "monaco-editor/esm/vs/editor/contrib/comment/browser/comment"; -import "monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu"; -import "monaco-editor/esm/vs/editor/contrib/cursorUndo/browser/cursorUndo"; -import "monaco-editor/esm/vs/editor/contrib/dnd/browser/dnd"; -// import 'monaco-editor/esm/vs/editor/contrib/dropOrPasteInto/browser/copyPasteContribution'; -import "monaco-editor/esm/vs/editor/contrib/dropOrPasteInto/browser/dropIntoEditorContribution"; -import "monaco-editor/esm/vs/editor/contrib/find/browser/findController"; -import "monaco-editor/esm/vs/editor/contrib/folding/browser/folding"; -import "monaco-editor/esm/vs/editor/contrib/fontZoom/browser/fontZoom"; -import "monaco-editor/esm/vs/editor/contrib/format/browser/formatActions"; -// import 'monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/documentSymbols'; -import "monaco-editor/esm/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution"; -import "monaco-editor/esm/vs/editor/contrib/inlineProgress/browser/inlineProgress"; -import "monaco-editor/esm/vs/editor/contrib/gotoSymbol/browser/goToCommands"; -import "monaco-editor/esm/vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition"; -import "monaco-editor/esm/vs/editor/contrib/gotoError/browser/gotoError"; -import "monaco-editor/esm/vs/editor/contrib/hover/browser/hover"; -import "monaco-editor/esm/vs/editor/contrib/indentation/browser/indentation"; -import "monaco-editor/esm/vs/editor/contrib/inlayHints/browser/inlayHintsContribution"; -import "monaco-editor/esm/vs/editor/contrib/inPlaceReplace/browser/inPlaceReplace"; -import "monaco-editor/esm/vs/editor/contrib/lineSelection/browser/lineSelection"; -import "monaco-editor/esm/vs/editor/contrib/linesOperations/browser/linesOperations"; -import "monaco-editor/esm/vs/editor/contrib/linkedEditing/browser/linkedEditing"; -import "monaco-editor/esm/vs/editor/contrib/links/browser/links"; -import "monaco-editor/esm/vs/editor/contrib/longLinesHelper/browser/longLinesHelper"; -import "monaco-editor/esm/vs/editor/contrib/multicursor/browser/multicursor"; -import "monaco-editor/esm/vs/editor/contrib/parameterHints/browser/parameterHints"; -import "monaco-editor/esm/vs/editor/contrib/rename/browser/rename"; -import "monaco-editor/esm/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens"; -import "monaco-editor/esm/vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens"; -import "monaco-editor/esm/vs/editor/contrib/smartSelect/browser/smartSelect"; -import "monaco-editor/esm/vs/editor/contrib/snippet/browser/snippetController2"; -import "monaco-editor/esm/vs/editor/contrib/stickyScroll/browser/stickyScrollContribution"; -import "monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController"; -import "monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestInlineCompletions"; -// import 'monaco-editor/esm/vs/editor/contrib/tokenization/browser/tokenization'; -// import 'monaco-editor/esm/vs/editor/contrib/toggleTabFocusMode/browser/toggleTabFocusMode'; -import "monaco-editor/esm/vs/editor/contrib/unicodeHighlighter/browser/unicodeHighlighter"; -import "monaco-editor/esm/vs/editor/contrib/unusualLineTerminators/browser/unusualLineTerminators"; -import "monaco-editor/esm/vs/editor/contrib/wordHighlighter/browser/wordHighlighter"; -import "monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations"; -import "monaco-editor/esm/vs/editor/contrib/wordPartOperations/browser/wordPartOperations"; -import "monaco-editor/esm/vs/editor/contrib/readOnlyMessage/browser/contribution"; - -// import 'monaco-editor/esm/vs/editor/common/standaloneStrings'; -// import 'vs/base/browser/ui/codicons/codiconStyles'; - -// import "monaco-editor/esm/vs/editor/standalone/browser/iPadShowKeyboard/iPadShowKeyboard"; -import "monaco-editor/esm/vs/editor/standalone/browser/inspectTokens/inspectTokens"; -import "monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneHelpQuickAccess"; -import "monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneGotoLineQuickAccess"; -import "monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneGotoSymbolQuickAccess"; -import "monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneCommandsQuickAccess"; -import "monaco-editor/esm/vs/editor/standalone/browser/referenceSearch/standaloneReferenceSearch"; -// import 'monaco-editor/esm/vs/editor/standalone/browser/toggleHighContrast/toggleHighContrast'; - -import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; - -// === Languages === - -// Note: JSON doesn't have a monarch grammar in basic-languages, so -// we use the advanced handler (which also has its own web worker) -import "monaco-editor/esm/vs/language/json/monaco.contribution"; - -import "monaco-editor/esm/vs/basic-languages/elixir/elixir.contribution"; -import "monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution"; -import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution"; -import "monaco-editor/esm/vs/basic-languages/sql/sql.contribution"; -import "monaco-editor/esm/vs/basic-languages/css/css.contribution"; -import "monaco-editor/esm/vs/basic-languages/html/html.contribution"; -import "monaco-editor/esm/vs/basic-languages/xml/xml.contribution"; -import "monaco-editor/esm/vs/basic-languages/dockerfile/dockerfile.contribution"; - -// === Configuration === - -import { CommandsRegistry } from "monaco-editor/esm/vs/platform/commands/common/commands"; -import ElixirOnTypeFormattingEditProvider from "./elixir/on_type_formatting_edit_provider"; -import ErlangMonarchLanguage from "./erlang/monarch_language"; -import ErlangLanguageConfiguration from "./erlang/language_configuration"; -import { theme, lightTheme } from "./theme"; - -import { PieceTreeTextBufferBuilder } from "monaco-editor/esm/vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder"; - -import { settingsStore } from "../../../lib/settings"; - -// Force LF for line ending. -// -// Monaco infers EOL based on the text content if any, otherwise uses -// a system dependent value (CRLF for Windows). Then, the content is -// always normalized to use that EOL. We need to ensure consistent -// behaviour for collaborative editing to work. We already enforce -// LF when importing/exporting Live Markdown, so the easiest approach -// is to enforce it in the editor as well. -// -// There is no direct configuration to accomplish this, so we use an -// override of [1] instead. There is also a long-running discussion -// around EOL in [2]. -// -// An alternative approach would be to disable line normalization and -// possibly set the default EOL to LF (used when there is no content -// to infer EOL from). Currently neither of those is configurable and -// requires more complex overrides. -// -// [1]: https://github.com/microsoft/vscode/blob/34f184263de048a6283af1d9eb9faab84da4547d/src/vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder.ts#L27-L40 -// [2]: https://github.com/microsoft/vscode/issues/127 -if (PieceTreeTextBufferBuilder.prototype.finish) { - const original = PieceTreeTextBufferBuilder.prototype.finish; - - // We don't have access to the factory class directly, so we override - // the builder, such that we modify the factory object once created - PieceTreeTextBufferBuilder.prototype.finish = function (...args) { - const factory = original.apply(this, args); - - if (factory._getEOL) { - factory._getEOL = function (defaultEOL) { - return "\n"; - }; - } else { - throw new Error("failed to override line endings to LF"); - } - - return factory; - }; -} else { - throw new Error("failed to override line endings to LF"); -} - -monaco.languages.registerOnTypeFormattingEditProvider( - "elixir", - ElixirOnTypeFormattingEditProvider -); - -monaco.languages.register({ id: "erlang" }); -monaco.languages.setMonarchTokensProvider("erlang", ErlangMonarchLanguage); -monaco.languages.setLanguageConfiguration( - "erlang", - ErlangLanguageConfiguration -); - -// Define custom theme -monaco.editor.defineTheme("default", theme); -monaco.editor.defineTheme("light", lightTheme); - -// See https://github.com/microsoft/monaco-editor/issues/648#issuecomment-564978560 -// Without this selecting text with whitespace shrinks the whitespace. -document.fonts.addEventListener("loadingdone", (event) => { - const jetBrainsMonoLoaded = event.fontfaces.some( - // font-family may be either "JetBrains Mono" or "\"JetBrains Mono\"" - (fontFace) => fontFace.family.includes("JetBrains Mono") - ); - - if (jetBrainsMonoLoaded) { - // We use JetBrains Mono in all instances of the editor, - // so we wait until it loads and then tell Monaco to remeasure - // fonts and updates its cache. - monaco.editor.remeasureFonts(); - } -}); - -/** - * Define custom providers for various editor features. - * - * In our case, each cell has its own editor and behaviour - * of requests like completion and hover are cell dependent. - * For this reason we delegate the implementation to the - * specific cell by using its text model object. - * - * See cell/live_editor.js for more details. - */ - -let completionItemProvider = null; - -settingsStore.getAndSubscribe((settings) => { - // We replace the completion provider to always reflect the settings - if (completionItemProvider) { - completionItemProvider.dispose(); - } - - completionItemProvider = monaco.languages.registerCompletionItemProvider( - "elixir", - { - // Trigger characters always open the popup, so we add dot only - // when completion while typing is enabled - triggerCharacters: settings.editor_auto_completion ? ["."] : [], - provideCompletionItems: (model, position, context, token) => { - if (model.__getCompletionItems__) { - return model.__getCompletionItems__(model, position); - } else { - return null; - } - }, - } - ); -}); - -monaco.languages.registerHoverProvider("elixir", { - provideHover: (model, position, token) => { - if (model.__getHover__) { - return model.__getHover__(model, position); - } else { - return null; - } - }, -}); - -monaco.languages.registerSignatureHelpProvider("elixir", { - signatureHelpTriggerCharacters: ["(", ","], - provideSignatureHelp: (model, position, token, context) => { - if (model.__getSignatureHelp__) { - return model.__getSignatureHelp__(model, position); - } else { - return null; - } - }, -}); - -monaco.languages.registerDocumentFormattingEditProvider("elixir", { - provideDocumentFormattingEdits: (model, options, token) => { - if (model.__getDocumentFormattingEdits__) { - return model.__getDocumentFormattingEdits__(model); - } else { - return null; - } - }, -}); - -export default monaco; - -/** - * Highlights the given code using the same rules as in the editor. - * - * Returns a promise resolving to HTML that renders as the highlighted code. - */ -export function highlight(code, language) { - // Currently monaco.editor.colorize doesn't support passing theme - // directly and uses the theme from last editor initialization, so - // we need to make sure there was at least one editor initialization - // with the configured theme. - // - // Tracked in https://github.com/microsoft/monaco-editor/issues/3302 - if (!highlight.initialized) { - const settings = settingsStore.get(); - monaco.editor.create(document.createElement("div"), { - theme: settings.editor_theme, - }); - highlight.initialized = true; - } - - return monaco.editor.colorize(code, language).then((result) => { - // `colorize` always adds additional newline, so we remove it - return result.replace(/$/, ""); - }); -} - -/** - * Updates keybinding for the given editor command. - * - * This uses an internal API, since there is no clean support - * for customizing keybindings. - * See https://github.com/microsoft/monaco-editor/issues/102#issuecomment-822981429 - */ -export function addKeybinding(editor, id, newKeybinding) { - const { handler, when } = CommandsRegistry.getCommand(id) ?? {}; - - if (handler) { - editor._standaloneKeybindingService.addDynamicKeybinding( - id, - newKeybinding, - handler, - when - ); - } -} diff --git a/assets/js/hooks/cell_editor/live_editor/monaco_editor_adapter.js b/assets/js/hooks/cell_editor/live_editor/monaco_editor_adapter.js deleted file mode 100644 index 8e4474ed670..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/monaco_editor_adapter.js +++ /dev/null @@ -1,144 +0,0 @@ -import monaco from "./monaco"; -import Delta, { isDelete, isInsert, isRetain } from "../../../lib/delta"; - -/** - * Encapsulates logic related to getting/applying changes to the editor. - * - * Uses the given Monaco editor instance. - */ -export default class MonacoEditorAdapter { - constructor(editor) { - this.editor = editor; - this._onDelta = null; - this.isLastChangeRemote = false; - - this.editor.onDidChangeModelContent((event) => { - if (this.ignoreChange) { - return; - } - - this.isLastChangeRemote = false; - - const delta = this._deltaFromEditorChange(event); - this._onDelta && this._onDelta(delta); - }); - } - - /** - * Registers a callback called whenever the user makes a change - * to the editor content. The change is represented by a delta object. - */ - onDelta(callback) { - this._onDelta = callback; - } - - /** - * Applies the given delta to the editor content. - */ - applyDelta(delta) { - const isStandaloneChange = delta.ops.some((op) => { - if (isDelete(op)) { - return true; - } - - if (isInsert(op)) { - return op.insert.match(/\s+/); - } - - return false; - }); - - // Explicitly close the last stack element when the remote - // change inserts whitespace or deletes text. Otherwise - // merge subsequent remote changes whenever possible. - if (isStandaloneChange || !this.isLastChangeRemote) { - this.editor.getModel().pushStackElement(); - } else { - this.editor.getModel().popStackElement(); - } - - const operations = this._deltaToEditorOperations(delta); - this.ignoreChange = true; - // Apply the operations and add them to the undo stack - this.editor.getModel().pushEditOperations(null, operations, null); - // Close the stack element upfront in case the next - // change is local. If another remote change comes, - // we open the element back using `popStackElement`. - this.editor.getModel().pushStackElement(); - this.ignoreChange = false; - - this.isLastChangeRemote = true; - } - - _deltaFromEditorChange(event) { - const deltas = event.changes.map((change) => { - const { rangeOffset, rangeLength, text } = change; - - const delta = new Delta(); - - if (rangeOffset) { - delta.retain(rangeOffset); - } - - if (rangeLength) { - delta.delete(rangeLength); - } - - if (text) { - delta.insert(text); - } - - return delta; - }); - - return deltas.reduce((delta1, delta2) => delta1.compose(delta2)); - } - - _deltaToEditorOperations(delta) { - const model = this.editor.getModel(); - - const operations = []; - let index = 0; - - delta.ops.forEach((op) => { - if (isRetain(op)) { - index += op.retain; - } - - if (isInsert(op)) { - const start = model.getPositionAt(index); - - operations.push({ - forceMoveMarkers: true, - range: new monaco.Range( - start.lineNumber, - start.column, - start.lineNumber, - start.column - ), - text: op.insert, - }); - } - - if (isDelete(op)) { - const start = model.getPositionAt(index); - const end = model.getPositionAt(index + op.delete); - - operations.push({ - forceMoveMarkers: false, - range: new monaco.Range( - start.lineNumber, - start.column, - end.lineNumber, - end.column - ), - text: null, - }); - - index += op.delete; - } - }); - - return operations; - } -} diff --git a/assets/js/hooks/cell_editor/live_editor/monaco_environment.js b/assets/js/hooks/cell_editor/live_editor/monaco_environment.js deleted file mode 100644 index b1de18bb7fa..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/monaco_environment.js +++ /dev/null @@ -1,12 +0,0 @@ -window.MonacoEnvironment = { - // Certain browser extensions are Monaco-aware, so we expose it on - // the window object - globalAPI: true, - getWorkerUrl(_workerId, label) { - if (label === "json") { - return "/assets/language/json/json.worker.js"; - } - - return "/assets/editor/editor.worker.js"; - }, -}; diff --git a/assets/js/hooks/cell_editor/live_editor/remote_user.js b/assets/js/hooks/cell_editor/live_editor/remote_user.js deleted file mode 100644 index 457c767ce16..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/remote_user.js +++ /dev/null @@ -1,167 +0,0 @@ -import monaco from "./monaco"; -import { randomId } from "../../../lib/utils"; - -/** - * Remote user visual indicators within the editor. - * - * Consists of a cursor widget and a selection highlight. - * Both elements have the user's hex color of choice. - */ -export default class RemoteUser { - constructor(editor, selection, hexColor, label) { - this._cursorWidget = new CursorWidget( - editor, - selection.getPosition(), - hexColor, - label - ); - - this._selectionDecoration = new SelectionDecoration( - editor, - selection, - hexColor - ); - } - - /** - * Updates indicators to match the given selection. - */ - update(selection) { - this._cursorWidget.update(selection.getPosition()); - this._selectionDecoration.update(selection); - } - - /** - * Performs necessary cleanup actions. - */ - dispose() { - this._cursorWidget.dispose(); - this._selectionDecoration.dispose(); - } -} - -class CursorWidget { - constructor(editor, position, hexColor, label) { - this._id = randomId(); - this._editor = editor; - this._position = position; - this._isPositionValid = this._checkPositionValidity(position); - - this._buildDomNode(hexColor, label); - - this._editor.addContentWidget(this); - - this._onDidChangeModelContentDisposable = - this._editor.onDidChangeModelContent((event) => { - // We may receive new cursor position before content update, - // and the position may be invalid (e.g. column 10, even though the line has currently length 9). - // If that's the case then we want to update the cursor once the content is updated. - if (!this._isPositionValid) { - this.update(this._position); - } - }); - } - - getId() { - return this._id; - } - - getPosition() { - return { - position: this._position, - preference: [monaco.editor.ContentWidgetPositionPreference.EXACT], - }; - } - - update(position) { - this._position = position; - this._isPositionValid = this._checkPositionValidity(position); - this._updateDomNode(); - this._editor.layoutContentWidget(this); - } - - getDomNode() { - return this._domNode; - } - - dispose() { - this._editor.removeContentWidget(this); - this._onDidChangeModelContentDisposable.dispose(); - } - - _checkPositionValidity(position) { - const validPosition = this._editor.getModel().validatePosition(position); - return position.equals(validPosition); - } - - _buildDomNode(hexColor, label) { - const lineHeight = this._editor.getOption( - monaco.editor.EditorOption.lineHeight - ); - - const node = document.createElement("div"); - node.classList.add("monaco-cursor-widget-container"); - - const cursorNode = document.createElement("div"); - cursorNode.classList.add("monaco-cursor-widget-cursor"); - cursorNode.style.background = hexColor; - cursorNode.style.height = `${lineHeight}px`; - - const labelNode = document.createElement("div"); - labelNode.classList.add("monaco-cursor-widget-label"); - labelNode.style.height = `${lineHeight}px`; - labelNode.innerText = label; - labelNode.style.background = hexColor; - - node.appendChild(cursorNode); - node.appendChild(labelNode); - - this._domNode = node; - this._updateDomNode(); - } - - _updateDomNode() { - const isFirstLine = this._position.lineNumber === 1; - this._domNode.classList.toggle("inline", isFirstLine); - } -} - -class SelectionDecoration { - constructor(editor, selection, hexColor) { - this._editor = editor; - this._decorations = []; - - // Dynamically create CSS class for the given hex color - - this._className = `user-selection-${hexColor.replace("#", "")}`; - - this._styleElement = document.createElement("style"); - this._styleElement.innerHTML = ` - .${this._className} { - background-color: ${hexColor}30; - } - `; - document.body.appendChild(this._styleElement); - - this.update(selection); - } - - update(selection) { - const newDecorations = [ - { - range: selection, - options: { className: this._className }, - }, - ]; - - this._decorations = this._editor.deltaDecorations( - this._decorations, - newDecorations - ); - } - - dispose() { - this._editor.deltaDecorations(this._decorations, []); - this._styleElement.remove(); - } -} diff --git a/assets/js/hooks/cell_editor/live_editor/theme.js b/assets/js/hooks/cell_editor/live_editor/theme.js deleted file mode 100644 index 56fa69be53d..00000000000 --- a/assets/js/hooks/cell_editor/live_editor/theme.js +++ /dev/null @@ -1,118 +0,0 @@ -// This is a port of the One Dark theme to the Monaco editor. -// We color graded the comment so it has AA accessibility and -// then similarly scaled the default font. -const colors = { - background: "#282c34", - default: "#c4cad6", - lightRed: "#e06c75", - blue: "#61afef", - gray: "#8c92a3", - green: "#98c379", - purple: "#c678dd", - red: "#be5046", - teal: "#56b6c2", - peach: "#d19a66", -}; - -const lightColors = { - background: "#fafafa", - default: "#304254", - lightRed: "#e45649", - blue: "#4078F2", - gray: "#707177", - green: "#50a14f", - purple: "#a726a4", - red: "#ca1243", - teal: "#56b6c2", - peach: "#986801", -}; - -const rules = (colors) => [ - { token: "", foreground: colors.default }, - { token: "variable", foreground: colors.lightRed }, - { token: "constant", foreground: colors.blue }, - { token: "constant.character.escape", foreground: colors.blue }, - { token: "comment", foreground: colors.gray }, - { token: "number", foreground: colors.blue }, - { token: "regexp", foreground: colors.lightRed }, - { token: "type", foreground: colors.lightRed }, - { token: "string", foreground: colors.green }, - { token: "keyword", foreground: colors.purple }, - { token: "operator", foreground: colors.peach }, - { token: "delimiter.bracket.embed", foreground: colors.red }, - { token: "sigil", foreground: colors.teal }, - { token: "function", foreground: colors.blue }, - { token: "function.call", foreground: colors.default }, - - // Markdown specific - { token: "emphasis", fontStyle: "italic" }, - { token: "strong", fontStyle: "bold" }, - { token: "keyword.md", foreground: colors.lightRed }, - { token: "keyword.table", foreground: colors.lightRed }, - { token: "string.link.md", foreground: colors.blue }, - { token: "variable.md", foreground: colors.teal }, - { token: "string.md", foreground: colors.default }, - { token: "variable.source.md", foreground: colors.default }, - - // XML specific - { token: "tag", foreground: colors.lightRed }, - { token: "metatag", foreground: colors.lightRed }, - { token: "attribute.name", foreground: colors.peach }, - { token: "attribute.value", foreground: colors.green }, - - // JSON specific - { token: "string.key", foreground: colors.lightRed }, - { token: "keyword.json", foreground: colors.blue }, - - // SQL specific - { token: "operator.sql", foreground: colors.purple }, -]; - -const theme = { - base: "vs-dark", - inherit: false, - rules: rules(colors), - - colors: { - "editor.background": colors.background, - "editor.foreground": colors.default, - "editorLineNumber.foreground": "#636d83", - "editorCursor.foreground": "#636d83", - "editor.selectionBackground": "#3e4451", - "editor.findMatchHighlightBackground": "#528bff3d", - "editorSuggestWidget.background": "#21252b", - "editorSuggestWidget.border": "#181a1f", - "editorSuggestWidget.selectedBackground": "#2c313a", - "input.background": "#1b1d23", - "input.border": "#181a1f", - "editorBracketMatch.border": "#282c34", - "editorBracketMatch.background": "#3e4451", - }, -}; - -const lightTheme = { - base: "vs", - inherit: false, - rules: rules(lightColors), - - colors: { - "editor.background": lightColors.background, - "editor.foreground": lightColors.default, - "editorLineNumber.foreground": "#9d9d9f", - "editorCursor.foreground": "#526fff", - "editor.selectionBackground": "#e5e5e6", - "editor.findMatchHighlightBackground": "#526fff33", - "editorSuggestWidget.highlightForeground": lightColors.default, - "editorSuggestWidget.focusHighlightForeground": "#0431fa", - "editorSuggestWidget.selectedForeground": lightColors.default, - "editorSuggestWidget.background": "#eaeaeb", - "editorSuggestWidget.border": "#dbdbdc", - "editorSuggestWidget.selectedBackground": "#ffffff", - "input.background": "#ffffff", - "input.border": "#dbdbdc", - "editorBracketMatch.border": "#fafafa", - "editorBracketMatch.background": "#e5e5e6", - }, -}; - -export { theme, lightTheme }; diff --git a/assets/js/hooks/headline.js b/assets/js/hooks/headline.js index fd35b7997ed..e68a2c237e8 100644 --- a/assets/js/hooks/headline.js +++ b/assets/js/hooks/headline.js @@ -1,5 +1,5 @@ import { parseHookProps } from "../lib/attribute"; -import { globalPubSub } from "../lib/pub_sub"; +import { globalPubsub } from "../lib/pubsub"; import { smoothlyScrollToElement } from "../lib/utils"; /** @@ -26,11 +26,9 @@ const Headline = { this.initializeHeadingEl(); - this.unsubscribeFromNavigationEvents = globalPubSub.subscribe( + this.navigationSubscription = globalPubsub.subscribe( "navigation", - (event) => { - this.handleNavigationEvent(event); - } + this.handleNavigationEvent.bind(this) ); }, @@ -40,7 +38,7 @@ const Headline = { }, destroyed() { - this.unsubscribeFromNavigationEvents(); + this.navigationSubscription.destroy(); }, getProps() { diff --git a/assets/js/hooks/highlight.js b/assets/js/hooks/highlight.js index 7743df20fc6..0b5e9a09900 100644 --- a/assets/js/hooks/highlight.js +++ b/assets/js/hooks/highlight.js @@ -1,5 +1,5 @@ import { parseHookProps } from "../lib/attribute"; -import { highlight } from "./cell_editor/live_editor/monaco"; +import { highlight } from "./cell_editor/live_editor/highlight"; import { findChildOrThrow } from "../lib/utils"; /** diff --git a/assets/js/hooks/js_view.js b/assets/js/hooks/js_view.js index 0c227b66dbd..0e83fc6fb7e 100644 --- a/assets/js/hooks/js_view.js +++ b/assets/js/hooks/js_view.js @@ -4,8 +4,9 @@ import { isElementVisibleInViewport, randomId, randomToken, + waitUntilInViewport, } from "../lib/utils"; -import { globalPubSub } from "../lib/pub_sub"; +import { globalPubsub } from "../lib/pubsub"; import { getChannel, transportDecode, @@ -130,10 +131,16 @@ const JSView = { this.channel.off(`pong:${this.props.ref}`, pongRef); }; - this.unsubscribeFromJSViewEvents = globalPubSub.subscribe( - `js_views:${this.props.ref}`, - (event) => this.handleJSViewEvent(event) - ); + this.subscriptions = [ + globalPubsub.subscribe( + `js_views:${this.props.ref}`, + this.handleJSViewEvent.bind(this) + ), + globalPubsub.subscribe( + "navigation", + this.handleNavigationEvent.bind(this) + ), + ]; this.channel.push( "connect", @@ -146,11 +153,6 @@ const JSView = { // default timeout of 10s, so we increase it 30_000 ); - - this.unsubscribeFromCellEvents = globalPubSub.subscribe( - "navigation", - (event) => this.handleNavigationEvent(event) - ); }, updated() { @@ -170,8 +172,7 @@ const JSView = { this.unsubscribeFromChannelEvents(); this.channel.push("disconnect", { ref: this.props.ref }); - this.unsubscribeFromJSViewEvents(); - this.unsubscribeFromCellEvents(); + this.subscriptions.forEach((subscription) => subscription.destroy()); }, getProps() { @@ -232,14 +233,11 @@ const JSView = { // dispatched to trigger reposition. This way we don't need to // use deep MutationObserver, which would be expensive, especially // with code editor - const unsubscribeFromJSViewsEvents = globalPubSub.subscribe( - "js_views", - (event) => { - if (event.type === "reposition") { - this.repositionIframe(); - } + const jsViewSubscription = globalPubsub.subscribe("js_views", (event) => { + if (event.type === "reposition") { + this.repositionIframe(); } - ); + }); // Emulate mouse enter and leave on the placeholder. Note that we // intentionally use bubbling to notify all parents that may have @@ -260,21 +258,7 @@ const JSView = { // We detect when the placeholder enters viewport and becomes visible, // based on that we can load the iframe contents lazily - let viewportIntersectionObserver = null; - - const visibilityPromise = new Promise((resolve, reject) => { - if (isElementVisibleInViewport(this.iframePlaceholder)) { - resolve(); - } else { - viewportIntersectionObserver = new IntersectionObserver((entries) => { - if (isElementVisibleInViewport(this.iframePlaceholder)) { - viewportIntersectionObserver.disconnect(); - resolve(); - } - }); - viewportIntersectionObserver.observe(this.iframePlaceholder); - } - }); + const visibility = waitUntilInViewport(this.iframePlaceholder); // Reflect focus based on whether there is a focused parent, this // is later synced on "element_focused" events @@ -287,13 +271,13 @@ const JSView = { const remove = () => { resizeObserver.disconnect(); - unsubscribeFromJSViewsEvents(); - viewportIntersectionObserver && viewportIntersectionObserver.disconnect(); + jsViewSubscription.destroy(); + visibility.cancel(); this.iframe.remove(); this.iframePlaceholder.remove(); }; - return { visibilityPromise, remove }; + return { visibilityPromise: visibility.promise, remove }; }, repositionIframe() { diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index 2ad1c1abf65..283f6c0ec7e 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -8,14 +8,15 @@ import { cancelEvent, isElementInViewport, isElementHidden, + pop, } from "../lib/utils"; import { parseHookProps } from "../lib/attribute"; import KeyBuffer from "../lib/key_buffer"; -import { globalPubSub } from "../lib/pub_sub"; -import monaco from "./cell_editor/live_editor/monaco"; +import { globalPubsub } from "../lib/pubsub"; import { leaveChannel } from "./js_view/channel"; import { isDirectlyEditable, isEvaluable } from "../lib/notebook"; import { settingsStore } from "../lib/settings"; +import { LiveStore } from "../lib/live_store"; /** * A hook managing the whole session. @@ -51,20 +52,23 @@ import { settingsStore } from "../lib/settings"; * ## Location tracking and following * * Location describes where the given client is within the notebook - * (in which cell, and where specifically in that cell). When multiple - * clients are connected, they report own location to each other - * whenever it changes. We then each the location to show cursor and - * selection indicators. + * (in which cell). When multiple clients are connected, they report + * own location to each other whenever it changes. The user can jump + * to the cell focused by any other client. * * Additionally the current user may follow another client from the * clients list. In such case, whenever a new location comes from that - * client we move there automatically (i.e. we focus the same cells - * to effectively mimic how the followed client moves around). + * client we move there automatically, that is we focus the same cells + * to effectively mimic how the followed client moves around. + * + * Note that cursor and selection tracking is handled separately by + * each editor, as it involves transforming the positions with local + * and incoming remote changes. * * Initially we load basic information about connected clients using * the `"session_init"` event and then update this information whenever - * clients join/leave/update. This way location reports include only - * client id, as we already have the necessary hex_color/name locally. + * clients join/leave/update. This way subsequent messages only include + * the client id and we already have the necessary color/name locally. */ const Session = { mounted() { @@ -75,9 +79,9 @@ const Session = { this.view = null; this.viewOptions = null; this.keyBuffer = new KeyBuffer(); - this.clientsMap = {}; this.lastLocationReportByClientId = {}; this.followedClientId = null; + this.store = LiveStore.create("session"); setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus)); @@ -86,11 +90,17 @@ const Session = { // DOM events this._handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); + this._handleEditorEscape = this.handleEditorEscape.bind(this); this._handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); this._handleDocumentFocus = this.handleDocumentFocus.bind(this); this._handleDocumentClick = this.handleDocumentClick.bind(this); + // Note: we register for the capture phase, so that we handle the + // event before the editor. Specifically, in case of Ctrl + Enter + // we want to evaluate the cell and cancel the event, so that the + // editor doesn't insert a newline document.addEventListener("keydown", this._handleDocumentKeyDown, true); + document.addEventListener("lb:editor_escape", this._handleEditorEscape); document.addEventListener("mousedown", this._handleDocumentMouseDown); // Note: the focus event doesn't bubble, so we register for the capture phase document.addEventListener("focus", this._handleDocumentFocus, true); @@ -158,10 +168,18 @@ const Session = { // Server events - this.handleEvent("session_init", ({ clients }) => { - clients.forEach((client) => { - this.clientsMap[client.id] = client; - }); + this.handleEvent("session_init", ({ clients, client_id }) => { + const clientsMap = {}; + + for (const client of clients) { + clientsMap[client.id] = client; + } + + // Note that we keep clients in a global store, so that all cell + // hooks can access this information, without pushing it for each + // of them separately + this.store.set("clients", clientsMap); + this.store.set("clientId", client_id); }); this.handleEvent("cell_inserted", ({ cell_id: cellId }) => { @@ -195,10 +213,6 @@ const Session = { this.handleSectionMoved(section_id); }); - this.handleEvent("cell_upload", ({ cell_id, url }) => { - this.handleCellUpload(cell_id, url); - }); - this.handleEvent("client_joined", ({ client }) => { this.handleClientJoined(client); }); @@ -218,24 +232,10 @@ const Session = { } ); - this.handleEvent( - "location_report", - ({ client_id, focusable_id, selection }) => { - const report = { - focusableId: focusable_id, - selection: this.decodeSelection(selection), - }; - - this.handleLocationReport(client_id, report); - } - ); - - this.unsubscribeFromSessionEvents = globalPubSub.subscribe( - "session", - (event) => { - this.handleSessionEvent(event); - } - ); + this.handleEvent("location_report", ({ client_id, focusable_id }) => { + const report = { focusableId: focusable_id }; + this.handleLocationReport(client_id, report); + }); }, updated() { @@ -257,9 +257,8 @@ const Session = { }, destroyed() { - this.unsubscribeFromSessionEvents(); - document.removeEventListener("keydown", this._handleDocumentKeyDown, true); + document.removeEventListener("lb:editor_scape", this._handleEditorEscape); document.removeEventListener("mousedown", this._handleDocumentMouseDown); document.removeEventListener("focus", this._handleDocumentFocus, true); document.removeEventListener("click", this._handleDocumentClick); @@ -269,6 +268,8 @@ const Session = { if (!this.keepChannel) { leaveChannel(); } + + this.store.destroy(); }, getProps() { @@ -336,12 +337,13 @@ const Session = { if (this.insertMode) { keyBuffer.reset(); - if (key === "Escape") { - // Ignore Escape if it's supposed to close an editor widget - if (!this.escapesMonacoWidget(event)) { - this.escapeInsertMode(); - } + // We handle editor escape in a dedicated handler + const isEditor = !!event.target.closest(`[data-el-editor-container]`); + + if (!isEditor && key === "Escape") { + this.escapeInsertMode(); } + // Ignore keystrokes on input fields } else if (isEditableElement(event.target)) { keyBuffer.reset(); @@ -437,39 +439,11 @@ const Session = { } }, - escapesMonacoWidget(event) { - // Escape pressed in an editor input - if (event.target.closest(".monaco-inputbox")) { - return true; - } - - const editor = event.target.closest(".monaco-editor.focused"); - - if (!editor) { - return false; - } - - // Completion box open - if (editor.querySelector(".editor-widget.parameter-hints-widget.visible")) { - return true; - } - - // Signature details open - if (editor.querySelector(".editor-widget.suggest-widget.visible")) { - return true; - } - - // Multi-cursor selection enabled - if (editor.querySelectorAll(".cursor").length > 1) { - return true; - } - - // Vim insert or visual mode - if (["insert", "visual"].includes(editor.dataset.vimMode)) { - return true; + handleEditorEscape() { + if (this.insertMode) { + this.keyBuffer.reset(); + this.escapeInsertMode(); } - - return false; }, /** @@ -479,12 +453,8 @@ const Session = { * (e.g. if the user starts selecting some text within the editor) */ handleDocumentMouseDown(event) { - if ( - // If the click is outside the notebook element, keep the focus as is - !event.target.closest(`[data-el-notebook]`) || - // If the click is inside the custom doctest editor widget, keep the focus as is - event.target.closest(`.doctest-details-widget`) - ) { + // If the click is outside the notebook element, keep the focus as is + if (!event.target.closest(`[data-el-notebook]`)) { if (this.insertMode) { this.setInsertMode(false); } @@ -928,7 +898,7 @@ const Session = { // If an evaluable cell is focused, we forward the evaluation // request to that cell, so it can synchronize itself before // sending the request to the server - globalPubSub.broadcast(`cells:${this.focusedId}`, { + globalPubsub.broadcast(`cells:${this.focusedId}`, { type: "dispatch_queue_evaluation", dispatch, }); @@ -1062,13 +1032,15 @@ const Session = { } } - globalPubSub.broadcast("navigation", { + globalPubsub.broadcast("navigation", { type: "element_focused", focusableId: focusableId, scroll, }); this.setInsertMode(false); + + this.sendLocationReport({ focusableId }); }, setInsertMode(insertModeEnabled) { @@ -1078,14 +1050,9 @@ const Session = { this.el.setAttribute("data-js-insert-mode", ""); } else { this.el.removeAttribute("data-js-insert-mode"); - - this.sendLocationReport({ - focusableId: this.focusedId, - selection: null, - }); } - globalPubSub.broadcast("navigation", { + globalPubsub.broadcast("navigation", { type: "insert_mode_changed", enabled: insertModeEnabled, }); @@ -1105,7 +1072,7 @@ const Session = { this.unsetView(); if (view === "custom") { - this.unsubscribeCustomViewFromSettings(); + this.customViewSettingsSubscription.destroy(); } } else if (view === "code-zen") { this.setView(view, { @@ -1122,7 +1089,7 @@ const Session = { spotlight: true, }); } else if (view === "custom") { - this.unsubscribeCustomViewFromSettings = settingsStore.getAndSubscribe( + this.customViewSettingsSubscription = settingsStore.getAndSubscribe( (settings) => { this.setView(view, { showSection: settings.custom_view_show_section, @@ -1238,7 +1205,7 @@ const Session = { this.repositionJSViews(); if (this.focusedId === cellId) { - globalPubSub.broadcast("cells", { type: "cell_moved", cellId }); + globalPubsub.broadcast("cells", { type: "cell_moved", cellId }); } }, @@ -1265,32 +1232,18 @@ const Session = { smoothlyScrollToElement(section); }, - handleCellUpload(cellId, url) { - if (this.focusedId !== cellId) { - this.setFocusedEl(cellId); - } - - if (!this.insertMode) { - this.setInsertMode(true); - } - - globalPubSub.broadcast("cells", { type: "cell_upload", cellId, url }); - }, - handleClientJoined(client) { - this.clientsMap[client.id] = client; + const clientsMap = this.store.get("clients"); + this.store.set("clients", { ...clientsMap, [client.id]: client }); }, handleClientLeft(clientId) { - const client = this.clientsMap[clientId]; + const clientsMap = this.store.get("clients"); + const client = clientsMap[clientId]; if (client) { - delete this.clientsMap[clientId]; - - this.broadcastLocationReport(client, { - focusableId: null, - selection: null, - }); + const [, newClientsMap] = pop(clientsMap, clientId); + this.store.set("clients", newClientsMap); if (client.id === this.followedClientId) { this.followedClientId = null; @@ -1299,26 +1252,29 @@ const Session = { }, handleClientsUpdated(updatedClients) { - updatedClients.forEach((client) => { - this.clientsMap[client.id] = client; - }); + const clientsMap = this.store.get("clients"); + const newClientsMap = { ...clientsMap }; + + for (const client of updatedClients) { + newClientsMap[client.id] = client; + } + + this.store.set("clients", newClientsMap); }, handleSecretSelected(select_secret_ref, secretName) { - globalPubSub.broadcast(`js_views:${select_secret_ref}`, { + globalPubsub.broadcast(`js_views:${select_secret_ref}`, { type: "secretSelected", secretName, }); }, handleLocationReport(clientId, report) { - const client = this.clientsMap[clientId]; + const client = this.store.get("clients")[clientId]; this.lastLocationReportByClientId[clientId] = report; if (client) { - this.broadcastLocationReport(client, report); - if ( client.id === this.followedClientId && report.focusableId !== this.focusedId @@ -1328,82 +1284,22 @@ const Session = { } }, - // Session event handlers - - handleSessionEvent(event) { - if (event.type === "cursor_selection_changed") { - this.sendLocationReport({ - focusableId: event.focusableId, - selection: event.selection, - }); - } - }, - repositionJSViews() { - globalPubSub.broadcast("js_views", { type: "reposition" }); - }, - - /** - * Broadcast new location report coming from the server to all the cells. - */ - broadcastLocationReport(client, report) { - globalPubSub.broadcast("navigation", { - type: "location_report", - client, - report, - }); + globalPubsub.broadcast("js_views", { type: "reposition" }); }, /** * Sends local location report to the server. */ sendLocationReport(report) { - const numberOfClients = Object.keys(this.clientsMap).length; + const numberOfClients = Object.keys(this.store.get("clients")).length; // Only send reports if there are other people to send to if (numberOfClients > 1) { - this.pushEvent("location_report", { - focusable_id: report.focusableId, - selection: this.encodeSelection(report.selection), - }); + this.pushEvent("location_report", { focusable_id: report.focusableId }); } }, - encodeSelection(selection) { - if (selection === null) return null; - - const { tag, editorSelection } = selection; - - return [ - tag, - editorSelection.selectionStartLineNumber, - editorSelection.selectionStartColumn, - editorSelection.positionLineNumber, - editorSelection.positionColumn, - ]; - }, - - decodeSelection(encoded) { - if (encoded === null) return null; - - const [ - tag, - selectionStartLineNumber, - selectionStartColumn, - positionLineNumber, - positionColumn, - ] = encoded; - - const editorSelection = new monaco.Selection( - selectionStartLineNumber, - selectionStartColumn, - positionLineNumber, - positionColumn - ); - - return { tag, editorSelection }; - }, - // Helpers focusedCellType() { @@ -1510,23 +1406,4 @@ const Session = { }, }; -/** - * Data of a specific LV client. - * - * @typedef Client - * @type {Object} - * @property {String} id - * @property {String} hex_color - * @property {String} name - */ - -/** - * A report of the current location sent by one of the other clients. - * - * @typedef LocationReport - * @type {Object} - * @property {String|null} focusableId - * @property {monaco.Selection|null} selection - */ - export default Session; diff --git a/assets/js/lib/delta.js b/assets/js/lib/delta.js index edbd67ed816..5e8aff06e3c 100644 --- a/assets/js/lib/delta.js +++ b/assets/js/lib/delta.js @@ -1,12 +1,13 @@ /** - * Delta is a format used to represent a set of changes introduced to a text document. + * Delta is a format used to represent a set of changes introduced to + * a text document. * - * See `Livebook.Delta` for more details. + * See `Livebook.Text.Delta` for more details. * - * An implementation of the full Delta specification is available - * in the official quill-delta package (https://github.com/quilljs/delta) - * licensed under MIT. Our version is based on that package, - * but simplified to match our non rich-text use case. + * An implementation of the full Delta specification is available in + * the official quill-delta package (https://github.com/quilljs/delta) + * licensed under MIT. Our version is based on that package, but + * simplified to match our non rich-text use case. */ export default class Delta { constructor(ops = []) { @@ -49,7 +50,7 @@ export default class Delta { /** * Appends the given operation. * - * See `Livebook.Delta.append/2` for more details. + * See `Livebook.Text.Delta.append/2` for more details. */ append(op) { if (this.ops.length === 0) { @@ -124,7 +125,7 @@ export default class Delta { * The method takes a `priority` argument indicates which delta * is considered to have happened first and is used for conflict resolution. * - * See `Livebook.Delta.Transformation` for more details. + * See `Livebook.Text.Delta.Transformation` for more details. */ transform(other, priority) { if (priority !== "left" && priority !== "right") { @@ -174,6 +175,33 @@ export default class Delta { return this; } + /** + * Transforms the given index with this delta's operations. + * + * See `Livebook.Text.Delta.Transformation` for more details. + */ + transformPosition(index) { + const thisIter = new Iterator(this.ops); + + let offset = 0; + + while (thisIter.hasNext() && offset < index) { + const op = thisIter.next(); + const length = operationLength(op); + + if (isDelete(op)) { + index -= Math.min(length, index - offset); + } else if (isInsert(op)) { + index += length; + offset += length; + } else { + offset += length; + } + } + + return index; + } + /** * Converts the delta to a compact representation, suitable for sending over the network. */ diff --git a/assets/js/lib/emitter.js b/assets/js/lib/emitter.js new file mode 100644 index 00000000000..77924b9dd7b --- /dev/null +++ b/assets/js/lib/emitter.js @@ -0,0 +1,54 @@ +/** + * An abstraction for registering and dispatching callbacks. + */ +export default class Emitter { + constructor() { + /** @private */ + this.callbacks = []; + } + + /** + * A function used to register new listener in the emitter. + * + * This is a shorthand for `addListener`. + */ + get event() { + return this.addListener.bind(this); + } + + /** + * Adds new listener to the emitter. + * + * Returns a subscription object that you can destroy in order to + * unsubscribe. + */ + addListener(callback) { + this.callbacks.push(callback); + + return { + destroy: () => { + this.removeListener(callback); + }, + }; + } + + /** + * Removes a listener from the emitter. + */ + removeListener(callback) { + const idx = this.callbacks.indexOf(callback); + + if (idx !== -1) { + this.callbacks.splice(idx, 1); + } + } + + /** + * Dispatches all listeners with the given arguments. + */ + dispatch(...args) { + this.callbacks.forEach((callback) => { + callback(...args); + }); + } +} diff --git a/assets/js/lib/key_buffer.js b/assets/js/lib/key_buffer.js index 8c0d8ff7609..cdfd32eeb54 100644 --- a/assets/js/lib/key_buffer.js +++ b/assets/js/lib/key_buffer.js @@ -1,8 +1,8 @@ /** - * Allows for recording a sequence of keys pressed - * and matching against that sequence. + * Allows for recording a sequence of keys pressed and matching against + * that sequence. */ -class KeyBuffer { +export default class KeyBuffer { /** * @param {Number} resetTimeout The number of milliseconds to wait after new key is pushed before the buffer is cleared. */ @@ -59,5 +59,3 @@ class KeyBuffer { return matches; } } - -export default KeyBuffer; diff --git a/assets/js/lib/live_store.js b/assets/js/lib/live_store.js new file mode 100644 index 00000000000..da9df0a645d --- /dev/null +++ b/assets/js/lib/live_store.js @@ -0,0 +1,78 @@ +import PubSub from "./pubsub"; + +const stores = {}; + +/** + * A reactive in-memory store implementation. + * + * Stores can be accessed by a global id. Values in a given store can + * can be watched for change. + */ +export class LiveStore { + /** @private */ + constructor(id) { + this.id = id; + + this.pubsub = new PubSub(); + this.map = {}; + } + + /** + * Creates and registers a global store under the given id. + */ + static create(id) { + const store = new LiveStore(id); + stores[id] = store; + return store; + } + + /** + * Gets a store by the given global id. + */ + static getStore(id) { + if (!stores.hasOwnProperty(id)) { + throw new Error(`No store found for id "${id}"`); + } + + return stores[id]; + } + + /** + * Destroys the store. + * + * This removes the store from the global register. + */ + destroy() { + // Another store may be created and re-registered under the given + // id, so we unregister only if it points to this store + if (stores[this.id] === this) { + delete stores[this.id]; + } + } + + /** + * Puts value in the store. + */ + set(key, value) { + this.map[key] = value; + this.pubsub.broadcast(key, value); + } + + /** + * Gets value from the store. + */ + get(key) { + if (!this.map.hasOwnProperty(key)) { + throw new Error(`Key "${key}" not found in the store`); + } + + return this.map[key]; + } + + /** + * Subscribes to changes for the given key. + */ + watch(key, callback) { + return this.pubsub.subscribe(key, callback); + } +} diff --git a/assets/js/lib/markdown.js b/assets/js/lib/markdown.js index 92191d6bb02..7a641fc5085 100644 --- a/assets/js/lib/markdown.js +++ b/assets/js/lib/markdown.js @@ -17,7 +17,7 @@ import { visit } from "unist-util-visit"; import { toText } from "hast-util-to-text"; import { removePosition } from "unist-util-remove-position"; -import { highlight } from "../hooks/cell_editor/live_editor/monaco"; +import { highlight } from "../hooks/cell_editor/live_editor/highlight"; import { renderMermaid } from "./markdown/mermaid"; import { escapeHtml } from "../lib/utils"; @@ -28,24 +28,34 @@ class Markdown { constructor( container, content, - { baseUrl = null, emptyText = "", allowedUriSchemes = [] } = {} + { + baseUrl = null, + defaultCodeLanguage = null, + emptyText = "", + allowedUriSchemes = [], + } = {} ) { this.container = container; this.content = content; this.baseUrl = baseUrl; + this.defaultCodeLanguage = defaultCodeLanguage; this.emptyText = emptyText; this.allowedUriSchemes = allowedUriSchemes; - this._render(); + this.render(); } + /** + * Sets new markdown content to be rendered in the container. + */ setContent(content) { this.content = content; - this._render(); + this.render(); } - _render() { - this._getHtml().then((html) => { + /** @private */ + render() { + this.getHtml().then((html) => { // Wrap the HTML in another element, so that we // can use morphdom's childrenOnly option const wrappedHtml = `
${html}
`; @@ -53,14 +63,18 @@ class Markdown { }); } - _getHtml() { + /** @private */ + getHtml() { return ( unified() .use(remarkParse) .use(remarkGfm) .use(remarkMath) .use(remarkPrepareMermaid) - .use(remarkSyntaxHiglight, { highlight }) + .use(remarkSyntaxHiglight, { + highlight, + defaultLanguage: this.defaultCodeLanguage, + }) // We keep the HTML nodes, parse with rehype-raw and then sanitize .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) @@ -117,13 +131,15 @@ function remarkSyntaxHiglight(options) { const promises = []; visit(ast, "code", (node) => { - if (node.lang) { + const language = node.lang || options.defaultLanguage; + + if (language) { function updateNode(html) { node.type = "html"; node.value = `
${html}
`; } - const result = options.highlight(node.value, node.lang); + const result = options.highlight(node.value, language); if (result && typeof result.then === "function") { const promise = Promise.resolve(result).then(updateNode); diff --git a/assets/js/lib/pub_sub.js b/assets/js/lib/pubsub.js similarity index 55% rename from assets/js/lib/pub_sub.js rename to assets/js/lib/pubsub.js index 4a0f8f17805..227c3c0d2a6 100644 --- a/assets/js/lib/pub_sub.js +++ b/assets/js/lib/pubsub.js @@ -1,5 +1,5 @@ /** - * A basic pub-sub implementation for client-side communication. + * A simple pub-sub implementation for dispatching events by key. */ export default class PubSub { constructor() { @@ -9,11 +9,10 @@ export default class PubSub { /** * Links the given function to the given topic. * - * Subsequent calls to `broadcast` with this topic - * will result in this function being called. + * Subsequent calls to `broadcast` with this topic will result in + * this function being called. * - * Returns a function that unsubscribes - * as a shorthand for `unsubscribe`. + * Returns a subscription object with `destroy` method that unsubscribes. */ subscribe(topic, callback) { if (!Array.isArray(this.subscribersByTopic[topic])) { @@ -22,28 +21,34 @@ export default class PubSub { this.subscribersByTopic[topic].push(callback); - return () => { - this.unsubscribe(topic, callback); + return { + destroy: () => { + this.unsubscribe(topic, callback); + }, }; } /** * Unlinks the given function from the given topic. * - * Note that you must pass the same function reference - * as you passed to `subscribe`. + * Note that you must pass the same function reference as you passed + * to `subscribe`. */ unsubscribe(topic, callback) { const idx = this.subscribersByTopic[topic].indexOf(callback); if (idx !== -1) { this.subscribersByTopic[topic].splice(idx, 1); + + if (this.subscribersByTopic[topic].length === 0) { + delete this.subscribersByTopic[topic]; + } } } /** - * Calls all functions linked to the given topic - * and passes `payload` as the argument. + * Calls all functions linked to the given topic and passes `payload` + * as the argument. */ broadcast(topic, payload) { if (Array.isArray(this.subscribersByTopic[topic])) { @@ -54,4 +59,4 @@ export default class PubSub { } } -export const globalPubSub = new PubSub(); +export const globalPubsub = new PubSub(); diff --git a/assets/js/lib/settings.js b/assets/js/lib/settings.js index d841cf0eb38..35aacd2f101 100644 --- a/assets/js/lib/settings.js +++ b/assets/js/lib/settings.js @@ -1,3 +1,4 @@ +import Emitter from "./emitter"; import { load, store } from "./storage"; const SETTINGS_KEY = "settings"; @@ -18,7 +19,7 @@ export const EDITOR_THEME = { light: "light", }; -const DEFAULT_SETTINGS = { +const DEFAULTSETTINGS = { editor_auto_completion: true, editor_auto_signature: true, editor_font_size: EDITOR_FONT_SIZE.normal, @@ -35,18 +36,20 @@ const DEFAULT_SETTINGS = { * Stores local configuration and persists it across browser sessions. */ class SettingsStore { + /** @private */ + _onChange = new Emitter(); + constructor() { - this._subscribers = []; - this._settings = DEFAULT_SETTINGS; + this.settings = DEFAULTSETTINGS; - this._loadSettings(); + this.loadSettings(); } /** * Returns the current settings. */ get() { - return this._settings; + return this.settings; } /** @@ -55,12 +58,10 @@ class SettingsStore { * The given attributes are merged into the current settings. */ update(newSettings) { - const prevSettings = this._settings; - this._settings = { ...this._settings, ...newSettings }; - this._subscribers.forEach((callback) => - callback(this._settings, prevSettings) - ); - this._storeSettings(); + const prevSettings = this.settings; + this.settings = { ...this.settings, ...newSettings }; + this._onChange.dispatch(this.settings, prevSettings); + this.storeSettings(); } /** @@ -69,33 +70,16 @@ class SettingsStore { * The given function is called immediately with the current * settings and then on every change. * - * Returns a function that unsubscribes as a shorthand for - * `unsubscribe`. + * Returns a subscription object with `destroy` method that + * unsubscribes from changes. */ getAndSubscribe(callback) { - this._subscribers.push(callback); - callback(this._settings); - - return () => { - this.unsubscribe(callback); - }; - } - - /** - * Unsubscribes the given function from updates. - * - * Note that you must pass the same function reference as you - * passed to `subscribe`. - */ - unsubscribe(callback) { - const index = this._subscribers.indexOf(callback); - - if (index !== -1) { - this._subscribers.splice(index, 1); - } + callback(this.settings); + return this._onChange.addListener(callback); } - _loadSettings() { + /** @private */ + loadSettings() { const settings = load(SETTINGS_KEY); if (settings) { @@ -104,12 +88,13 @@ class SettingsStore { delete settings.editor_theme; } - this._settings = { ...this._settings, ...settings }; + this.settings = { ...this.settings, ...settings }; } } - _storeSettings() { - store(SETTINGS_KEY, this._settings); + /** @private */ + storeSettings() { + store(SETTINGS_KEY, this.settings); } } diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js index a89a3672997..5b91b8833a9 100644 --- a/assets/js/lib/utils.js +++ b/assets/js/lib/utils.js @@ -22,23 +22,48 @@ export function isElementHidden(element) { } export function waitUntilVisible(element) { - let viewportIntersectionObserver = null; + let observer = null; return new Promise((resolve, reject) => { if (isElementHidden(element)) { - viewportIntersectionObserver = new ResizeObserver((entries) => { + observer = new ResizeObserver((entries) => { if (!isElementHidden(element)) { - viewportIntersectionObserver.disconnect(); + observer.disconnect(); resolve(); } }); - viewportIntersectionObserver.observe(element); + observer.observe(element); } else { resolve(); } }); } +export function waitUntilInViewport(element) { + let observer = null; + + const promise = new Promise((resolve, reject) => { + if (isElementVisibleInViewport(element)) { + resolve(); + } else { + observer = new IntersectionObserver((entries) => { + if (isElementVisibleInViewport(element)) { + observer.disconnect(); + observer = null; + resolve(); + } + }); + observer.observe(element); + } + }); + + const cancel = () => { + observer && observer.disconnect(); + }; + + return { promise, cancel }; +} + export function isElementVisibleInViewport(element) { return !isElementHidden(element) && isElementInViewport(element); } @@ -261,3 +286,22 @@ export function cookieOptions() { return ";SameSite=Lax"; } } + +/** + * Removes `key` from `object` and returns the associated value and + * the updated object. + */ +export function pop(object, key, defaultValue = undefined) { + if (object.hasOwnProperty(key)) { + const { [key]: value, ...newObject } = object; + return [value, newObject]; + } + + return [defaultValue, object]; +} + +export function wait(milliseconds) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), milliseconds); + }); +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 6fcf12170a6..18125a0db33 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -5,19 +5,35 @@ "packages": { "": { "dependencies": { + "@codemirror/autocomplete": "^6.11.1", + "@codemirror/commands": "^6.3.3", + "@codemirror/lang-css": "^6.2.1", + "@codemirror/lang-html": "^6.4.7", + "@codemirror/lang-javascript": "^6.2.1", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-markdown": "^6.2.3", + "@codemirror/lang-sql": "^6.5.5", + "@codemirror/lang-xml": "^6.0.2", + "@codemirror/language": "^6.10.0", + "@codemirror/legacy-modes": "^6.3.3", + "@codemirror/lint": "^6.4.2", + "@codemirror/search": "^6.5.5", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.23.0", "@fontsource/inter": "^5.0.1", "@fontsource/jetbrains-mono": "^5.0.1", "@fontsource/red-hat-text": "^5.0.1", "@picmo/popup-picker": "^5.7.6", + "@replit/codemirror-emacs": "^6.0.1", + "@replit/codemirror-vim": "^6.1.0", + "@replit/codemirror-vscode-keymap": "^6.0.2", + "codemirror-lang-elixir": "^4.0.0", "crypto-js": "^4.0.0", "esbuild": "^0.19.4", "hast-util-to-text": "^4.0.0", "hyperlist": "^1.0.0", "jest": "^29.1.2", "mermaid": "^10.0.2", - "monaco-editor": "^0.43.0", - "monaco-emacs": "^0.3.0", - "monaco-vim": "^0.4.0", "morphdom": "^2.6.1", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", @@ -1738,6 +1754,180 @@ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==" }, + "node_modules/@codemirror/autocomplete": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz", + "integrity": "sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", + "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz", + "integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.7.tgz", + "integrity": "sha512-y9hWSSO41XlcL4uYwWyk0lEgTHcelWWfRuqmvcAmxfCs0HNWZdriWo/EU43S63SxEZpc1Hd50Itw7ktfQvfkUg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.1.tgz", + "integrity": "sha512-jlFOXTejVyiQCW3EQwvKH0m99bUYIw40oPmFjSX2VS78yzfe0HELZ+NEo9Yfo1MkGRpGlj3Gnu4rdxV1EnAs5A==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", + "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.3.tgz", + "integrity": "sha512-wCewRLWpdefWi7uVkHIDiE8+45Fe4buvMDZkihqEom5uRUQrl76Zb13emjeK3W+8pcRgRfAmwelURBbxNEKCIg==", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.5.5.tgz", + "integrity": "sha512-DvOaP2RXLb2xlxJxxydTFfwyYw5YDqEFea6aAfgh9UH0kUD6J1KFZ0xPgPpw1eo/5s2w3L6uh5PVR7GM23GxkQ==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.0.2.tgz", + "integrity": "sha512-JQYZjHL2LAfpiZI2/qZ/qzDuSqmGKMwyApYmEUUCTxLM4MWS7sATUEfIguZQr9Zjx/7gcdnewb039smF6nC2zw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.0.tgz", + "integrity": "sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.3.3.tgz", + "integrity": "sha512-X0Z48odJ0KIoh/HY8Ltz75/4tDYc9msQf1E/2trlxFaFFhgjpVHjZ/BCXe1Lk7s4Gd67LL/CeEEHNI+xHOiESg==", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.4.2.tgz", + "integrity": "sha512-wzRkluWb1ptPKdzlsrbwwjYCPLgzU6N88YBAmlZi8WFyuiEduSd05MnJYNogzyc8rPK7pj6m95ptUApc8sHKVA==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.5.tgz", + "integrity": "sha512-PIEN3Ke1buPod2EHbJsoQwlbpkz30qGZKcnmH1eihq9+bPQx8gelauUwLYaY4vBOuBAuEhmpDLii4rj/uO0yMA==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz", + "integrity": "sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==" + }, + "node_modules/@codemirror/view": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.0.tgz", + "integrity": "sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.19.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.4.tgz", @@ -2842,6 +3032,85 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.0.tgz", + "integrity": "sha512-Wmvlm4q6tRpwiy20TnB3yyLTZim38Tkc50dPY8biQRwqE+ati/wD84rm3N15hikvdT4uSg9phs9ubjvcLmkpKg==" + }, + "node_modules/@lezer/css": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.7.tgz", + "integrity": "sha512-7BlFFAKNn/b39jJLrhdLSX5A2k56GIJvyLqdmm7UU+7XvequY084iuKDMAEhAmAzHnwDE8FK4OQtsIUssW91tg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.8.tgz", + "integrity": "sha512-EXseJ3pUzWxE6XQBQdqWHZqqlGQRSuNMBcLb6mZWS2J2v+QZhOObD+3ZIKIcm59ntTzyor4LqFTb72iJc3k23Q==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.12.tgz", + "integrity": "sha512-kwO5MftUiyfKBcECMEDc4HYnc10JME9kTJNPVoCXqJj/Y+ASWF0rgstORi3BThlQI6SoPSshrK5TjuiLFnr29A==", + "dependencies": { + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz", + "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.14.tgz", + "integrity": "sha512-z5mY4LStlA3yL7aHT/rqgG614cfcvklS+8oFRFBYrs4YaWLJyKKM4+nN6KopToX0o9Hj6zmH6M5kinOYuy06ug==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.2.0.tgz", + "integrity": "sha512-d7MwsfAukZJo1GpPrcPGa3MxaFFOqNp0gbqF+3F7pTeNDOgeJN1muXzx1XXDPt+Ac+/voCzsH7qXqnn+xReG/g==", + "dependencies": { + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.4.tgz", + "integrity": "sha512-WmXKb5eX8+rRfZYSNRR5TPee/ZoDgBdVS/rj1VCJGDKa5gNldIctQYibCoFVyNhvZsyL/8nHbZJZPM4gnXN2Vw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2898,6 +3167,44 @@ "node": ">=14" } }, + "node_modules/@replit/codemirror-emacs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.0.1.tgz", + "integrity": "sha512-2WYkODZGH1QVAXWuOxTMCwktkoZyv/BjYdJi2A5w4fRrmOQFuIACzb6pO9dgU3J+Pm2naeiX2C8veZr/3/r6AA==", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.2", + "@codemirror/commands": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.1", + "@codemirror/view": "^6.3.0" + } + }, + "node_modules/@replit/codemirror-vim": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.1.0.tgz", + "integrity": "sha512-XATcrMBYphSgTTDHaL5cTdBKA+/kwg8x0kHpX9xFHkI8c2G9+nXdkIzFCtk76x1VDYQSlT6orNhudNt+9H9zOA==", + "peerDependencies": { + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.1.0", + "@codemirror/search": "^6.2.0", + "@codemirror/state": "^6.0.1", + "@codemirror/view": "^6.0.3" + } + }, + "node_modules/@replit/codemirror-vscode-keymap": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vscode-keymap/-/codemirror-vscode-keymap-6.0.2.tgz", + "integrity": "sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3663,6 +3970,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codemirror-lang-elixir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/codemirror-lang-elixir/-/codemirror-lang-elixir-4.0.0.tgz", + "integrity": "sha512-mzFesxo/t6KOxwnkqVd34R/q7yk+sMtHh6vUKGAvjwHmpL7bERHB+vQAsmU/nqrndkwVeJEHWGw/z/ybfdiudA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "lezer-elixir": "^1.0.0" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -3818,6 +4134,11 @@ "node": ">=8" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7048,6 +7369,15 @@ "node": ">=6" } }, + "node_modules/lezer-elixir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.0.2.tgz", + "integrity": "sha512-YTUIn9e4WhBwlGs7ENGXwnCxAaDT44d1W6J+oTN9H3fD4jUmAuDhAteLxmZcBLm1CTsIfl1SIPNyBa3YQRM0tw==", + "dependencies": { + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -7094,16 +7424,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==" - }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -12006,31 +12326,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/monaco-editor": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.43.0.tgz", - "integrity": "sha512-cnoqwQi/9fml2Szamv1XbSJieGJ1Dc8tENVMD26Kcfl7xGQWp7OBKMjlwKVGYFJ3/AXJjSOGvcqK7Ry/j9BM1Q==" - }, - "node_modules/monaco-emacs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/monaco-emacs/-/monaco-emacs-0.3.0.tgz", - "integrity": "sha512-T7uyCIqpLBmU2dO9pzQ3KTUt2RNOFtqLVQZA0lF8oJ2nphnomSNl29WpkTCJKJVFMmGKauPVB9fknEMFibEwCA==", - "dependencies": { - "lodash.kebabcase": "^4.1.1", - "lodash.throttle": "^4.1.1" - }, - "peerDependencies": { - "monaco-editor": ">=0.31" - } - }, - "node_modules/monaco-vim": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/monaco-vim/-/monaco-vim-0.4.0.tgz", - "integrity": "sha512-+CsW0+Mvx2+eitkXS7OpUXIu57qXlqAL8oVkYhkPCEZ/c6+6gOp/IcG7w+Lb33YiZuTyvJ891+czkeJRPIEwVA==", - "peerDependencies": { - "monaco-editor": "*" - } - }, "node_modules/morphdom": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.1.tgz", @@ -13771,6 +14066,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==" + }, "node_modules/stylis": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", @@ -14334,6 +14634,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -15687,6 +15992,174 @@ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==" }, + "@codemirror/autocomplete": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz", + "integrity": "sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "@codemirror/commands": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", + "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", + "requires": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.1.0" + } + }, + "@codemirror/lang-css": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz", + "integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.0.0" + } + }, + "@codemirror/lang-html": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.7.tgz", + "integrity": "sha512-y9hWSSO41XlcL4uYwWyk0lEgTHcelWWfRuqmvcAmxfCs0HNWZdriWo/EU43S63SxEZpc1Hd50Itw7ktfQvfkUg==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, + "@codemirror/lang-javascript": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.1.tgz", + "integrity": "sha512-jlFOXTejVyiQCW3EQwvKH0m99bUYIw40oPmFjSX2VS78yzfe0HELZ+NEo9Yfo1MkGRpGlj3Gnu4rdxV1EnAs5A==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "@codemirror/lang-json": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", + "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==", + "requires": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "@codemirror/lang-markdown": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.3.tgz", + "integrity": "sha512-wCewRLWpdefWi7uVkHIDiE8+45Fe4buvMDZkihqEom5uRUQrl76Zb13emjeK3W+8pcRgRfAmwelURBbxNEKCIg==", + "requires": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/markdown": "^1.0.0" + } + }, + "@codemirror/lang-sql": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.5.5.tgz", + "integrity": "sha512-DvOaP2RXLb2xlxJxxydTFfwyYw5YDqEFea6aAfgh9UH0kUD6J1KFZ0xPgPpw1eo/5s2w3L6uh5PVR7GM23GxkQ==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "@codemirror/lang-xml": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.0.2.tgz", + "integrity": "sha512-JQYZjHL2LAfpiZI2/qZ/qzDuSqmGKMwyApYmEUUCTxLM4MWS7sATUEfIguZQr9Zjx/7gcdnewb039smF6nC2zw==", + "requires": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "@codemirror/language": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.0.tgz", + "integrity": "sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "@codemirror/legacy-modes": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.3.3.tgz", + "integrity": "sha512-X0Z48odJ0KIoh/HY8Ltz75/4tDYc9msQf1E/2trlxFaFFhgjpVHjZ/BCXe1Lk7s4Gd67LL/CeEEHNI+xHOiESg==", + "requires": { + "@codemirror/language": "^6.0.0" + } + }, + "@codemirror/lint": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.4.2.tgz", + "integrity": "sha512-wzRkluWb1ptPKdzlsrbwwjYCPLgzU6N88YBAmlZi8WFyuiEduSd05MnJYNogzyc8rPK7pj6m95ptUApc8sHKVA==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "@codemirror/search": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.5.tgz", + "integrity": "sha512-PIEN3Ke1buPod2EHbJsoQwlbpkz30qGZKcnmH1eihq9+bPQx8gelauUwLYaY4vBOuBAuEhmpDLii4rj/uO0yMA==", + "requires": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "@codemirror/state": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz", + "integrity": "sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==" + }, + "@codemirror/view": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.0.tgz", + "integrity": "sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==", + "requires": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "@esbuild/android-arm": { "version": "0.19.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.4.tgz", @@ -16394,6 +16867,85 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@lezer/common": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.0.tgz", + "integrity": "sha512-Wmvlm4q6tRpwiy20TnB3yyLTZim38Tkc50dPY8biQRwqE+ati/wD84rm3N15hikvdT4uSg9phs9ubjvcLmkpKg==" + }, + "@lezer/css": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.7.tgz", + "integrity": "sha512-7BlFFAKNn/b39jJLrhdLSX5A2k56GIJvyLqdmm7UU+7XvequY084iuKDMAEhAmAzHnwDE8FK4OQtsIUssW91tg==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "requires": { + "@lezer/common": "^1.0.0" + } + }, + "@lezer/html": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.8.tgz", + "integrity": "sha512-EXseJ3pUzWxE6XQBQdqWHZqqlGQRSuNMBcLb6mZWS2J2v+QZhOObD+3ZIKIcm59ntTzyor4LqFTb72iJc3k23Q==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "@lezer/javascript": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.12.tgz", + "integrity": "sha512-kwO5MftUiyfKBcECMEDc4HYnc10JME9kTJNPVoCXqJj/Y+ASWF0rgstORi3BThlQI6SoPSshrK5TjuiLFnr29A==", + "requires": { + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "@lezer/json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz", + "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "@lezer/lr": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.14.tgz", + "integrity": "sha512-z5mY4LStlA3yL7aHT/rqgG614cfcvklS+8oFRFBYrs4YaWLJyKKM4+nN6KopToX0o9Hj6zmH6M5kinOYuy06ug==", + "requires": { + "@lezer/common": "^1.0.0" + } + }, + "@lezer/markdown": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.2.0.tgz", + "integrity": "sha512-d7MwsfAukZJo1GpPrcPGa3MxaFFOqNp0gbqF+3F7pTeNDOgeJN1muXzx1XXDPt+Ac+/voCzsH7qXqnn+xReG/g==", + "requires": { + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "@lezer/xml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.4.tgz", + "integrity": "sha512-WmXKb5eX8+rRfZYSNRR5TPee/ZoDgBdVS/rj1VCJGDKa5gNldIctQYibCoFVyNhvZsyL/8nHbZJZPM4gnXN2Vw==", + "requires": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -16432,6 +16984,24 @@ "dev": true, "optional": true }, + "@replit/codemirror-emacs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.0.1.tgz", + "integrity": "sha512-2WYkODZGH1QVAXWuOxTMCwktkoZyv/BjYdJi2A5w4fRrmOQFuIACzb6pO9dgU3J+Pm2naeiX2C8veZr/3/r6AA==", + "requires": {} + }, + "@replit/codemirror-vim": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.1.0.tgz", + "integrity": "sha512-XATcrMBYphSgTTDHaL5cTdBKA+/kwg8x0kHpX9xFHkI8c2G9+nXdkIzFCtk76x1VDYQSlT6orNhudNt+9H9zOA==", + "requires": {} + }, + "@replit/codemirror-vscode-keymap": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vscode-keymap/-/codemirror-vscode-keymap-6.0.2.tgz", + "integrity": "sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==", + "requires": {} + }, "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -17005,6 +17575,15 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==" }, + "codemirror-lang-elixir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/codemirror-lang-elixir/-/codemirror-lang-elixir-4.0.0.tgz", + "integrity": "sha512-mzFesxo/t6KOxwnkqVd34R/q7yk+sMtHh6vUKGAvjwHmpL7bERHB+vQAsmU/nqrndkwVeJEHWGw/z/ybfdiudA==", + "requires": { + "@codemirror/language": "^6.0.0", + "lezer-elixir": "^1.0.0" + } + }, "collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -17124,6 +17703,11 @@ } } }, + "crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -19481,6 +20065,15 @@ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" }, + "lezer-elixir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.0.2.tgz", + "integrity": "sha512-YTUIn9e4WhBwlGs7ENGXwnCxAaDT44d1W6J+oTN9H3fD4jUmAuDhAteLxmZcBLm1CTsIfl1SIPNyBa3YQRM0tw==", + "requires": { + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.3.0" + } + }, "lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -19521,16 +20114,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, - "lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==" - }, - "lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" - }, "longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -22219,26 +22802,6 @@ "integrity": "sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==", "dev": true }, - "monaco-editor": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.43.0.tgz", - "integrity": "sha512-cnoqwQi/9fml2Szamv1XbSJieGJ1Dc8tENVMD26Kcfl7xGQWp7OBKMjlwKVGYFJ3/AXJjSOGvcqK7Ry/j9BM1Q==" - }, - "monaco-emacs": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/monaco-emacs/-/monaco-emacs-0.3.0.tgz", - "integrity": "sha512-T7uyCIqpLBmU2dO9pzQ3KTUt2RNOFtqLVQZA0lF8oJ2nphnomSNl29WpkTCJKJVFMmGKauPVB9fknEMFibEwCA==", - "requires": { - "lodash.kebabcase": "^4.1.1", - "lodash.throttle": "^4.1.1" - } - }, - "monaco-vim": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/monaco-vim/-/monaco-vim-0.4.0.tgz", - "integrity": "sha512-+CsW0+Mvx2+eitkXS7OpUXIu57qXlqAL8oVkYhkPCEZ/c6+6gOp/IcG7w+Lb33YiZuTyvJ891+czkeJRPIEwVA==", - "requires": {} - }, "morphdom": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.1.tgz", @@ -23372,6 +23935,11 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "style-mod": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==" + }, "stylis": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", @@ -23796,6 +24364,11 @@ } } }, + "w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/assets/package.json b/assets/package.json index 52b29cc9bc7..6465e587759 100644 --- a/assets/package.json +++ b/assets/package.json @@ -9,19 +9,35 @@ "test:watch": "jest --watch" }, "dependencies": { + "@codemirror/autocomplete": "^6.11.1", + "@codemirror/commands": "^6.3.3", + "@codemirror/lang-css": "^6.2.1", + "@codemirror/lang-html": "^6.4.7", + "@codemirror/lang-javascript": "^6.2.1", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-markdown": "^6.2.3", + "@codemirror/lang-sql": "^6.5.5", + "@codemirror/lang-xml": "^6.0.2", + "@codemirror/language": "^6.10.0", + "@codemirror/legacy-modes": "^6.3.3", + "@codemirror/lint": "^6.4.2", + "@codemirror/search": "^6.5.5", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.23.0", "@fontsource/inter": "^5.0.1", "@fontsource/jetbrains-mono": "^5.0.1", "@fontsource/red-hat-text": "^5.0.1", "@picmo/popup-picker": "^5.7.6", + "@replit/codemirror-emacs": "^6.0.1", + "@replit/codemirror-vim": "^6.1.0", + "@replit/codemirror-vscode-keymap": "^6.0.2", + "codemirror-lang-elixir": "^4.0.0", "crypto-js": "^4.0.0", "esbuild": "^0.19.4", "hast-util-to-text": "^4.0.0", "hyperlist": "^1.0.0", "jest": "^29.1.2", "mermaid": "^10.0.2", - "monaco-editor": "^0.43.0", - "monaco-emacs": "^0.3.0", - "monaco-vim": "^0.4.0", "morphdom": "^2.6.1", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", diff --git a/assets/test/lib/delta.test.js b/assets/test/lib/delta.test.js index da4c70d8481..70a908bd71a 100644 --- a/assets/test/lib/delta.test.js +++ b/assets/test/lib/delta.test.js @@ -282,4 +282,51 @@ describe("Delta", () => { expect(result).toEqual("cars"); }); }); + + describe("transformPosition", () => { + it("insert before position", () => { + const delta = new Delta().insert("A"); + expect(delta.transformPosition(2)).toEqual(3); + }); + + it("insert after position", () => { + const delta = new Delta().retain(2).insert("A"); + expect(delta.transformPosition(1)).toEqual(1); + }); + + it("insert at position", () => { + const delta = new Delta().retain(2).insert("A"); + expect(delta.transformPosition(2)).toEqual(2); + }); + + it("delete before position", () => { + const delta = new Delta().delete(2); + expect(delta.transformPosition(4)).toEqual(2); + }); + + it("delete after position", () => { + const delta = new Delta().retain(4).delete(2); + expect(delta.transformPosition(2)).toEqual(2); + }); + + it("delete across position", () => { + const delta = new Delta().retain(1).delete(4); + expect(delta.transformPosition(2)).toEqual(1); + }); + + it("insert and delete before position", () => { + const delta = new Delta().retain(2).insert("A").delete(2); + expect(delta.transformPosition(4)).toEqual(3); + }); + + it("insert before and delete across position", () => { + const delta = new Delta().retain(2).insert("A").delete(4); + expect(delta.transformPosition(4)).toEqual(3); + }); + + it("delete before and delete across position", () => { + const delta = new Delta().delete(1).retain(1).delete(4); + expect(delta.transformPosition(4)).toEqual(1); + }); + }); }); diff --git a/assets/test/lib/emitter.test.js b/assets/test/lib/emitter.test.js new file mode 100644 index 00000000000..7df0c7ee9b6 --- /dev/null +++ b/assets/test/lib/emitter.test.js @@ -0,0 +1,27 @@ +import Emitter from "../../js/lib/emitter"; + +describe("Emitter", () => { + test("listener callbacks are called on dispatch", () => { + const emitter = new Emitter(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + emitter.addListener(callback1); + emitter.addListener(callback2); + emitter.dispatch({ data: 1 }); + + expect(callback1).toHaveBeenCalledWith({ data: 1 }); + expect(callback2).toHaveBeenCalledWith({ data: 1 }); + }); + + test("addListener returns a subscription object that can be destroyed", () => { + const emitter = new Emitter(); + const callback1 = jest.fn(); + + const subscription = emitter.addListener(callback1); + subscription.destroy(); + emitter.dispatch({}); + + expect(callback1).not.toHaveBeenCalled(); + }); +}); diff --git a/assets/test/lib/pub_sub.test.js b/assets/test/lib/pubsub.test.js similarity index 53% rename from assets/test/lib/pub_sub.test.js rename to assets/test/lib/pubsub.test.js index 6188b3a003b..38af3b82404 100644 --- a/assets/test/lib/pub_sub.test.js +++ b/assets/test/lib/pubsub.test.js @@ -1,4 +1,4 @@ -import PubSub from "../../js/lib/pub_sub"; +import PubSub from "../../js/lib/pubsub"; describe("PubSub", () => { test("subscribed callback is called on the specified topic", () => { @@ -14,23 +14,12 @@ describe("PubSub", () => { expect(callback2).not.toHaveBeenCalled(); }); - test("subscribe returns a function that unsubscribes", () => { + test("subscribe returns a subscription object that can be destroyed", () => { const pubsub = new PubSub(); const callback1 = jest.fn(); - const unsubscribe = pubsub.subscribe("topic1", callback1); - unsubscribe(); - pubsub.broadcast("topic1", {}); - - expect(callback1).not.toHaveBeenCalled(); - }); - - test("unsubscribed callback is not called on the specified topic", () => { - const pubsub = new PubSub(); - const callback1 = jest.fn(); - - pubsub.subscribe("topic1", callback1); - pubsub.unsubscribe("topic1", callback1); + const subscription = pubsub.subscribe("topic1", callback1); + subscription.destroy(); pubsub.broadcast("topic1", {}); expect(callback1).not.toHaveBeenCalled(); diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index 0d89d1741e6..eb28fde20e1 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -676,7 +676,7 @@ defmodule Livebook.Config do def app_version(), do: @app_version defp parse_connection_config!(config) do - {node, cookie} = split_at_last_occurrence(config, ":") + {:ok, node, cookie} = Livebook.Utils.split_at_last_occurrence(config, ":") node = String.to_atom(node) cookie = String.to_atom(cookie) @@ -684,15 +684,6 @@ defmodule Livebook.Config do {node, cookie} end - defp split_at_last_occurrence(string, pattern) do - {idx, 1} = string |> :binary.matches(pattern) |> List.last() - - { - binary_part(string, 0, idx), - binary_part(string, idx + 1, byte_size(string) - idx - 1) - } - end - @doc """ Aborts booting due to a configuration error. """ diff --git a/lib/livebook/intellisense.ex b/lib/livebook/intellisense.ex index d01b489d54f..89cd23c6112 100644 --- a/lib/livebook/intellisense.ex +++ b/lib/livebook/intellisense.ex @@ -91,7 +91,7 @@ defmodule Livebook.Intellisense do {:ok, signature_infos, active_argument} -> %{ active_argument: active_argument, - signature_items: + items: signature_infos |> Enum.map(&format_signature_item/1) |> Enum.uniq() @@ -102,15 +102,10 @@ defmodule Livebook.Intellisense do end end - defp format_signature_item({name, signature, documentation, specs}), + defp format_signature_item({_name, signature, _documentation, _specs}), do: %{ signature: signature, - arguments: arguments_from_signature(signature), - documentation: - join_with_divider([ - format_documentation(documentation, :short), - format_specs(specs, name, @line_length) |> code() - ]) + arguments: arguments_from_signature(signature) } defp arguments_from_signature(signature) do @@ -254,7 +249,7 @@ defmodule Livebook.Intellisense do true -> # A snippet with cursor in parentheses - "#{display_name}($0)" + "#{display_name}(${})" end } @@ -278,7 +273,7 @@ defmodule Livebook.Intellisense do insert_text: cond do arity == 0 -> "#{Atom.to_string(name)}()" - true -> "#{Atom.to_string(name)}($0)" + true -> "#{Atom.to_string(name)}(${})" end } @@ -296,7 +291,7 @@ defmodule Livebook.Intellisense do if arity == 0 do Atom.to_string(name) else - "#{name}($0)" + "#{name}(${})" end %{ @@ -342,13 +337,6 @@ defmodule Livebook.Intellisense do defp extra_completion_items(hint) do items = [ - %{ - label: "do", - kind: :keyword, - detail: "do-end block", - documentation: nil, - insert_text: "do\n $0\nend" - }, %{ label: "true", kind: :keyword, diff --git a/lib/livebook/intellisense/identifier_matcher.ex b/lib/livebook/intellisense/identifier_matcher.ex index e3cd8fc0305..1bafac2984a 100644 --- a/lib/livebook/intellisense/identifier_matcher.ex +++ b/lib/livebook/intellisense/identifier_matcher.ex @@ -794,9 +794,9 @@ defmodule Livebook.Intellisense.IdentifierMatcher do :error parts -> - {start, _} = List.last(parts) - size = byte_size(string) - {:ok, binary_part(string, 0, start), binary_part(string, start + 1, size - start - 1)} + {start, length} = List.last(parts) + <> = string + {:ok, left, right} end end diff --git a/lib/livebook/intellisense/signature_matcher.ex b/lib/livebook/intellisense/signature_matcher.ex index 2a1e17de272..f904bc91bb6 100644 --- a/lib/livebook/intellisense/signature_matcher.ex +++ b/lib/livebook/intellisense/signature_matcher.ex @@ -70,7 +70,11 @@ defmodule Livebook.Intellisense.SignatureMatcher do defp fix_erlang_signature(signature, specs) do # Erlang signatures may include "->" followed by return type - signature = signature |> String.split("->") |> hd() |> String.trim() + signature = + case String.split(signature, ") ->") do + [signature] -> signature + [signature, _rest] -> signature <> ")" + end case parse_erlang_signature(signature) do {:ok, fun, arity} -> diff --git a/lib/livebook/js_interop.ex b/lib/livebook/js_interop.ex deleted file mode 100644 index c667d0f4ba3..00000000000 --- a/lib/livebook/js_interop.ex +++ /dev/null @@ -1,122 +0,0 @@ -defmodule Livebook.JSInterop do - alias Livebook.Delta - - @doc """ - Returns the result of applying `delta` to `string`. - - The delta operation lengths (retain, delete) are treated such that - they match the JavaScript strings behavior. - - JavaScript uses UTF-16 encoding, in which every character is stored - as either one or two 16-bit code units. JS treats the number of units - as string length and this also impacts position-based functions like - `String.slice`. To match this behavior we first convert normal UTF-8 - string into a list of UTF-16 code points, then apply the delta to this - list and finally convert back to a UTF-8 string. - """ - @spec apply_delta_to_string(Delta.t(), String.t()) :: String.t() - def apply_delta_to_string(delta, string) do - code_units = string_to_utf16_code_units(string) - - delta - |> Delta.operations() - |> apply_to_code_units(code_units) - |> utf16_code_units_to_string() - end - - defp apply_to_code_units([], code_units), do: code_units - - defp apply_to_code_units([{:retain, n} | ops], code_units) do - {left, right} = Enum.split(code_units, n) - left ++ apply_to_code_units(ops, right) - end - - defp apply_to_code_units([{:insert, inserted} | ops], code_units) do - string_to_utf16_code_units(inserted) ++ apply_to_code_units(ops, code_units) - end - - defp apply_to_code_units([{:delete, n} | ops], code_units) do - apply_to_code_units(ops, Enum.slice(code_units, n..-1//1)) - end - - @doc """ - Computes Myers Difference between the given strings and returns its - `Delta` representation. - - The diff is computed on UTF-16 code units and the resulting delta - is JavaScript-compatible. See `apply_delta_to_string/2` for more - details. - """ - @spec diff(String.t(), String.t()) :: Delta.t() - def diff(string1, string2) do - units1 = string_to_utf16_code_units(string1) - units2 = string_to_utf16_code_units(string2) - - units1 - |> List.myers_difference(units2) - |> Enum.reduce(Delta.new(), fn - {:eq, units}, delta -> Delta.retain(delta, length(units)) - {:ins, units}, delta -> Delta.insert(delta, utf16_code_units_to_string(units)) - {:del, units}, delta -> Delta.delete(delta, length(units)) - end) - |> Delta.trim() - end - - @doc """ - Returns a column number in the Elixir string corresponding to - the given column interpreted in terms of UTF-16 code units. - """ - @spec js_column_to_elixir(pos_integer(), String.t()) :: pos_integer() - def js_column_to_elixir(column, line) do - line - |> string_to_utf16_code_units() - |> Enum.take(column - 1) - |> utf16_code_units_to_string() - |> String.length() - |> Kernel.+(1) - end - - @doc """ - Returns a column represented in terms of UTF-16 code units - corresponding to the given column number in Elixir string. - """ - @spec elixir_column_to_js(pos_integer(), String.t()) :: pos_integer() - def elixir_column_to_js(column, line) do - line - |> string_take(column - 1) - |> string_to_utf16_code_units() - |> length() - |> Kernel.+(1) - end - - defp string_take(_string, 0), do: "" - defp string_take(string, n) when n > 0, do: String.slice(string, 0..(n - 1)) - - # UTF-16 helpers - - defp string_to_utf16_code_units(string) do - string - |> :unicode.characters_to_binary(:utf8, :utf16) - |> utf16_binary_to_code_units([]) - |> Enum.reverse() - end - - defp utf16_binary_to_code_units(<<>>, code_units), do: code_units - - defp utf16_binary_to_code_units(<>, code_units) do - utf16_binary_to_code_units(rest, [code_unit | code_units]) - end - - defp utf16_code_units_to_string(code_units) do - code_units - |> Enum.reverse() - |> code_units_to_utf16_binary(<<>>) - |> :unicode.characters_to_binary(:utf16, :utf8) - end - - defp code_units_to_utf16_binary([], utf16_binary), do: utf16_binary - - defp code_units_to_utf16_binary([code_unit | code_units], utf16_binary) do - code_units_to_utf16_binary(code_units, <>) - end -end diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index fc481fade2b..c8b31c96404 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -485,12 +485,13 @@ defprotocol Livebook.Runtime do @type doctest_report :: %{ status: :running | :success, - line: pos_integer() + line: pos_integer(), + column: pos_integer() } | %{ status: :failed, - column: pos_integer(), line: pos_integer(), + column: pos_integer(), end_line: pos_integer(), details: String.t() } @@ -562,13 +563,12 @@ defprotocol Livebook.Runtime do @type signature_response :: %{ active_argument: non_neg_integer(), - signature_items: list(signature_item()) + items: list(signature_item()) } @type signature_item :: %{ signature: String.t(), - arguments: list(String.t()), - documentation: String.t() | nil + arguments: list(String.t()) } @typedoc """ diff --git a/lib/livebook/runtime/evaluator/doctests.ex b/lib/livebook/runtime/evaluator/doctests.ex index 1390618c80d..4160f5f3b90 100644 --- a/lib/livebook/runtime/evaluator/doctests.ex +++ b/lib/livebook/runtime/evaluator/doctests.ex @@ -48,7 +48,7 @@ defmodule Livebook.Runtime.Evaluator.Doctests do tests |> Enum.sort_by(& &1.tags.doctest_line) |> Enum.each(fn test -> - report_doctest_running(test) + report_doctest_running(test, lines) test = run_test(test) report_doctest_result(test, lines) end) @@ -75,23 +75,31 @@ defmodule Livebook.Runtime.Evaluator.Doctests do end end - defp report_doctest_running(test) do + defp report_doctest_running(test, lines) do + {line, column} = doctest_line_and_column(test, lines) + send_doctest_report(%{ - line: test.tags.doctest_line, - status: :running + status: :running, + line: line, + column: column }) end - defp report_doctest_result(%{state: nil} = test, _lines) do + defp report_doctest_result(%{state: nil} = test, lines) do + {line, column} = doctest_line_and_column(test, lines) + send_doctest_report(%{ - line: test.tags.doctest_line, - status: :success + status: :success, + line: line, + column: column }) end defp report_doctest_result(%{state: {:failed, failure}} = test, lines) do + {line, column} = doctest_line_and_column(test, lines) + doctest_line = test.tags.doctest_line - [prompt_line | _] = lines = Enum.drop(lines, doctest_line - 1) + lines = Enum.drop(lines, doctest_line - 1) end_line = test.tags[:doctest_data][:end_line] end_line = @@ -112,14 +120,21 @@ defmodule Livebook.Runtime.Evaluator.Doctests do end send_doctest_report(%{ - column: count_columns(prompt_line, 0), - line: doctest_line, - end_line: end_line, status: :failed, + line: line, + column: column, + end_line: end_line, details: IO.iodata_to_binary(format_failure(failure, test)) }) end + defp doctest_line_and_column(test, lines) do + doctest_line = test.tags.doctest_line + [prompt_line | _] = Enum.drop(lines, doctest_line - 1) + column = count_columns(prompt_line, 0) + {doctest_line, column} + end + defp count_columns(" " <> rest, counter), do: count_columns(rest, counter + 1) defp count_columns("\t" <> rest, counter), do: count_columns(rest, counter + 2) defp count_columns(_, counter), do: counter diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 773f3c81242..79471579560 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -85,7 +85,7 @@ defmodule Livebook.Session do alias Livebook.NotebookManager alias Livebook.Session.{Data, FileGuard} - alias Livebook.{Utils, Notebook, Delta, Runtime, LiveMarkdown, FileSystem} + alias Livebook.{Utils, Notebook, Text, Runtime, LiveMarkdown, FileSystem} alias Livebook.Users.User alias Livebook.Notebook.{Cell, Section} @@ -526,11 +526,12 @@ defmodule Livebook.Session do pid(), Cell.id(), Data.cell_source_tag(), - Delta.t(), + Text.Delta.t(), + Selection.t() | nil, Data.cell_revision() ) :: :ok - def apply_cell_delta(pid, cell_id, tag, delta, revision) do - GenServer.cast(pid, {:apply_cell_delta, self(), cell_id, tag, delta, revision}) + def apply_cell_delta(pid, cell_id, tag, delta, selection, revision) do + GenServer.cast(pid, {:apply_cell_delta, self(), cell_id, tag, delta, selection, revision}) end @doc """ @@ -1258,9 +1259,12 @@ defmodule Livebook.Session do {:noreply, handle_operation(state, operation)} end - def handle_cast({:apply_cell_delta, client_pid, cell_id, tag, delta, revision}, state) do + def handle_cast( + {:apply_cell_delta, client_pid, cell_id, tag, delta, selection, revision}, + state + ) do client_id = client_id(state, client_pid) - operation = {:apply_cell_delta, client_id, cell_id, tag, delta, revision} + operation = {:apply_cell_delta, client_id, cell_id, tag, delta, selection, revision} {:noreply, handle_operation(state, operation)} end @@ -1669,7 +1673,7 @@ defmodule Livebook.Session do case Notebook.fetch_cell_and_section(state.data.notebook, id) do {:ok, cell, _section} -> chunks = info[:chunks] - delta = Livebook.JSInterop.diff(cell.source, info.source) + delta = Livebook.Text.Delta.diff(cell.source, info.source) operation = {:smart_cell_started, @client_id, id, delta, chunks, info.js_view, info.editor} @@ -1690,7 +1694,7 @@ defmodule Livebook.Session do case Notebook.fetch_cell_and_section(state.data.notebook, id) do {:ok, cell, _section} -> chunks = info[:chunks] - delta = Livebook.JSInterop.diff(cell.source, source) + delta = Livebook.Text.Delta.diff(cell.source, source) operation = {:update_smart_cell, @client_id, id, attrs, delta, chunks} state = handle_operation(state, operation) @@ -2001,12 +2005,12 @@ defmodule Livebook.Session do state {:ok, new_source} -> - delta = Livebook.JSInterop.diff(cell.source, new_source) - revision = state.data.cell_infos[cell.id].sources.primary.revision + 1 + delta = Livebook.Text.Delta.diff(cell.source, new_source) + revision = state.data.cell_infos[cell.id].sources.primary.revision handle_operation( state, - {:apply_cell_delta, @client_id, cell.id, :primary, delta, revision} + {:apply_cell_delta, @client_id, cell.id, :primary, delta, nil, revision} ) {:error, message} -> @@ -2143,7 +2147,7 @@ defmodule Livebook.Session do defp after_operation( state, _prev_state, - {:apply_cell_delta, _client_id, cell_id, tag, _delta, _revision} + {:apply_cell_delta, _client_id, cell_id, tag, _delta, _selection, _revision} ) do hydrate_cell_source_digest(state, cell_id, tag) @@ -2161,7 +2165,7 @@ defmodule Livebook.Session do _prev_state, {:smart_cell_started, _client_id, cell_id, delta, _chunks, _js_view, _editor} ) do - unless Delta.empty?(delta) do + unless Text.Delta.empty?(delta) do hydrate_cell_source_digest(state, cell_id, :primary) end diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index 2743535770c..215396eaf63 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -37,7 +37,7 @@ defmodule Livebook.Session.Data do :app_data ] - alias Livebook.{Notebook, Delta, Runtime, JSInterop, FileSystem, Hubs} + alias Livebook.{Notebook, Text, Runtime, FileSystem, Hubs} alias Livebook.Users.User alias Livebook.Notebook.{Cell, Section, AppSettings} alias Livebook.Utils.Graph @@ -89,7 +89,7 @@ defmodule Livebook.Session.Data do @type cell_source_info :: %{ revision: cell_revision(), - deltas: list(Delta.t()), + deltas: list(Text.Delta.t()), revision_by_client_id: %{client_id() => cell_revision()}, digest: String.t() | nil } @@ -192,9 +192,9 @@ defmodule Livebook.Session.Data do | {:reflect_main_evaluation_failure, client_id()} | {:reflect_evaluation_failure, client_id(), Section.id()} | {:cancel_cell_evaluation, client_id(), Cell.id()} - | {:smart_cell_started, client_id(), Cell.id(), Delta.t(), Runtime.chunks() | nil, + | {:smart_cell_started, client_id(), Cell.id(), Text.Delta.t(), Runtime.chunks() | nil, Runtime.js_view(), Runtime.editor() | nil} - | {:update_smart_cell, client_id(), Cell.id(), Cell.Smart.attrs(), Delta.t(), + | {:update_smart_cell, client_id(), Cell.id(), Cell.Smart.attrs(), Text.Delta.t(), Runtime.chunks() | nil} | {:queue_smart_cell_reevaluation, client_id(), Cell.id()} | {:smart_cell_down, client_id(), Cell.id()} @@ -205,8 +205,8 @@ defmodule Livebook.Session.Data do | {:client_join, client_id(), User.t()} | {:client_leave, client_id()} | {:update_user, client_id(), User.t()} - | {:apply_cell_delta, client_id(), Cell.id(), cell_source_tag(), Delta.t(), - cell_revision()} + | {:apply_cell_delta, client_id(), Cell.id(), cell_source_tag(), Text.Delta.t(), + Text.Selection.t() | nil, cell_revision()} | {:report_cell_revision, client_id(), Cell.id(), cell_source_tag(), cell_revision()} | {:set_cell_attributes, client_id(), Cell.id(), map()} | {:set_input_value, client_id(), input_id(), value :: term()} @@ -237,7 +237,7 @@ defmodule Livebook.Session.Data do | {:start_smart_cell, Cell.t(), Section.t()} | {:set_smart_cell_parents, Cell.t(), Section.t(), parent :: {Cell.t(), Section.t()} | nil} - | {:report_delta, client_id(), Cell.t(), cell_source_tag(), Delta.t()} + | {:report_delta, client_id(), Cell.t(), cell_source_tag(), Text.Delta.t()} | {:clean_up_input_values, %{input_id() => input_info()}} | :app_report_status | :app_recover @@ -791,20 +791,21 @@ defmodule Livebook.Session.Data do end end - def apply_operation(data, {:apply_cell_delta, client_id, cell_id, tag, delta, revision}) do + def apply_operation( + data, + {:apply_cell_delta, client_id, cell_id, tag, delta, selection, revision} + ) do with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), source_info <- data.cell_infos[cell_id].sources[tag], - true <- 0 < revision and revision <= source_info.revision + 1, + true <- 0 <= revision and revision <= source_info.revision, # We either need to know the client, so that we can transform # the delta, or the delta must apply to the latest revision, # in which case no transformation is necessary. The latter is - # useful when we want to apply changes programatically - true <- - Map.has_key?(data.clients_map, client_id) or - revision == source_info.revision + 1 do + # useful when we want to apply changes programmatically + true <- Map.has_key?(data.clients_map, client_id) or revision == source_info.revision do data |> with_actions() - |> apply_delta(client_id, cell, tag, delta, revision) + |> apply_delta(client_id, cell, tag, delta, selection, revision) |> set_dirty() |> wrap_ok() else @@ -815,7 +816,7 @@ defmodule Livebook.Session.Data do def apply_operation(data, {:report_cell_revision, client_id, cell_id, tag, revision}) do with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), source_info <- data.cell_infos[cell_id].sources[tag], - true <- 0 < revision and revision <= source_info.revision, + true <- 0 <= revision and revision <= source_info.revision, true <- Map.has_key?(data.clients_map, client_id) do data |> with_actions() @@ -1658,7 +1659,7 @@ defmodule Livebook.Session.Data do info = put_in(info.sources.primary, source_info) put_in(info.sources.secondary, new_source_info(editor && editor.source, data.clients_map)) end) - |> add_action({:report_delta, client_id, updated_cell, :primary, delta}) + |> add_action({:report_delta, client_id, updated_cell, :primary, delta, nil}) end defp update_smart_cell({data, _} = data_actions, cell, client_id, attrs, delta, chunks) do @@ -1677,7 +1678,7 @@ defmodule Livebook.Session.Data do |> update_cell_info!(cell.id, fn info -> put_in(info.sources.primary, source_info) end) - |> add_action({:report_delta, client_id, updated_cell, :primary, delta}) + |> add_action({:report_delta, client_id, updated_cell, :primary, delta, nil}) end defp smart_cell_down(data_actions, cell) do @@ -1855,19 +1856,27 @@ defmodule Livebook.Session.Data do set!(data_actions, users_map: Map.put(data.users_map, user.id, user)) end - defp apply_delta({data, _} = data_actions, client_id, cell, tag, delta, revision) do + defp apply_delta({data, _} = data_actions, client_id, cell, tag, delta, selection, revision) do source_info = data.cell_infos[cell.id].sources[tag] - deltas_ahead = Enum.take(source_info.deltas, -(source_info.revision - revision + 1)) + deltas_ahead = Enum.take(source_info.deltas, -(source_info.revision - revision)) - transformed_new_delta = - Enum.reduce(deltas_ahead, delta, fn delta_ahead, transformed_new_delta -> - Delta.transform(delta_ahead, transformed_new_delta, :left) + # Transform the incoming delta and selection against the already + # acknowledged deltas + {delta, selection} = + Enum.reduce(deltas_ahead, {delta, selection}, fn delta_ahead, {delta, selection} -> + {delta, delta_ahead} = + {Text.Delta.transform(delta_ahead, delta, :left), + Text.Delta.transform(delta, delta_ahead, :right)} + + selection = selection && Text.Selection.transform(selection, delta_ahead) + + {delta, selection} end) source_info = source_info - |> Map.update!(:deltas, &(&1 ++ [transformed_new_delta])) + |> Map.update!(:deltas, &(&1 ++ [delta])) |> Map.update!(:revision, &(&1 + 1)) source_info = @@ -1882,12 +1891,12 @@ defmodule Livebook.Session.Data do end {updated_cell, source_info} = - apply_delta_to_cell(cell, source_info, tag, transformed_new_delta) + apply_delta_to_cell(cell, source_info, tag, delta) data_actions |> set!(notebook: Notebook.update_cell(data.notebook, cell.id, fn _ -> updated_cell end)) |> update_cell_info!(cell.id, &put_in(&1.sources[tag], source_info)) - |> add_action({:report_delta, client_id, updated_cell, tag, transformed_new_delta}) + |> add_action({:report_delta, client_id, updated_cell, tag, delta, selection}) end # Note: the clients drop cell's source once it's no longer needed @@ -1899,7 +1908,7 @@ defmodule Livebook.Session.Data do cell = update_in(cell, source_access(cell, tag), fn :__pruned__ -> :__pruned__ - source -> JSInterop.apply_delta_to_string(delta, source) + source -> Text.Delta.apply(delta, source) end) source_info = @@ -2255,6 +2264,27 @@ defmodule Livebook.Session.Data do set!(data_actions, app_data: fun.(data.app_data)) end + @doc """ + Transforms the given selection against deltas ahead of the given + revision, if any. + """ + @spec transform_selection( + t(), + Cell.id(), + cell_source_tag(), + TextSelection.t(), + cell_revision() + ) :: Text.Selection.t() + def transform_selection(data, cell_id, tag, selection, revision) do + source_info = data.cell_infos[cell_id].sources[tag] + + deltas_ahead = Enum.take(source_info.deltas, -(source_info.revision - revision)) + + Enum.reduce(deltas_ahead, selection, fn delta_ahead, selection -> + Text.Selection.transform(selection, delta_ahead) + end) + end + @doc """ Builds evaluation parent sequence for every evaluable cell. diff --git a/lib/livebook/delta.ex b/lib/livebook/text/delta.ex similarity index 67% rename from lib/livebook/delta.ex rename to lib/livebook/text/delta.ex index 4b9fd26054c..b06c832c203 100644 --- a/lib/livebook/delta.ex +++ b/lib/livebook/text/delta.ex @@ -1,4 +1,4 @@ -defmodule Livebook.Delta do +defmodule Livebook.Text.Delta do # Delta is a format used to represent a set of changes introduced # to a text document. # @@ -9,18 +9,29 @@ defmodule Livebook.Delta do # https://quilljs.com/guides/designing-the-delta-format. The # specification covers rich-text editing, while we only need to # work with plain-text, so we use a subset of the specification - # with operations listed in `Livebook.Delta.Operation`. + # with operations listed in `Livebook.Text.Delta.Operation`. # # An implementation of the full Delta specification is available in # the :text_delta package (https://github.com/deltadoc/text_delta) # by Konstantin Kudryashov under the MIT license. This module builds # directly on that package, and is simplified to better fit our not # rich-text use case. + # + # ## Lengths and offsets + # + # Delta operations involve strings and string lengths. The exact + # definition of these depends on the string representation in the + # given programming language. This module is implemented for + # compatibility with JavaScript, hence all lengths match the length + # as defined by JavaScript (the number of UTF-16 code units). + # + # All places with calls to the `Livebook.Text.JS` module are the + # ones where the implementation is affected by this distinction. defstruct ops: [] - alias Livebook.Delta - alias Livebook.Delta.{Operation, Transformation} + alias Livebook.Text.{Delta, JS} + alias Livebook.Text.Delta.{Operation, Transformation} @typedoc """ Delta carries a list of consecutive operations. @@ -135,12 +146,12 @@ defmodule Livebook.Delta do @doc """ Converts the given delta to a compact representation, suitable for - sending over the network. + JSON serialization. ## Examples iex> delta = Delta.new([retain: 2, insert: "hey", delete: 3]) - iex> Livebook.Delta.to_compressed(delta) + iex> Livebook.Text.Delta.to_compressed(delta) [2, "hey", -3] """ @@ -156,8 +167,8 @@ defmodule Livebook.Delta do ## Examples - iex> delta = Livebook.Delta.from_compressed([2, "hey", -3]) - iex> Livebook.Delta.operations(delta) + iex> delta = Livebook.Text.Delta.from_compressed([2, "hey", -3]) + iex> Livebook.Text.Delta.operations(delta) [retain: 2, insert: "hey", delete: 3] """ @@ -172,4 +183,47 @@ defmodule Livebook.Delta do end defdelegate transform(left, right, priority), to: Transformation + + defdelegate transform_position(delta, index), to: Transformation + + @doc """ + Returns the result of applying `delta` to `string`. + """ + @spec apply(Delta.t(), String.t()) :: String.t() + def apply(delta, string) do + do_apply(operations(delta), <<>>, string) + end + + defp do_apply([{:retain, n} | ops], result, string) do + {left, right} = JS.split_at(string, n) + do_apply(ops, <>, right) + end + + defp do_apply([{:insert, inserted} | ops], result, string) do + do_apply(ops, <>, string) + end + + defp do_apply([{:delete, n} | ops], result, string) do + do_apply(ops, result, JS.slice(string, n)) + end + + defp do_apply([], result, string) do + <> + end + + @doc """ + Computes Myers Difference between the given strings and returns its + `Delta` representation. + """ + @spec diff(String.t(), String.t()) :: Delta.t() + def diff(string1, string2) do + string1 + |> String.myers_difference(string2) + |> Enum.reduce(Delta.new(), fn + {:eq, string}, delta -> Delta.retain(delta, JS.length(string)) + {:ins, string}, delta -> Delta.insert(delta, string) + {:del, string}, delta -> Delta.delete(delta, JS.length(string)) + end) + |> Delta.trim() + end end diff --git a/lib/livebook/delta/operation.ex b/lib/livebook/text/delta/operation.ex similarity index 92% rename from lib/livebook/delta/operation.ex rename to lib/livebook/text/delta/operation.ex index 7200d771c2b..17eb56bbfdd 100644 --- a/lib/livebook/delta/operation.ex +++ b/lib/livebook/text/delta/operation.ex @@ -1,4 +1,4 @@ -defmodule Livebook.Delta.Operation do +defmodule Livebook.Text.Delta.Operation do # An operation represents an atomic change applicable to a text. # # For plain-text (our use case) an operation can be either of: @@ -36,7 +36,7 @@ defmodule Livebook.Delta.Operation do Returns length of text affected by a given operation. """ @spec length(t()) :: non_neg_integer() - def length({:insert, string}), do: String.length(string) + def length({:insert, string}), do: Livebook.Text.JS.length(string) def length({:retain, length}), do: length def length({:delete, length}), do: length @@ -47,7 +47,7 @@ defmodule Livebook.Delta.Operation do def split_at(op, position) def split_at({:insert, string}, position) do - {part_one, part_two} = String.split_at(string, position) + {part_one, part_two} = Livebook.Text.JS.split_at(string, position) {insert(part_one), insert(part_two)} end @@ -83,7 +83,7 @@ defmodule Livebook.Delta.Operation do iex> left = [{:insert, "cat"}] iex> right = [{:retain, 2}, {:delete, 2}] - iex> Livebook.Delta.Operation.align_heads(left, right) + iex> Livebook.Text.Delta.Operation.align_heads(left, right) { [{:insert, "ca"}, {:insert, "t"}], [{:retain, 2}, {:delete, 2}] diff --git a/lib/livebook/delta/transformation.ex b/lib/livebook/text/delta/transformation.ex similarity index 68% rename from lib/livebook/delta/transformation.ex rename to lib/livebook/text/delta/transformation.ex index 9702352afb9..3f9a3020201 100644 --- a/lib/livebook/delta/transformation.ex +++ b/lib/livebook/text/delta/transformation.ex @@ -1,4 +1,4 @@ -defmodule Livebook.Delta.Transformation do +defmodule Livebook.Text.Delta.Transformation do # Implementation of the Operational Transformation algorithm for # deltas. # @@ -19,8 +19,8 @@ defmodule Livebook.Delta.Transformation do # all the clients send deltas, as it naturally imposes the necessary # ordering. - alias Livebook.Delta - alias Livebook.Delta.Operation + alias Livebook.Text.Delta + alias Livebook.Text.Delta.Operation @type priority :: :left | :right @@ -107,4 +107,46 @@ defmodule Livebook.Delta.Transformation do |> Operation.length() |> Operation.retain() end + + @doc """ + Transforms `index` against `delta`. + + The primary usage of this function is to transform text selections. + This transformation maps the position into a matching location in + any text that this delta would be applied to. + + Note that OT guarantees documents to converge, however transforming + positions does not provide the same guarantee. In practice, convergence + is achieved most of the time, except certain edge cases where inserts + and deletions happen around the transformed position. For more context + see [this writeup](https://marijnhaverbeke.nl/blog/collaborative-editing-cm.html#position-mapping). + + In the implementation, one decision we need to make is whether the + position should shift when an insert happens at that position. Since + either way we do not get convergence in all cases, we can choose any + behaviour and we choose no to shift the position. With this variant + replacements adjacent to the transformed position do not move the + position across the replacement, which is a more intuitive behaviour. + """ + @spec transform_position(Delta.t(), non_neg_integer()) :: non_neg_integer() + def transform_position(delta, index) do + do_transform_position(Delta.operations(delta), index, 0) + end + + defp do_transform_position([], index, _offset), do: index + defp do_transform_position(_ops, index, offset) when offset >= index, do: index + + defp do_transform_position([{:delete, length} | ops], index, offset) do + index = index - min(length, index - offset) + do_transform_position(ops, index, offset) + end + + defp do_transform_position([{:insert, _} = op | ops], index, offset) do + length = Operation.length(op) + do_transform_position(ops, index + length, offset + length) + end + + defp do_transform_position([{:retain, length} | ops], index, offset) do + do_transform_position(ops, index, offset + length) + end end diff --git a/lib/livebook/text/js.ex b/lib/livebook/text/js.ex new file mode 100644 index 00000000000..faa74882760 --- /dev/null +++ b/lib/livebook/text/js.ex @@ -0,0 +1,122 @@ +defmodule Livebook.Text.JS do + # String operations replicating the JavaScript behaviour. + # + # JavaScript uses UTF-16 encoding, in which every character is stored + # as either one or two 16-bit code units. String length is the number + # of these units. This also impacts position-based functions such as + # `String.slice`. + # + # Functions in this module work with regular Elixir strings, but + # use lengths and offsets according to the above definition. + + import Kernel, except: [length: 1] + + @doc """ + Returns the length of the given string. + + The length is defined as the number of UTF-16 code units used to + encode the string. + """ + @spec length(String.t()) :: non_neg_integer() + def length(string) do + do_length(string, 0) + end + + defp do_length(<<>>, length), do: length + + defp do_length(<>, length) do + do_length(rest, length + num_code_units(codepoint)) + end + + @doc """ + Returns a substring starting at the offset `start`. + + See `slice/3` for slicing a specific length. + """ + @spec slice(String.t(), non_neg_integer()) :: String.t() + def slice(string, start) do + drop(string, start) + end + + @doc """ + Returns a substring starting at the offset `start` and having the + given `length`. + """ + @spec slice(String.t(), non_neg_integer(), non_neg_integer()) :: String.t() + def slice(string, start, length) do + string |> drop(start) |> take(length) + end + + @doc """ + Splits a string into two parts at the specified offset. + """ + @spec split_at(String.t(), non_neg_integer()) :: {String.t(), String.t()} + def split_at(string, position) do + offset = byte_offset(string, position) + <> = string + {left, right} + end + + defp drop(<>, n) when n > 0 do + drop(rest, n - num_code_units(codepoint)) + end + + defp drop(string, 0), do: string + + defp take(string, n) do + offset = byte_offset(string, n) + binary_part(string, 0, offset) + end + + defp byte_offset(string, length), do: byte_offset(string, length, 0) + + defp byte_offset(_string, 0, offset), do: offset + + defp byte_offset(<> = string, length, offset) when length > 0 do + num_bytes = byte_size(string) - byte_size(rest) + byte_offset(rest, length - num_code_units(codepoint), offset + num_bytes) + end + + defp num_code_units(codepoint) do + num_utf16_bytes = byte_size(<>) + div(num_utf16_bytes, 2) + end + + @doc """ + Maps `column` pointing to a UTF-16 code unit in `line` to the + corresponding grapheme it is a part of. + """ + @spec js_column_to_elixir(pos_integer(), String.t()) :: pos_integer() + def js_column_to_elixir(column, line) when column > 0 do + do_js_column_to_elixir(line, column, 0) + end + + defp do_js_column_to_elixir(<<>>, _column, num_graphemes), do: num_graphemes + + defp do_js_column_to_elixir(line, column, num_graphemes) do + {grapheme, line} = String.next_grapheme(line) + length = length(grapheme) + + if column <= length do + num_graphemes + 1 + else + do_js_column_to_elixir(line, column - length, num_graphemes + 1) + end + end + + @doc """ + Maps `column` pointing to a grapheme in `line` to the first UTF-16 + code unit in that grapheme. + """ + @spec elixir_column_to_js(pos_integer(), String.t()) :: pos_integer() + def elixir_column_to_js(column, line) when column > 0 do + do_elixir_column_to_js(line, column, 0) + end + + defp do_elixir_column_to_js(_line, 1, length), do: length + 1 + + defp do_elixir_column_to_js(line, column, length) do + {grapheme, line} = String.next_grapheme(line) + do_elixir_column_to_js(line, column - 1, length + length(grapheme)) + end +end diff --git a/lib/livebook/text/selection.ex b/lib/livebook/text/selection.ex new file mode 100644 index 00000000000..c1562b3980a --- /dev/null +++ b/lib/livebook/text/selection.ex @@ -0,0 +1,58 @@ +defmodule Livebook.Text.Selection do + # A text selection holding one or more selection ranges. + # + # This is a minimal representation of an editor selection, specifically + # for the purpose of serialization and transformation. It does not + # perform any normalizations, such as deduplication and range merging, + # since this is already handled on the client during deserialization. + + defstruct [:ranges] + + @type t :: %__MODULE__{ranges: list(range())} + + @type range :: {anchor :: non_neg_integer(), head :: non_neg_integer()} + + def new(ranges) do + if ranges == [] do + raise ArgumentError, "text selection must have at least a single range" + end + + %__MODULE__{ranges: ranges} + end + + @doc """ + Transforms `selection` against `delta`. + + Selections are ultimately a group of positions, so each position is + transformed. See `Livebook.Text.Delta.transform_position/2` for more + details. + """ + @spec transform(t(), Livebook.Text.Delta.t()) :: t() + def transform(selection, delta) do + ranges = + for {anchor, head} <- selection.ranges do + {Livebook.Text.Delta.transform_position(delta, anchor), + Livebook.Text.Delta.transform_position(delta, head)} + end + + %__MODULE__{ranges: ranges} + end + + @doc """ + Converts the given selection to a compact representation, suitable + for JSON serialization. + """ + @spec to_compressed(t()) :: list() + def to_compressed(selection) do + for {anchor, head} <- selection.ranges, do: [anchor, head] + end + + @doc """ + Builds a new selection from the given compact representation. + """ + @spec from_compressed(list()) :: t() + def from_compressed(list) do + ranges = for [anchor, head] <- list, do: {anchor, head} + %__MODULE__{ranges: ranges} + end +end diff --git a/lib/livebook/utils.ex b/lib/livebook/utils.ex index 279d4e90ae2..f1403b6afc1 100644 --- a/lib/livebook/utils.ex +++ b/lib/livebook/utils.ex @@ -446,6 +446,9 @@ defmodule Livebook.Utils do iex> Livebook.Utils.split_at_last_occurrence("1,2,3", ",") {:ok, "1,2", "3"} + iex> Livebook.Utils.split_at_last_occurrence("1<>2<>3", "<>") + {:ok, "1<>2", "3"} + iex> Livebook.Utils.split_at_last_occurrence("123", ",") :error @@ -458,9 +461,9 @@ defmodule Livebook.Utils do :error parts -> - {start, _} = List.last(parts) - size = byte_size(string) - {:ok, binary_part(string, 0, start), binary_part(string, start + 1, size - start - 1)} + {start, length} = List.last(parts) + <> = string + {:ok, left, right} end end diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 5aff274a81b..2c72e3f2a4b 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -6,9 +6,8 @@ defmodule LivebookWeb.SessionLive do import LivebookWeb.FileSystemHelpers import Livebook.Utils, only: [format_bytes: 1] - alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown} + alias Livebook.{Sessions, Session, Text, Notebook, Runtime, LiveMarkdown} alias Livebook.Notebook.{Cell, ContentLoader} - alias Livebook.JSInterop on_mount LivebookWeb.SidebarHook @@ -46,6 +45,7 @@ defmodule LivebookWeb.SessionLive do socket = if connected?(socket) do payload = %{ + client_id: client_id, clients: Enum.map(data.clients_map, fn {client_id, user_id} -> client_info(client_id, data.users_map[user_id]) @@ -1246,12 +1246,37 @@ defmodule LivebookWeb.SessionLive do def handle_event( "apply_cell_delta", - %{"cell_id" => cell_id, "tag" => tag, "delta" => delta, "revision" => revision}, + %{ + "cell_id" => cell_id, + "tag" => tag, + "delta" => delta, + "selection" => selection, + "revision" => revision + }, socket ) do tag = String.to_atom(tag) - delta = Delta.from_compressed(delta) - Session.apply_cell_delta(socket.assigns.session.pid, cell_id, tag, delta, revision) + delta = Text.Delta.from_compressed(delta) + selection = selection && Text.Selection.from_compressed(selection) + Session.apply_cell_delta(socket.assigns.session.pid, cell_id, tag, delta, selection, revision) + + {:noreply, socket} + end + + def handle_event( + "report_cell_selection", + %{"cell_id" => cell_id, "tag" => tag, "selection" => selection, "revision" => revision}, + socket + ) do + selection = selection && Text.Selection.from_compressed(selection) + tag = String.to_atom(tag) + + Phoenix.PubSub.broadcast_from( + Livebook.PubSub, + self(), + "sessions:#{socket.assigns.session.id}", + {:report_cell_selection, socket.assigns.client_id, cell_id, tag, selection, revision} + ) {:noreply, socket} end @@ -1473,7 +1498,7 @@ defmodule LivebookWeb.SessionLive do {:completion, hint} %{"type" => "details", "line" => line, "column" => column} -> - column = JSInterop.js_column_to_elixir(column, line) + column = Text.JS.js_column_to_elixir(column, line) {:details, line, column} %{"type" => "signature", "hint" => hint} -> @@ -1823,6 +1848,37 @@ defmodule LivebookWeb.SessionLive do {:noreply, push_event(socket, "location_report", report)} end + def handle_info({:report_cell_selection, client_id, cell_id, tag, selection, revision}, socket) do + # Note that we transform the delta separately in each client's LV. + # This way we avoid a race condition with session sending us a new + # delta. We either acknowledge such delta and use it to transform + # the incoming selection, or send the selection as is and only then + # acknowledge the delta. + + case Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) do + {:ok, _cell, _section} -> + selection = + selection && + Session.Data.transform_selection( + socket.private.data, + cell_id, + tag, + selection, + revision + ) + + payload = %{ + "client_id" => client_id, + "selection" => selection && Text.Selection.to_compressed(selection) + } + + {:noreply, push_event(socket, "cell_selection:#{cell_id}:#{tag}", payload)} + + _ -> + {:noreply, socket} + end + end + def handle_info({:set_input_values, values, local}, socket) do if local do socket = @@ -2243,11 +2299,15 @@ defmodule LivebookWeb.SessionLive do Enum.reduce(actions, socket, &handle_action(&2, &1)) end - defp handle_action(socket, {:report_delta, client_id, cell, tag, delta}) do + defp handle_action(socket, {:report_delta, client_id, cell, tag, delta, selection}) do if client_id == socket.assigns.client_id do push_event(socket, "cell_acknowledgement:#{cell.id}:#{tag}", %{}) else - push_event(socket, "cell_delta:#{cell.id}:#{tag}", %{delta: Delta.to_compressed(delta)}) + push_event(socket, "cell_delta:#{cell.id}:#{tag}", %{ + client_id: client_id, + delta: Text.Delta.to_compressed(delta), + selection: selection && Text.Selection.to_compressed(selection) + }) end end @@ -2356,19 +2416,23 @@ defmodule LivebookWeb.SessionLive do %{ response | range: %{ - from: JSInterop.elixir_column_to_js(from, line), - to: JSInterop.elixir_column_to_js(to, line) + from: Text.JS.elixir_column_to_js(from, line), + to: Text.JS.elixir_column_to_js(to, line) } } end - # Currently we don't use signature docs, so we optimise the response - # to exclude them - defp process_intellisense_response( - %{signature_items: signature_items} = response, - {:signature, _hint} - ) do - %{response | signature_items: Enum.map(signature_items, &%{&1 | documentation: nil})} + defp process_intellisense_response(%{code: code} = response, {:format, original_code}) do + delta = + if code do + original_code + |> Text.Delta.diff(code) + |> Text.Delta.to_compressed() + end + + response + |> Map.delete(:code) + |> Map.put(:delta, delta) end defp process_intellisense_response(response, _request), do: response @@ -2566,8 +2630,8 @@ defmodule LivebookWeb.SessionLive do defp doctest_report_payload(doctest_report) do Map.replace_lazy(doctest_report, :details, fn details -> details - |> LivebookWeb.Helpers.ANSI.ansi_string_to_html_lines() - |> Enum.map(&Phoenix.HTML.safe_to_string/1) + |> LivebookWeb.Helpers.ANSI.ansi_string_to_html() + |> Phoenix.HTML.safe_to_string() end) end @@ -2838,7 +2902,7 @@ defmodule LivebookWeb.SessionLive do {:report_cell_revision, _client_id, _cell_id, _tag, _revision} -> data_view - {:apply_cell_delta, _client_id, _cell_id, _tag, _delta, _revision} -> + {:apply_cell_delta, _client_id, _cell_id, _tag, _delta, _selection, _revision} -> update_dirty_status(data_view, data) {:update_smart_cell, _client_id, _cell_id, _cell_state, _delta, _chunks} -> diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index 958d469ac6d..4d14625fdf0 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -75,7 +75,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do />
-
- <.content_skeleton bg_class="bg-gray-500" empty={@empty} /> +
+
+ <.content_skeleton bg_class="bg-gray-500" empty={@empty} /> +
diff --git a/lib/livebook_web/live/session_live/shortcuts_component.ex b/lib/livebook_web/live/session_live/shortcuts_component.ex index c7ae6c5b2f9..9463291624d 100644 --- a/lib/livebook_web/live/session_live/shortcuts_component.ex +++ b/lib/livebook_web/live/session_live/shortcuts_component.ex @@ -19,8 +19,8 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do }, %{ seq: ["ctrl", "shift", "i"], - seq_mac: ["⇧", "⌥", "f"], - seq_windows: ["shift", "alt", "f"], + seq_mac: ["⌥", "⇧", "f"], + seq_windows: ["alt", "shift", "f"], press_all: true, desc: "Format Elixir code", basic: true diff --git a/test/livebook/delta_test.exs b/test/livebook/delta_test.exs deleted file mode 100644 index ca4711aa0f4..00000000000 --- a/test/livebook/delta_test.exs +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Livebook.DeltaTest do - use ExUnit.Case, async: true - - alias Livebook.Delta - alias Livebook.Delta.Operation - - doctest Delta - - describe "append/2" do - test "ignores empty operations" do - assert Delta.new() |> Delta.append({:insert, ""}) |> Delta.operations() == [] - assert Delta.new() |> Delta.append({:retain, 0}) |> Delta.operations() == [] - assert Delta.new() |> Delta.append({:delete, 0}) |> Delta.operations() == [] - end - - test "given empty delta just appends the operation" do - delta = Delta.new() - op = Operation.insert("cats") - assert delta |> Delta.append(op) |> Delta.operations() == [insert: "cats"] - end - - test "merges consecutive inserts" do - delta = Delta.new() |> Delta.insert("cats") - op = Operation.insert(" rule") - assert delta |> Delta.append(op) |> Delta.operations() == [insert: "cats rule"] - end - - test "merges consecutive retains" do - delta = Delta.new() |> Delta.retain(2) - op = Operation.retain(2) - assert delta |> Delta.append(op) |> Delta.operations() == [retain: 4] - end - - test "merges consecutive delete" do - delta = Delta.new() |> Delta.delete(2) - op = Operation.delete(2) - assert delta |> Delta.append(op) |> Delta.operations() == [delete: 4] - end - - test "given insert appended after delete, swaps the operations" do - delta = Delta.new() |> Delta.delete(2) - op = Operation.insert("cats") - assert delta |> Delta.append(op) |> Delta.operations() == [insert: "cats", delete: 2] - end - end -end diff --git a/test/livebook/intellisense_test.exs b/test/livebook/intellisense_test.exs index 06db97d16dc..5080d4662c4 100644 --- a/test/livebook/intellisense_test.exs +++ b/test/livebook/intellisense_test.exs @@ -58,7 +58,7 @@ defmodule Livebook.IntellisenseTest do @spec length(list()) :: non_neg_integer() ```\ """, - insert_text: "length($0)" + insert_text: "length(${})" } assert length_item in Intellisense.get_completion_items("", context, node()) @@ -75,8 +75,7 @@ defmodule Livebook.IntellisenseTest do label: ":zlib", kind: :module, detail: "module", - documentation: - "This module provides an API for the zlib library ([www.zlib.net](http://www.zlib.net)). It is used to compress and decompress data. The data format is described by [RFC 1950](https://www.ietf.org/rfc/rfc1950.txt), [RFC 1951](https://www.ietf.org/rfc/rfc1951.txt), and [RFC 1952](https://www.ietf.org/rfc/rfc1952.txt).", + documentation: "zlib compression interface.", insert_text: "zlib" } ] = Intellisense.get_completion_items(":zl", context, node()) @@ -117,7 +116,7 @@ defmodule Livebook.IntellisenseTest do label: ":lists", kind: :module, detail: "module", - documentation: "This module contains functions for list processing.", + documentation: "List processing functions.", insert_text: "lists" } @@ -142,7 +141,7 @@ defmodule Livebook.IntellisenseTest do kind: :function, detail: ":erlang.open_port/2", documentation: _open_port_doc, - insert_text: "open_port($0)" + insert_text: "open_port(${})" } ] = Intellisense.get_completion_items(":erlang.open_por", context, node()) end @@ -178,7 +177,7 @@ defmodule Livebook.IntellisenseTest do @opaque iterator(key, value) ```\ """, - insert_text: "iterator($0)" + insert_text: "iterator(${})" } in items end @@ -288,7 +287,7 @@ defmodule Livebook.IntellisenseTest do @opaque internal(value) ```\ """, - insert_text: "internal($0)" + insert_text: "internal(${})" } ] = Intellisense.get_completion_items("MapSet.intern", context, node()) end @@ -436,7 +435,7 @@ defmodule Livebook.IntellisenseTest do compressed: binary() ```\ """, - insert_text: "gzip($0)" + insert_text: "gzip(${})" } in Intellisense.get_completion_items(":zlib.gz", context, node()) end @@ -455,7 +454,7 @@ defmodule Livebook.IntellisenseTest do @spec concat(t()) :: t() ```\ """, - insert_text: "concat($0)" + insert_text: "concat(${})" } in Intellisense.get_completion_items("Enum.concat/", context, node()) assert [ @@ -480,7 +479,7 @@ defmodule Livebook.IntellisenseTest do @spec concat(t()) :: t() ```\ """, - insert_text: "concat($0)" + insert_text: "concat(${})" }, %{ label: "concat/2", @@ -494,7 +493,7 @@ defmodule Livebook.IntellisenseTest do @spec concat(t(), t()) :: t() ```\ """, - insert_text: "concat($0)" + insert_text: "concat(${})" } ] = Intellisense.get_completion_items("Enum.concat", context, node()) end @@ -527,7 +526,7 @@ defmodule Livebook.IntellisenseTest do @spec utc_today(Calendar.calendar()) :: t() ```\ """, - insert_text: "utc_today($0)" + insert_text: "utc_today(${})" } ] = Intellisense.get_completion_items("Date.utc", context, node()) end @@ -754,7 +753,7 @@ defmodule Livebook.IntellisenseTest do kind: :function, detail: "Kernel.is_nil(term)", documentation: "Returns `true` if `term` is `nil`, `false` otherwise.", - insert_text: "is_nil($0)" + insert_text: "is_nil(${})" } ] = Intellisense.get_completion_items("Kernel.is_ni", context, node()) end @@ -782,7 +781,7 @@ defmodule Livebook.IntellisenseTest do kind: :function, detail: "Kernel.put_in(path, value)", documentation: "Puts a value in a nested structure via the given `path`.", - insert_text: "put_in($0)" + insert_text: "put_in(${})" }, %{ label: "put_in/3", @@ -799,7 +798,7 @@ defmodule Livebook.IntellisenseTest do ) :: Access.t() ```\ """, - insert_text: "put_in($0)" + insert_text: "put_in(${})" } ] = Intellisense.get_completion_items("put_i", context, node()) end @@ -1039,7 +1038,7 @@ defmodule Livebook.IntellisenseTest do when list: [t, ...], max: t, t: term() ```\ """, - insert_text: "max($0)" + insert_text: "max(${})" } in Intellisense.get_completion_items("EList.", context, node()) assert [] = Intellisense.get_completion_items("EList.Invalid", context, node()) @@ -1258,14 +1257,14 @@ defmodule Livebook.IntellisenseTest do assert [ %{ - label: "do", + label: "nil", kind: :keyword, - detail: "do-end block", + detail: "special atom", documentation: nil, - insert_text: "do\n $0\nend" + insert_text: "nil" } | _ - ] = Intellisense.get_completion_items("do", context, node()) + ] = Intellisense.get_completion_items("nil", context, node()) end test "includes space instead of parentheses for def* macros" do @@ -1318,7 +1317,7 @@ defmodule Livebook.IntellisenseTest do %{ detail: "bitstring option", documentation: nil, - insert_text: "size($0)", + insert_text: "size(${})", kind: :bitstring_option, label: "size" } @@ -1432,10 +1431,10 @@ defmodule Livebook.IntellisenseTest do test "properly renders Erlang signature types list" do context = eval(do: nil) - assert %{contents: [file_read]} = - Intellisense.get_details(":odbc.connect()", 8, context, node()) + assert %{contents: [xmerl_callbacks]} = + Intellisense.get_details(":xmerl.callbacks(Mod)", 8, context, node()) - assert file_read =~ "Ref = connection_reference()" + assert xmerl_callbacks =~ "Result = [atom()]" end test "properly parses unicode" do @@ -1581,21 +1580,10 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "map(enumerable, fun)", - arguments: ["enumerable", "fun"], - documentation: """ - Returns a list where each element is the result of invoking - `fun` on each corresponding element of `enumerable`. - - --- - - ``` - @spec map(t(), (element() -> any())) :: - list() - ```\ - """ + arguments: ["enumerable", "fun"] } ] } = Intellisense.get_signature_items("Enum.map(", context, node()) @@ -1606,19 +1594,10 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "length(list)", - arguments: ["list"], - documentation: """ - Returns the length of `list`. - - --- - - ``` - @spec length(list()) :: non_neg_integer() - ```\ - """ + arguments: ["list"] } ] } = Intellisense.get_signature_items("length(", context, node()) @@ -1633,22 +1612,20 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "map(enumerable, fun)", - arguments: ["enumerable", "fun"], - documentation: _map_doc + arguments: ["enumerable", "fun"] } ] } = Intellisense.get_signature_items("map(", context, node()) assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: ~S"derive(protocol, module, options \\ [])", - arguments: ["protocol", "module", ~S"options \\ []"], - documentation: _derive_doc + arguments: ["protocol", "module", ~S"options \\ []"] } ] } = Intellisense.get_signature_items("derive(", context, node()) @@ -1662,11 +1639,10 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "map(enumerable, fun)", - arguments: ["enumerable", "fun"], - documentation: _map_doc + arguments: ["enumerable", "fun"] } ] } = Intellisense.get_signature_items("MyEnum.map(", context, node()) @@ -1680,13 +1656,10 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "add.(arg1, arg2)", - arguments: ["arg1", "arg2"], - documentation: """ - No documentation available\ - """ + arguments: ["arg1", "arg2"] } ] } = Intellisense.get_signature_items("add.(", context, node()) @@ -1700,11 +1673,10 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "map(enumerable, fun)", - arguments: ["enumerable", "fun"], - documentation: _map_doc + arguments: ["enumerable", "fun"] } ] } = Intellisense.get_signature_items("map.(", context, node()) @@ -1716,11 +1688,10 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "map(fun, list1)", - arguments: ["fun", "list1"], - documentation: _map_doc + arguments: ["fun", "list1"] } ] } = Intellisense.get_signature_items(":lists.map(", context, node()) @@ -1732,82 +1703,95 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ + %{ + signature: "callbacks(Module)", + arguments: ["Module"] + } + ] + } = Intellisense.get_signature_items(":xmerl.callbacks(", context, node()) + end + + test "shows signature with default argument being an anonymous function" do + context = eval(do: nil) + + assert %{ + active_argument: 3, + items: [ %{ - signature: "connect(ConnectStr, Options)", - arguments: ["ConnectStr", "Options"], - documentation: _connect_doc + signature: + ~S"max_by(enumerable, fun, sorter \\ &>=/2, empty_fallback \\ fn -> raise Enum.EmptyError end)", + arguments: [ + "enumerable", + "fun", + ~S"sorter \\ &>=/2", + ~S"empty_fallback \\ fn -> raise Enum.EmptyError end" + ] } ] - } = Intellisense.get_signature_items(":odbc.connect(", context, node()) + } = + Intellisense.get_signature_items( + "Enum.max_by([1, 2], &Kernel.-/1, &>=/2, ", + context, + node() + ) end test "returns call active argument" do context = eval(do: nil) - assert %{active_argument: 0, signature_items: [_item]} = + assert %{active_argument: 0, items: [_item]} = Intellisense.get_signature_items("Enum.map([1, ", context, node()) - assert %{active_argument: 1, signature_items: [_item]} = + assert %{active_argument: 1, items: [_item]} = Intellisense.get_signature_items("Enum.map([1, 2], ", context, node()) - assert %{active_argument: 1, signature_items: [_item]} = + assert %{active_argument: 1, items: [_item]} = Intellisense.get_signature_items("Enum.map([1, 2], fn", context, node()) - assert %{active_argument: 1, signature_items: [_item]} = + assert %{active_argument: 1, items: [_item]} = Intellisense.get_signature_items( "Enum.map([1, 2], fn x -> x * x end", context, node() ) - assert %{active_argument: 2, signature_items: [_item]} = + assert %{active_argument: 2, items: [_item]} = Intellisense.get_signature_items("IO.ANSI.color(1, 2, 3", context, node()) - assert %{active_argument: 1, signature_items: [_item]} = + assert %{active_argument: 1, items: [_item]} = Intellisense.get_signature_items("elem(x, 1 + ", context, node()) end test "returns correct active argument when using pipe operator" do context = eval(do: nil) - assert %{active_argument: 1, signature_items: [_item]} = + assert %{active_argument: 1, items: [_item]} = Intellisense.get_signature_items("[1, 2] |> Enum.map(", context, node()) - assert %{active_argument: 1, signature_items: [_item]} = + assert %{active_argument: 1, items: [_item]} = Intellisense.get_signature_items("[1, 2] |> Enum.map(fn", context, node()) - assert %{active_argument: 1, signature_items: [_item]} = + assert %{active_argument: 1, items: [_item]} = Intellisense.get_signature_items( "[1, 2] |> Enum.map(fn x -> x * x end", context, node() ) - assert %{active_argument: 2, signature_items: [_item]} = + assert %{active_argument: 2, items: [_item]} = Intellisense.get_signature_items("1 |> IO.ANSI.color(2, 3", context, node()) end - test "returns a single signature for fnuctions with default arguments" do + test "returns a single signature for functions with default arguments" do context = eval(do: nil) assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: ~S"to_string(integer, base \\ 10)", - arguments: ["integer", ~S"base \\ 10"], - documentation: """ - Returns a binary which corresponds to the text representation - of `integer` in the given `base`. - - --- - - ``` - @spec to_string(integer(), 2..36) :: - String.t() - ```\ - """ + arguments: ["integer", ~S"base \\ 10"] } ] } = Intellisense.get_signature_items("Integer.to_string(", context, node()) @@ -1818,16 +1802,14 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ signature: "concat(enumerables)", - arguments: ["enumerables"], - documentation: _concat_1_docs + arguments: ["enumerables"] }, %{ signature: "concat(left, right)", - arguments: ["left", "right"], - documentation: _concat_2_docs + arguments: ["left", "right"] } ] } = Intellisense.get_signature_items("Enum.concat(", context, node()) @@ -1838,11 +1820,10 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 1, - signature_items: [ + items: [ %{ signature: "concat(left, right)", - arguments: ["left", "right"], - documentation: _concat_1_docs + arguments: ["left", "right"] } ] } = Intellisense.get_signature_items("Enum.concat([1, 2], ", context, node()) @@ -1865,10 +1846,9 @@ defmodule Livebook.IntellisenseTest do assert %{ active_argument: 0, - signature_items: [ + items: [ %{ arguments: ["list"], - documentation: _length_doc, signature: "length(list)" } ] diff --git a/test/livebook/js_interop_test.exs b/test/livebook/js_interop_test.exs deleted file mode 100644 index 40c322a2d94..00000000000 --- a/test/livebook/js_interop_test.exs +++ /dev/null @@ -1,111 +0,0 @@ -defmodule Livebook.JSInteropTest do - use ExUnit.Case, async: true - - alias Livebook.{JSInterop, Delta} - - describe "apply_delta_to_string/2" do - test "prepend" do - string = "cats" - delta = Delta.new() |> Delta.insert("fat ") - assert JSInterop.apply_delta_to_string(delta, string) == "fat cats" - end - - test "insert in the middle" do - string = "cats" - delta = Delta.new() |> Delta.retain(3) |> Delta.insert("'") - assert JSInterop.apply_delta_to_string(delta, string) == "cat's" - end - - test "delete" do - string = "cats" - delta = Delta.new() |> Delta.retain(1) |> Delta.delete(2) - assert JSInterop.apply_delta_to_string(delta, string) == "cs" - end - - test "replace" do - string = "cats" - delta = Delta.new() |> Delta.retain(1) |> Delta.delete(2) |> Delta.insert("ar") - assert JSInterop.apply_delta_to_string(delta, string) == "cars" - end - - test "retain skips the given number UTF-16 code units" do - # 🚀 consists of 2 UTF-16 code units, so JavaScript assumes "🚀".length is 2 - string = "🚀 cats" - # Skip the emoji (2 code unit) and the space (1 code unit) - delta = Delta.new() |> Delta.retain(3) |> Delta.insert("my ") - assert JSInterop.apply_delta_to_string(delta, string) == "🚀 my cats" - end - - test "delete removes the given number UTF-16 code units" do - # 🚀 consists of 2 UTF-16 code units, so JavaScript assumes "🚀".length is 2 - string = "🚀 cats" - delta = Delta.new() |> Delta.delete(2) - assert JSInterop.apply_delta_to_string(delta, string) == " cats" - end - end - - describe "diff/2" do - test "insert" do - assert JSInterop.diff("cats", "cat's") == - Delta.new() |> Delta.retain(3) |> Delta.insert("'") - end - - test "delete" do - assert JSInterop.diff("cats", "cs") == - Delta.new() |> Delta.retain(1) |> Delta.delete(2) - end - - test "replace" do - assert JSInterop.diff("cats", "cars") == - Delta.new() |> Delta.retain(2) |> Delta.delete(1) |> Delta.insert("r") - end - - test "retain skips the given number UTF-16 code units" do - assert JSInterop.diff("🚀 cats", "🚀 my cats") == - Delta.new() |> Delta.retain(3) |> Delta.insert("my ") - end - - test "delete removes the given number UTF-16 code units" do - assert JSInterop.diff("🚀 cats", " cats") == - Delta.new() |> Delta.delete(2) - end - end - - describe "js_column_to_elixir/2" do - test "keeps the column as is for ASCII characters" do - column = 4 - line = "String.replace" - assert JSInterop.js_column_to_elixir(column, line) == 4 - end - - test "shifts the column given characters spanning multiple UTF-16 code units" do - # 🚀 consists of 2 UTF-16 code units, so JavaScript assumes "🚀".length is 2 - column = 7 - line = "🚀🚀 String.replace" - assert JSInterop.js_column_to_elixir(column, line) == 5 - end - - test "returns proper column if a middle UTF-16 code unit is given" do - # 🚀 consists of 2 UTF-16 code units, so JavaScript assumes "🚀".length is 2 - # 3th and 4th code unit correspond to the second 🚀 - column = 3 - line = "🚀🚀 String.replace" - assert JSInterop.js_column_to_elixir(column, line) == 2 - end - end - - describe "elixir_column_to_js/2" do - test "keeps the column as is for ASCII characters" do - column = 4 - line = "String.replace" - assert JSInterop.elixir_column_to_js(column, line) == 4 - end - - test "shifts the column given characters spanning multiple UTF-16 code units" do - # 🚀 consists of 2 UTF-16 code units, so JavaScript assumes "🚀".length is 2 - column = 5 - line = "🚀🚀 String.replace" - assert JSInterop.elixir_column_to_js(column, line) == 7 - end - end -end diff --git a/test/livebook/remote_intellisense_test.exs b/test/livebook/remote_intellisense_test.exs index a671bb35cad..edc7875a56f 100644 --- a/test/livebook/remote_intellisense_test.exs +++ b/test/livebook/remote_intellisense_test.exs @@ -87,7 +87,7 @@ defmodule Livebook.RemoteIntellisenseTest do kind: :function, detail: "RemoteModule.hello(message)", documentation: "Hello doc", - insert_text: "hello($0)" + insert_text: "hello(${})" } in Intellisense.get_completion_items("RemoteModule.hel", context, node) end @@ -101,7 +101,7 @@ defmodule Livebook.RemoteIntellisenseTest do kind: :function, detail: ":mnesia.all_keys/1", documentation: _all_keys_doc, - insert_text: "all_keys($0)" + insert_text: "all_keys(${})" } ] = Intellisense.get_completion_items(":mnesia.all", context, node) end diff --git a/test/livebook/session/data_test.exs b/test/livebook/session/data_test.exs index f443967aaba..022e0533ef0 100644 --- a/test/livebook/session/data_test.exs +++ b/test/livebook/session/data_test.exs @@ -4,7 +4,8 @@ defmodule Livebook.Session.DataTest do import Livebook.TestHelpers alias Livebook.Session.Data - alias Livebook.{Delta, Notebook} + alias Livebook.{Text, Notebook} + alias Livebook.Text.Delta alias Livebook.Users.User @eval_resp %{type: :terminal_text, text: ":ok", chunk: false} @@ -2988,7 +2989,7 @@ defmodule Livebook.Session.DataTest do %{ notebook: %{sections: [%{cells: [%{id: "c1", source: "content"}]}]} }, - [{:report_delta, ^client_id, _cell, :primary, ^delta}]} = + [{:report_delta, ^client_id, _cell, :primary, ^delta, nil}]} = Data.apply_operation(data, operation) end end @@ -3018,7 +3019,7 @@ defmodule Livebook.Session.DataTest do sections: [%{cells: [%{id: "c1", source: "content!", attrs: ^attrs}]}] } }, - [{:report_delta, ^client_id, _cell, :primary, ^delta2}]} = + [{:report_delta, ^client_id, _cell, :primary, ^delta2, nil}]} = Data.apply_operation(data, operation) end end @@ -3263,7 +3264,7 @@ defmodule Livebook.Session.DataTest do {:client_join, client1_id, user}, {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_id, "c1", :primary, delta1, 1} + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0} ]) client2_id = "cid2" @@ -3338,7 +3339,7 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_id, User.new()}, {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_id, "c1", :primary, delta1, 1} + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0} ]) operation = {:client_leave, client2_id} @@ -3389,7 +3390,7 @@ defmodule Livebook.Session.DataTest do {:client_join, @cid, User.new()} ]) - operation = {:apply_cell_delta, @cid, "nonexistent", :primary, Delta.new(), 1} + operation = {:apply_cell_delta, @cid, "nonexistent", :primary, Delta.new(), nil, 0} assert :error = Data.apply_operation(data, operation) end @@ -3402,7 +3403,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, @cid, "c1", :primary, delta, 5} + operation = {:apply_cell_delta, @cid, "c1", :primary, delta, nil, 5} assert :error = Data.apply_operation(data, operation) end @@ -3417,11 +3418,11 @@ defmodule Livebook.Session.DataTest do {:client_join, client1_id, User.new()}, {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_id, "c1", :primary, delta1, 1} + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0} ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, @cid, "c1", :primary, delta, 1} + operation = {:apply_cell_delta, @cid, "c1", :primary, delta, nil, 0} assert :error = Data.apply_operation(data, operation) end @@ -3433,7 +3434,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, @cid, "c1", :primary, delta, 1} + operation = {:apply_cell_delta, @cid, "c1", :primary, delta, nil, 0} assert {:ok, %{ @@ -3458,11 +3459,11 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_id, User.new()}, {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_id, "c1", :primary, delta1, 1} + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0} ]) delta2 = Delta.new() |> Delta.insert("tea") - operation = {:apply_cell_delta, client2_id, "c1", :primary, delta2, 1} + operation = {:apply_cell_delta, client2_id, "c1", :primary, delta2, nil, 0} assert {:ok, %{ @@ -3475,7 +3476,7 @@ defmodule Livebook.Session.DataTest do }, _} = Data.apply_operation(data, operation) end - test "returns broadcast delta action with the transformed delta" do + test "returns broadcast delta action with the transformed delta and selection" do client1_id = "cid1" client2_id = "cid2" @@ -3487,16 +3488,21 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_id, User.new()}, {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_id, "c1", :primary, delta1, 1} + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0} ]) delta2 = Delta.new() |> Delta.insert("tea") - operation = {:apply_cell_delta, client2_id, "c1", :primary, delta2, 1} + selection = Text.Selection.new([{1, 3}]) + operation = {:apply_cell_delta, client2_id, "c1", :primary, delta2, selection, 0} transformed_delta2 = Delta.new() |> Delta.retain(4) |> Delta.insert("tea") + transformed_selection = Text.Selection.new([{5, 7}]) - assert {:ok, _data, [{:report_delta, ^client2_id, _cell, :primary, ^transformed_delta2}]} = - Data.apply_operation(data, operation) + assert {:ok, _data, + [ + {:report_delta, ^client2_id, _cell, :primary, ^transformed_delta2, + ^transformed_selection} + ]} = Data.apply_operation(data, operation) end test "given single client, does not keep deltas" do @@ -3510,7 +3516,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, client_id, "c1", :primary, delta, 1} + operation = {:apply_cell_delta, client_id, "c1", :primary, delta, nil, 0} assert {:ok, %{ @@ -3531,7 +3537,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, client1_id, "c1", :primary, delta, 1} + operation = {:apply_cell_delta, client1_id, "c1", :primary, delta, nil, 0} assert {:ok, %{ @@ -3552,7 +3558,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, @cid, "c1", :secondary, delta, 1} + operation = {:apply_cell_delta, @cid, "c1", :secondary, delta, nil, 0} assert {:ok, %{ @@ -3586,7 +3592,7 @@ defmodule Livebook.Session.DataTest do {:client_join, client1_id, User.new()}, {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_id, "c1", :primary, Delta.new(insert: "cats"), 1} + {:apply_cell_delta, client1_id, "c1", :primary, Delta.new(insert: "cats"), nil, 0} ]) operation = {:report_cell_revision, client2_id, "c1", :primary, 1} @@ -3617,7 +3623,7 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_id, User.new()}, {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_id, "c1", :primary, delta1, 1} + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0} ]) operation = {:report_cell_revision, client2_id, "c1", :primary, 1} @@ -4326,6 +4332,49 @@ defmodule Livebook.Session.DataTest do end end + describe "transform_selection/2" do + test "transforms the selection against more recent revisions" do + client1_id = "cid1" + client2_id = "cid2" + + delta1 = Delta.new() |> Delta.insert("cats") + delta2 = Delta.new() |> Delta.insert("tea") + + data = + data_after_operations!([ + {:client_join, client1_id, User.new()}, + {:client_join, client2_id, User.new()}, + {:insert_section, @cid, 0, "s1"}, + {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0}, + {:apply_cell_delta, client1_id, "c1", :primary, delta2, nil, 1} + ]) + + selection = Text.Selection.new([{1, 3}]) + + assert Data.transform_selection(data, "c1", :primary, selection, 1) == + Text.Selection.new([{4, 6}]) + end + + test "keeps the selection unchanged if the revision is already latest" do + client1_id = "cid1" + + delta1 = Delta.new() |> Delta.insert("cats") + + data = + data_after_operations!([ + {:client_join, client1_id, User.new()}, + {:insert_section, @cid, 0, "s1"}, + {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, + {:apply_cell_delta, client1_id, "c1", :primary, delta1, nil, 0} + ]) + + selection = Text.Selection.new([{1, 3}]) + + assert Data.transform_selection(data, "c1", :primary, selection, 1) == selection + end + end + describe "bound_cells_with_section/2" do test "returns an empty list when an invalid input id is given" do data = Data.new() @@ -4368,7 +4417,7 @@ defmodule Livebook.Session.DataTest do ), # Modify cell 2 {:client_join, @cid, User.new()}, - {:apply_cell_delta, @cid, "c2", :primary, Delta.new() |> Delta.insert("cats"), 1} + {:apply_cell_delta, @cid, "c2", :primary, Delta.new() |> Delta.insert("cats"), nil, 0} ]) assert Data.cell_ids_for_full_evaluation(data, []) |> Enum.sort() == ["c2", "c4"] diff --git a/test/livebook/session_test.exs b/test/livebook/session_test.exs index 046981cca1c..dafdd9adc9a 100644 --- a/test/livebook/session_test.exs +++ b/test/livebook/session_test.exs @@ -4,7 +4,7 @@ defmodule Livebook.SessionTest do import Livebook.HubHelpers import Livebook.TestHelpers - alias Livebook.{Session, Delta, Runtime, Utils, Notebook, FileSystem, Apps, App} + alias Livebook.{Session, Text, Runtime, Utils, Notebook, FileSystem, Apps, App} alias Livebook.Notebook.{Section, Cell} alias Livebook.Session.Data alias Livebook.NotebookManager @@ -223,7 +223,8 @@ defmodule Livebook.SessionTest do Session.add_dependencies(session.pid, [%{dep: {:jason, "~> 1.3.0"}, config: []}]) - assert_receive {:operation, {:apply_cell_delta, "__server__", "setup", :primary, _delta, 1}} + assert_receive {:operation, + {:apply_cell_delta, "__server__", "setup", :primary, _delta, _selection, 0}} assert %{ notebook: %{ @@ -338,13 +339,15 @@ defmodule Livebook.SessionTest do {_section_id, cell_id} = insert_section_and_cell(session.pid) - delta = Delta.new() |> Delta.insert("cats") - revision = 1 + delta = Text.Delta.new() |> Text.Delta.insert("cats") + selection = Text.Selection.new([{1, 1}]) + revision = 0 - Session.apply_cell_delta(session.pid, cell_id, :primary, delta, revision) + Session.apply_cell_delta(session.pid, cell_id, :primary, delta, selection, revision) assert_receive {:operation, - {:apply_cell_delta, _client_id, ^cell_id, :primary, ^delta, ^revision}} + {:apply_cell_delta, _client_id, ^cell_id, :primary, ^delta, ^selection, + ^revision}} # Sends new digest to clients digest = :erlang.md5("cats") @@ -947,7 +950,7 @@ defmodule Livebook.SessionTest do %{source: "content!", js_view: %{}, editor: nil}} ) - delta = Delta.new() |> Delta.retain(7) |> Delta.insert("!") + delta = Text.Delta.new() |> Text.Delta.retain(7) |> Text.Delta.insert("!") cell_id = smart_cell.id assert_receive {:operation, {:smart_cell_started, _, ^cell_id, ^delta, nil, %{}, nil}} @@ -981,8 +984,8 @@ defmodule Livebook.SessionTest do Session.register_client(session.pid, self(), Livebook.Users.User.new()) - delta = Delta.new() |> Delta.retain(7) |> Delta.insert("!") - Session.apply_cell_delta(session.pid, smart_cell.id, :secondary, delta, 1) + delta = Text.Delta.new() |> Text.Delta.retain(7) |> Text.Delta.insert("!") + Session.apply_cell_delta(session.pid, smart_cell.id, :secondary, delta, nil, 0) assert_receive {:editor_source, "content!"} end diff --git a/test/livebook/delta/transformation_test.exs b/test/livebook/text/delta/transformation_test.exs similarity index 71% rename from test/livebook/delta/transformation_test.exs rename to test/livebook/text/delta/transformation_test.exs index 157e59a9b7a..3d194662dcc 100644 --- a/test/livebook/delta/transformation_test.exs +++ b/test/livebook/text/delta/transformation_test.exs @@ -1,9 +1,9 @@ -defmodule Livebook.Delta.TransformationText do +defmodule Livebook.Text.Delta.TransformationText do use ExUnit.Case, async: true - alias Livebook.Delta + alias Livebook.Text.Delta - describe "transform" do + describe "transform/3" do test "insert against insert" do a = Delta.new() @@ -269,5 +269,74 @@ defmodule Livebook.Delta.TransformationText do assert Delta.transform(a, b, :right) == b_prime assert Delta.transform(b, a, :left) == a_prime end + + test "uses utf16 code units as lengths" do + a = + Delta.new() + |> Delta.insert("🚀") + + b = + Delta.new() + |> Delta.insert("B") + + b_prime = + Delta.new() + |> Delta.retain(2) + |> Delta.insert("B") + + assert Delta.transform(a, b, :left) == b_prime + end + end + + describe "transform_position/2" do + test "insert before position" do + delta = Delta.new() |> Delta.insert("A") + assert Delta.transform_position(delta, 2) == 3 + end + + test "insert after position" do + delta = Delta.new() |> Delta.retain(2) |> Delta.insert("A") + assert Delta.transform_position(delta, 1) == 1 + end + + test "insert at position" do + delta = Delta.new() |> Delta.retain(2) |> Delta.insert("A") + assert Delta.transform_position(delta, 2) == 2 + end + + test "delete before position" do + delta = Delta.new() |> Delta.delete(2) + assert Delta.transform_position(delta, 4) == 2 + end + + test "delete after position" do + delta = Delta.new() |> Delta.retain(4) |> Delta.delete(2) + assert Delta.transform_position(delta, 2) == 2 + end + + test "delete across position" do + delta = Delta.new() |> Delta.retain(1) |> Delta.delete(4) + assert Delta.transform_position(delta, 2) == 1 + end + + test "insert and delete before position" do + delta = Delta.new() |> Delta.retain(2) |> Delta.insert("A") |> Delta.delete(2) + assert Delta.transform_position(delta, 4) == 3 + end + + test "insert before and delete across position" do + delta = Delta.new() |> Delta.retain(2) |> Delta.insert("A") |> Delta.delete(4) + assert Delta.transform_position(delta, 4) == 3 + end + + test "delete before and delete across position" do + delta = Delta.new() |> Delta.delete(1) |> Delta.retain(1) |> Delta.delete(4) + assert Delta.transform_position(delta, 4) == 1 + end + + test "uses utf16 code units as lengths" do + delta = Delta.new() |> Delta.insert("🚀") + assert Delta.transform_position(delta, 2) == 4 + end end end diff --git a/test/livebook/text/delta_test.exs b/test/livebook/text/delta_test.exs new file mode 100644 index 00000000000..62adcabcbd1 --- /dev/null +++ b/test/livebook/text/delta_test.exs @@ -0,0 +1,114 @@ +defmodule Livebook.Text.DeltaTest do + use ExUnit.Case, async: true + + alias Livebook.Text.Delta + alias Livebook.Text.Delta.Operation + + doctest Delta + + describe "append/2" do + test "ignores empty operations" do + assert Delta.new() |> Delta.append({:insert, ""}) |> Delta.operations() == [] + assert Delta.new() |> Delta.append({:retain, 0}) |> Delta.operations() == [] + assert Delta.new() |> Delta.append({:delete, 0}) |> Delta.operations() == [] + end + + test "given empty delta just appends the operation" do + delta = Delta.new() + op = Operation.insert("cats") + assert delta |> Delta.append(op) |> Delta.operations() == [insert: "cats"] + end + + test "merges consecutive inserts" do + delta = Delta.new() |> Delta.insert("cats") + op = Operation.insert(" rule") + assert delta |> Delta.append(op) |> Delta.operations() == [insert: "cats rule"] + end + + test "merges consecutive retains" do + delta = Delta.new() |> Delta.retain(2) + op = Operation.retain(2) + assert delta |> Delta.append(op) |> Delta.operations() == [retain: 4] + end + + test "merges consecutive delete" do + delta = Delta.new() |> Delta.delete(2) + op = Operation.delete(2) + assert delta |> Delta.append(op) |> Delta.operations() == [delete: 4] + end + + test "given insert appended after delete, swaps the operations" do + delta = Delta.new() |> Delta.delete(2) + op = Operation.insert("cats") + assert delta |> Delta.append(op) |> Delta.operations() == [insert: "cats", delete: 2] + end + end + + describe "apply/2" do + test "prepend" do + string = "cats" + delta = Delta.new() |> Delta.insert("fat ") + assert Delta.apply(delta, string) == "fat cats" + end + + test "insert in the middle" do + string = "cats" + delta = Delta.new() |> Delta.retain(3) |> Delta.insert("'") + assert Delta.apply(delta, string) == "cat's" + end + + test "delete" do + string = "cats" + delta = Delta.new() |> Delta.retain(1) |> Delta.delete(2) + assert Delta.apply(delta, string) == "cs" + end + + test "replace" do + string = "cats" + delta = Delta.new() |> Delta.retain(1) |> Delta.delete(2) |> Delta.insert("ar") + assert Delta.apply(delta, string) == "cars" + end + + test "retain skips the given number UTF-16 code units" do + # 🚀 consists of 2 UTF-16 code units, so JavaScript assumes "🚀".length is 2 + string = "🚀 cats" + # Skip the emoji (2 code unit) and the space (1 code unit) + delta = Delta.new() |> Delta.retain(3) |> Delta.insert("my ") + assert Delta.apply(delta, string) == "🚀 my cats" + end + + test "delete removes the given number UTF-16 code units" do + # 🚀 consists of 2 UTF-16 code units, so JavaScript assumes "🚀".length is 2 + string = "🚀 cats" + delta = Delta.new() |> Delta.delete(2) + assert Delta.apply(delta, string) == " cats" + end + end + + describe "diff/2" do + test "insert" do + assert Delta.diff("cats", "cat's") == + Delta.new() |> Delta.retain(3) |> Delta.insert("'") + end + + test "delete" do + assert Delta.diff("cats", "cs") == + Delta.new() |> Delta.retain(1) |> Delta.delete(2) + end + + test "replace" do + assert Delta.diff("cats", "cars") == + Delta.new() |> Delta.retain(2) |> Delta.delete(1) |> Delta.insert("r") + end + + test "retain skips the given number UTF-16 code units" do + assert Delta.diff("🚀 cats", "🚀 my cats") == + Delta.new() |> Delta.retain(3) |> Delta.insert("my ") + end + + test "delete removes the given number UTF-16 code units" do + assert Delta.diff("🚀 cats", " cats") == + Delta.new() |> Delta.delete(2) + end + end +end diff --git a/test/livebook/text/js_test.exs b/test/livebook/text/js_test.exs new file mode 100644 index 00000000000..2a0c0061b18 --- /dev/null +++ b/test/livebook/text/js_test.exs @@ -0,0 +1,134 @@ +defmodule Livebook.Text.JSTest do + use ExUnit.Case, async: true + + alias Livebook.Text.JS + + describe "length/1" do + test "counts ASCII characters" do + string = "fox in a box" + assert JS.length(string) == 12 + end + + test "counts characters spanning multiple UTF-16 code units" do + # 🦊 and 📦 consist of 2 UTF-16 code units + string = "🦊 in a 📦" + assert JS.length(string) == 10 + end + + test "counts combining characters separately" do + # This is a single grapheme cluster, but two separate Unicode + # code points, one UTF-16 code unit each + string = "\u0065\u0301" + assert JS.length(string) == 2 + end + end + + describe "slice/2" do + test "slices ASCII characters" do + string = "fox in a box" + assert JS.slice(string, 4) == "in a box" + end + + test "slices characters spanning multiple UTF-16 code units" do + string = "🦊 in a 📦" + assert JS.slice(string, 3) == "in a 📦" + end + + test "slices combining characters" do + string = "\u0065\u0301" + assert JS.slice(string, 1) == "\u0301" + end + end + + describe "slice/3" do + test "slices ASCII characters" do + string = "fox in a box" + assert JS.slice(string, 4, 2) == "in" + end + + test "slices characters spanning multiple UTF-16 code units" do + string = "🦊 in a 📦" + assert JS.slice(string, 0, 5) == "🦊 in" + end + + test "slices combining characters" do + string = "\u0065\u0301" + assert JS.slice(string, 0, 1) == "\u0065" + end + end + + describe "split_at/2" do + test "splits ASCII characters" do + string = "fox in a box" + assert JS.split_at(string, 6) == {"fox in", " a box"} + end + + test "splits characters spanning multiple UTF-16 code units" do + string = "🦊 in a 📦" + assert JS.split_at(string, 5) == {"🦊 in", " a 📦"} + end + + test "splits combining characters" do + string = "\u0065\u0301" + assert JS.split_at(string, 1) == {"\u0065", "\u0301"} + end + end + + describe "js_column_to_elixir/2" do + test "keeps the column as is for ASCII characters" do + column = 4 + line = "String.replace" + assert JS.js_column_to_elixir(column, line) == 4 + end + + test "shifts the column given characters spanning multiple UTF-16 code units" do + column = 7 + line = "🦊🦊 String.replace" + assert JS.js_column_to_elixir(column, line) == 5 + end + + test "returns proper column if the first of a UTF-16 code unit pair is given" do + column = 3 + line = "🦊🦊 String.replace" + assert JS.js_column_to_elixir(column, line) == 2 + end + + test "returns proper column if the second of a UTF-16 code unit pair is given" do + column = 4 + line = "🦊🦊 String.replace" + assert JS.js_column_to_elixir(column, line) == 2 + end + + test "returns proper column if a combining Unicode characters is given" do + column = 2 + line = "\u0065\u0301 String.replace" + assert JS.js_column_to_elixir(column, line) == 1 + end + end + + describe "elixir_column_to_js/2" do + test "keeps the column as is for ASCII characters" do + column = 4 + line = "String.replace" + assert JS.elixir_column_to_js(column, line) == 4 + end + + test "shifts the column given characters spanning multiple UTF-16 code units" do + column = 5 + line = "🦊🦊 String.replace" + assert JS.elixir_column_to_js(column, line) == 7 + end + + test "returns column corresponding to the first UTF-16 code unit" do + column = 2 + line = "🦊🦊 String.replace" + assert JS.elixir_column_to_js(column, line) == 3 + end + + test "returns column corresponding to the first character in a grapheme cluster" do + column = 1 + line = "\u0065\u0301 String.replace" + assert JS.elixir_column_to_js(column, line) == 1 + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 1ab7a75e96e..0e5b0130cdc 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -56,8 +56,7 @@ Livebook.TeamsServer.setup() windows? = match?({:win32, _}, :os.type()) erl_docs_exclude = - if match?({:error, _}, Code.fetch_docs(:gen_server)) or - match?({:error, _}, Code.fetch_docs(:odbc)) do + if match?({:error, _}, Code.fetch_docs(:gen_server)) do [erl_docs: true] else []