From 6db36ea7e6999ad377d4c10c2a606410334a1fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Mon, 14 Mar 2022 22:19:56 +0100 Subject: [PATCH] Add support for smart cell editor (#1050) * Add support for smart cell editor * Log an error when smart cell fails to start --- assets/css/editor.css | 2 +- assets/js/app.js | 2 + assets/js/cell/index.js | 293 ++++++++++-------- assets/js/cell_editor/index.js | 62 ++++ .../js/{cell => cell_editor}/live_editor.js | 28 +- .../live_editor/editor_client.js | 0 .../on_type_formatting_edit_provider.js | 0 .../live_editor/hook_server_adapter.js | 23 +- .../live_editor/monaco.js | 0 .../live_editor/monaco_editor_adapter.js | 0 .../live_editor/remote_user.js | 0 .../live_editor/theme.js | 0 assets/js/highlight/index.js | 2 +- assets/js/js_view/index.js | 23 +- assets/js/{cell => lib}/markdown.js | 2 +- assets/js/{cell => lib}/markdown/mermaid.js | 0 assets/js/lib/notebook.js | 2 +- assets/js/markdown_renderer/index.js | 2 +- assets/js/session/index.js | 32 +- lib/livebook/notebook/cell/smart.ex | 10 +- .../runtime/erl_dist/runtime_server.ex | 9 +- lib/livebook/session.ex | 51 ++- lib/livebook/session/data.ex | 134 ++++---- lib/livebook_web/helpers.ex | 30 ++ lib/livebook_web/live/session_live.ex | 80 +++-- .../live/session_live/cell_component.ex | 104 +++---- .../session_live/cell_editor_component.ex | 56 ++++ .../runtime/erl_dist/runtime_server_test.exs | 3 +- test/livebook/session/data_test.exs | 117 ++++--- test/livebook/session_test.exs | 54 +++- 30 files changed, 717 insertions(+), 404 deletions(-) create mode 100644 assets/js/cell_editor/index.js rename assets/js/{cell => cell_editor}/live_editor.js (97%) rename assets/js/{cell => cell_editor}/live_editor/editor_client.js (100%) rename assets/js/{cell => cell_editor}/live_editor/elixir/on_type_formatting_edit_provider.js (100%) rename assets/js/{cell => cell_editor}/live_editor/hook_server_adapter.js (73%) rename assets/js/{cell => cell_editor}/live_editor/monaco.js (100%) rename assets/js/{cell => cell_editor}/live_editor/monaco_editor_adapter.js (100%) rename assets/js/{cell => cell_editor}/live_editor/remote_user.js (100%) rename assets/js/{cell => cell_editor}/live_editor/theme.js (100%) rename assets/js/{cell => lib}/markdown.js (99%) rename assets/js/{cell => lib}/markdown/mermaid.js (100%) create mode 100644 lib/livebook_web/live/session_live/cell_editor_component.ex diff --git a/assets/css/editor.css b/assets/css/editor.css index c6a975962..389650f07 100644 --- a/assets/css/editor.css +++ b/assets/css/editor.css @@ -151,7 +151,7 @@ Also some spacing adjustments. /* When in the first line, we want to display cursor and label in the same line */ .monaco-cursor-widget-container.inline { - display: flex; + display: flex !important; } .monaco-cursor-widget-container.inline .monaco-cursor-widget-label { diff --git a/assets/js/app.js b/assets/js/app.js index 4dff8fa17..16f925ed3 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -13,6 +13,7 @@ import topbar from "topbar"; import { LiveSocket } from "phoenix_live_view"; import Headline from "./headline"; import Cell from "./cell"; +import CellEditor from "./cell_editor"; import Session from "./session"; import FocusOnUpdate from "./focus_on_update"; import ScrollOnUpdate from "./scroll_on_update"; @@ -34,6 +35,7 @@ import { settingsStore } from "./lib/settings"; const hooks = { Headline, Cell, + CellEditor, Session, FocusOnUpdate, ScrollOnUpdate, diff --git a/assets/js/cell/index.js b/assets/js/cell/index.js index 50d8678b1..3b1ee33fd 100644 --- a/assets/js/cell/index.js +++ b/assets/js/cell/index.js @@ -1,6 +1,5 @@ -import { getAttributeOrThrow } from "../lib/attribute"; -import LiveEditor from "./live_editor"; -import Markdown from "./markdown"; +import { getAttributeOrDefault, getAttributeOrThrow } from "../lib/attribute"; +import Markdown from "../lib/markdown"; import { globalPubSub } from "../lib/pub_sub"; import { md5Base64, smoothlyScrollToElement } from "../lib/utils"; import scrollIntoView from "scroll-into-view-if-needed"; @@ -17,6 +16,7 @@ import { isEvaluable } from "../lib/notebook"; * * `data-cell-id` - id of the cell being edited * * `data-type` - type of the cell * * `data-session-path` - root path to the current session + * * `data-evaluation-digest` - digest of the last evaluated cell source */ const Cell = { mounted() { @@ -24,13 +24,13 @@ const Cell = { this.state = { isFocused: false, insertMode: false, - // For text cells (markdown or code) - liveEditor: null, - markdown: null, - evaluationDigest: null, + liveEditors: {}, }; + updateInsertModeAvailability(this); + // Setup action handlers + if (this.props.type === "code") { const amplifyButton = this.el.querySelector( `[data-element="amplify-outputs-button"]` @@ -46,119 +46,21 @@ const Cell = { ); toggleSourceButton.addEventListener("click", (event) => { this.el.toggleAttribute("data-js-source-visible"); + updateInsertModeAvailability(this); + maybeFocusCurrentEditor(this); }); } - this.handleEvent(`cell_init:${this.props.cellId}`, (payload) => { - const { source, revision, evaluation_digest } = payload; + // Setup listeners - // Setup markdown rendering - if (this.props.type === "markdown") { - const markdownContainer = this.el.querySelector( - `[data-element="markdown-container"]` - ); - this.state.markdown = new Markdown(markdownContainer, source, { - baseUrl: this.props.sessionPath, - emptyText: "Empty markdown cell", - }); - } + this.el.addEventListener("lb:cell:editor_created", (event) => { + const { tag, liveEditor } = event.detail; + handleCellEditorCreated(this, tag, liveEditor); + }); - const editorContainer = this.el.querySelector( - `[data-element="editor-container"]` - ); - // Remove the content placeholder. - editorContainer.firstElementChild.remove(); - // Create an empty container for the editor to be mounted in. - const editorElement = document.createElement("div"); - editorContainer.appendChild(editorElement); - // Setup the editor instance. - const language = { - markdown: "markdown", - code: "elixir", - smart: "elixir", - }[this.props.type]; - const readOnly = this.props.type === "smart"; - this.state.liveEditor = new LiveEditor( - this, - editorElement, - this.props.cellId, - source, - revision, - language, - readOnly - ); - - // Setup change indicator - if (isEvaluable(this.props.type)) { - this.state.evaluationDigest = evaluation_digest; - - const updateChangeIndicator = () => { - const cellStatus = this.el.querySelector( - `[data-element="cell-status"]` - ); - const indicator = - cellStatus && - cellStatus.querySelector(`[data-element="change-indicator"]`); - - if (indicator) { - const source = this.state.liveEditor.getSource(); - const digest = md5Base64(source); - const changed = this.state.evaluationDigest !== digest; - cellStatus.toggleAttribute("data-js-changed", changed); - } - }; - - updateChangeIndicator(); - - this.handleEvent( - `evaluation_started:${this.props.cellId}`, - ({ evaluation_digest }) => { - this.state.evaluationDigest = evaluation_digest; - updateChangeIndicator(); - } - ); - - this.state.liveEditor.onChange((newSource) => { - updateChangeIndicator(); - }); - - this.handleEvent( - `evaluation_finished:${this.props.cellId}`, - ({ code_error }) => { - this.state.liveEditor.setCodeErrorMarker(code_error); - } - ); - } - - // Setup markdown updates - if (this.props.type === "markdown") { - this.state.liveEditor.onChange((newSource) => { - this.state.markdown.setContent(newSource); - }); - } - - // Once the editor is created, reflect the current state. - if (this.state.isFocused && this.state.insertMode) { - this.state.liveEditor.focus(); - // If the element is being scrolled to, focus interrupts it, - // so ensure the scrolling continues. - smoothlyScrollToElement(this.el); - - broadcastSelection(this); - } - - this.state.liveEditor.onBlur(() => { - // Prevent from blurring unless the state changes. - // For example when we move cell using buttons - // the editor should keep focus. - if (this.state.isFocused && this.state.insertMode) { - this.state.liveEditor.focus(); - } - }); - - this.state.liveEditor.onCursorSelectionChange((selection) => { - broadcastSelection(this, selection); - }); + this.el.addEventListener("lb:cell:editor_removed", (event) => { + const { tag } = event.detail; + handleCellEditorRemoved(this, tag); }); this._unsubscribeFromNavigationEvents = globalPubSub.subscribe( @@ -187,14 +89,15 @@ const Cell = { destroyed() { this._unsubscribeFromNavigationEvents(); this._unsubscribeFromCellsEvents(); - - if (this.state.liveEditor) { - this.state.liveEditor.dispose(); - } }, updated() { + const prevProps = this.props; this.props = getProps(this); + + if (this.props.evaluationDigest !== prevProps.evaluationDigest) { + updateChangeIndicator(this); + } }, }; @@ -203,6 +106,11 @@ function getProps(hook) { cellId: getAttributeOrThrow(hook.el, "data-cell-id"), type: getAttributeOrThrow(hook.el, "data-type"), sessionPath: getAttributeOrThrow(hook.el, "data-session-path"), + evaluationDigest: getAttributeOrDefault( + hook.el, + "data-evaluation-digest", + null + ), }; } @@ -243,12 +151,116 @@ function handleElementFocused(hook, focusableId, scroll) { } } +function handleCellEditorCreated(hook, tag, liveEditor) { + hook.state.liveEditors[tag] = liveEditor; + + updateInsertModeAvailability(hook); + + if (liveEditor === currentEditor(hook)) { + // Once the editor is created, reflect the current insert mode state + maybeFocusCurrentEditor(hook, true); + } + + liveEditor.onBlur(() => { + // Prevent from blurring unless the state changes. For example + // when we move cell using buttons the editor should keep focus + if (hook.state.isFocused && hook.state.insertMode) { + currentEditor(hook).focus(); + } + }); + + liveEditor.onCursorSelectionChange((selection) => { + broadcastSelection(hook, selection); + }); + + if (tag === "primary") { + // Setup markdown rendering + if (hook.props.type === "markdown") { + const markdownContainer = hook.el.querySelector( + `[data-element="markdown-container"]` + ); + const markdown = new Markdown(markdownContainer, liveEditor.getSource(), { + baseUrl: hook.props.sessionPath, + emptyText: "Empty markdown cell", + }); + + liveEditor.onChange((newSource) => { + markdown.setContent(newSource); + }); + } + + // Setup change indicator + if (isEvaluable(hook.props.type)) { + updateChangeIndicator(hook); + + liveEditor.onChange((newSource) => { + updateChangeIndicator(hook); + }); + + hook.handleEvent( + `evaluation_finished:${hook.props.cellId}`, + ({ code_error }) => { + liveEditor.setCodeErrorMarker(code_error); + } + ); + } + } +} + +function handleCellEditorRemoved(hook, tag) { + delete hook.state.liveEditors[tag]; +} + +function currentEditor(hook) { + return hook.state.liveEditors[currentEditorTag(hook)]; +} + +function currentEditorTag(hook) { + if (hook.props.type === "smart") { + const isSourceTab = hook.el.hasAttribute("data-js-source-visible"); + return isSourceTab ? "primary" : "secondary"; + } + + return "primary"; +} + +function updateInsertModeAvailability(hook) { + hook.el.toggleAttribute("data-js-insert-mode-disabled", !currentEditor(hook)); +} + +function maybeFocusCurrentEditor(hook, scroll = false) { + if (hook.state.isFocused && hook.state.insertMode) { + currentEditor(hook).focus(); + + if (scroll) { + // If the element is being scrolled to, focus interrupts it, + // so ensure the scrolling continues. + smoothlyScrollToElement(hook.el); + } + + broadcastSelection(hook); + } +} + +function updateChangeIndicator(hook) { + const cellStatus = hook.el.querySelector(`[data-element="cell-status"]`); + const indicator = + cellStatus && cellStatus.querySelector(`[data-element="change-indicator"]`); + + if (indicator && hook.props.evaluationDigest) { + const source = hook.state.liveEditors.primary.getSource(); + const digest = md5Base64(source); + const changed = hook.props.evaluationDigest !== digest; + cellStatus.toggleAttribute("data-js-changed", changed); + } +} + function handleInsertModeChanged(hook, insertMode) { if (hook.state.isFocused && !hook.state.insertMode && insertMode) { hook.state.insertMode = insertMode; - if (hook.state.liveEditor) { - hook.state.liveEditor.focus(); + if (currentEditor(hook)) { + currentEditor(hook).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 @@ -268,8 +280,8 @@ function handleInsertModeChanged(hook, insertMode) { } else if (hook.state.insertMode && !insertMode) { hook.state.insertMode = insertMode; - if (hook.state.liveEditor) { - hook.state.liveEditor.blur(); + if (currentEditor(hook)) { + currentEditor(hook).blur(); } } } @@ -281,37 +293,44 @@ function handleCellMoved(hook, cellId) { } function handleCellUpload(hook, cellId, url) { - if (!hook.state.liveEditor) { + const liveEditor = hook.state.liveEditors.primary; + + if (!liveEditor) { return; } if (hook.props.cellId === cellId) { const markdown = `![](${url})`; - hook.state.liveEditor.insert(markdown); + liveEditor.insert(markdown); } } function handleLocationReport(hook, client, report) { - if (!hook.state.liveEditor) { - return; - } - - if (hook.props.cellId === report.focusableId && report.selection) { - hook.state.liveEditor.updateUserSelection(client, report.selection); - } else { - hook.state.liveEditor.removeUserSelection(client); - } + Object.entries(hook.state.liveEditors).forEach(([tag, liveEditor]) => { + if ( + hook.props.cellId === report.focusableId && + report.selection && + report.selection.tag === tag + ) { + liveEditor.updateUserSelection(client, report.selection.editorSelection); + } else { + liveEditor.removeUserSelection(client); + } + }); } -function broadcastSelection(hook, selection = null) { - selection = selection || hook.state.liveEditor.editor.getSelection(); +function broadcastSelection(hook, editorSelection = null) { + editorSelection = + editorSelection || currentEditor(hook).editor.getSelection(); + + const tag = currentEditorTag(hook); // Report new selection only if this cell is in insert mode if (hook.state.isFocused && hook.state.insertMode) { globalPubSub.broadcast("session", { type: "cursor_selection_changed", focusableId: hook.props.cellId, - selection, + selection: { tag, editorSelection }, }); } } diff --git a/assets/js/cell_editor/index.js b/assets/js/cell_editor/index.js new file mode 100644 index 000000000..0e4af0e7f --- /dev/null +++ b/assets/js/cell_editor/index.js @@ -0,0 +1,62 @@ +import LiveEditor from "./live_editor"; +import { getAttributeOrThrow } from "../lib/attribute"; + +const CellEditor = { + mounted() { + this.props = getProps(this); + + this.handleEvent( + `cell_editor_init:${this.props.cellId}:${this.props.tag}`, + ({ source_view, language, intellisense, read_only }) => { + const editorContainer = this.el.querySelector( + `[data-element="editor-container"]` + ); + + // Remove the content placeholder + editorContainer.firstElementChild.remove(); + const editorEl = document.createElement("div"); + editorContainer.appendChild(editorEl); + + this.liveEditor = new LiveEditor( + this, + editorEl, + this.props.cellId, + this.props.tag, + source_view.source, + source_view.revision, + language, + intellisense, + read_only + ); + + this.el.dispatchEvent( + new CustomEvent("lb:cell:editor_created", { + detail: { tag: this.props.tag, liveEditor: this.liveEditor }, + bubbles: true, + }) + ); + } + ); + }, + + destroyed() { + if (this.liveEditor) { + this.el.dispatchEvent( + new CustomEvent("lb:cell:editor_removed", { + detail: { tag: this.props.tag }, + bubbles: true, + }) + ); + this.liveEditor.dispose(); + } + }, +}; + +function getProps(hook) { + return { + cellId: getAttributeOrThrow(hook.el, "data-cell-id"), + tag: getAttributeOrThrow(hook.el, "data-tag"), + }; +} + +export default CellEditor; diff --git a/assets/js/cell/live_editor.js b/assets/js/cell_editor/live_editor.js similarity index 97% rename from assets/js/cell/live_editor.js rename to assets/js/cell_editor/live_editor.js index 92bba940e..9f35f4569 100644 --- a/assets/js/cell/live_editor.js +++ b/assets/js/cell_editor/live_editor.js @@ -10,12 +10,23 @@ import { settingsStore } from "../lib/settings"; * Mounts cell source editor with real-time collaboration mechanism. */ class LiveEditor { - constructor(hook, container, cellId, source, revision, language, readOnly) { + constructor( + hook, + container, + cellId, + tag, + source, + revision, + language, + intellisense, + readOnly + ) { this.hook = hook; this.container = container; this.cellId = cellId; this.source = source; this.language = language; + this.intellisense = intellisense; this.readOnly = readOnly; this._onChange = null; this._onBlur = null; @@ -24,11 +35,11 @@ class LiveEditor { this.__mountEditor(); - if (language === "elixir") { + if (this.intellisense) { this.__setupIntellisense(); } - const serverAdapter = new HookServerAdapter(hook, cellId); + const serverAdapter = new HookServerAdapter(hook, cellId, tag); const editorAdapter = new MonacoEditorAdapter(this.editor); this.editorClient = new EditorClient( serverAdapter, @@ -147,7 +158,7 @@ class LiveEditor { * To clear an existing marker `null` error is also supported. */ setCodeErrorMarker(error) { - const owner = "elixir.error.syntax"; + const owner = "livebook.error.syntax"; if (error) { const line = this.editor.getModel().getLineContent(error.line); @@ -198,17 +209,15 @@ class LiveEditor { autoIndent: true, formatOnType: true, formatOnPaste: true, - quickSuggestions: - this.language === "elixir" && settings.editor_auto_completion, + 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.language !== "elixir", - parameterHints: - this.language === "elixir" && settings.editor_auto_signature, + wordBasedSuggestions: !this.intellisense, + parameterHints: this.intellisense && settings.editor_auto_signature, }); this.editor.addAction({ @@ -219,6 +228,7 @@ class LiveEditor { keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyZ], run: (editor) => editor.updateOptions({ wordWrap: "on" }), }); + this.editor.addAction({ contextMenuGroupId: "word-wrapping", id: "disable-word-wrapping", diff --git a/assets/js/cell/live_editor/editor_client.js b/assets/js/cell_editor/live_editor/editor_client.js similarity index 100% rename from assets/js/cell/live_editor/editor_client.js rename to assets/js/cell_editor/live_editor/editor_client.js diff --git a/assets/js/cell/live_editor/elixir/on_type_formatting_edit_provider.js b/assets/js/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js similarity index 100% rename from assets/js/cell/live_editor/elixir/on_type_formatting_edit_provider.js rename to assets/js/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js diff --git a/assets/js/cell/live_editor/hook_server_adapter.js b/assets/js/cell_editor/live_editor/hook_server_adapter.js similarity index 73% rename from assets/js/cell/live_editor/hook_server_adapter.js rename to assets/js/cell_editor/live_editor/hook_server_adapter.js index a6ab200e0..e94f658f8 100644 --- a/assets/js/cell/live_editor/hook_server_adapter.js +++ b/assets/js/cell_editor/live_editor/hook_server_adapter.js @@ -6,19 +6,26 @@ import Delta from "../../lib/delta"; * Uses the given hook instance socket for the communication. */ export default class HookServerAdapter { - constructor(hook, cellId) { + 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}`, ({ delta }) => { - this._onDelta && this._onDelta(Delta.fromCompressed(delta)); - }); + 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._onAcknowledgement && this._onAcknowledgement(); - }); + this.hook.handleEvent( + `cell_acknowledgement:${this.cellId}:${this.tag}`, + () => { + this._onAcknowledgement && this._onAcknowledgement(); + } + ); } /** @@ -41,6 +48,7 @@ export default class HookServerAdapter { sendDelta(delta, revision) { this.hook.pushEvent("apply_cell_delta", { cell_id: this.cellId, + tag: this.tag, delta: delta.toCompressed(), revision, }); @@ -56,6 +64,7 @@ export default class HookServerAdapter { reportRevision(revision) { this.hook.pushEvent("report_cell_revision", { cell_id: this.cellId, + tag: this.tag, revision, }); } diff --git a/assets/js/cell/live_editor/monaco.js b/assets/js/cell_editor/live_editor/monaco.js similarity index 100% rename from assets/js/cell/live_editor/monaco.js rename to assets/js/cell_editor/live_editor/monaco.js diff --git a/assets/js/cell/live_editor/monaco_editor_adapter.js b/assets/js/cell_editor/live_editor/monaco_editor_adapter.js similarity index 100% rename from assets/js/cell/live_editor/monaco_editor_adapter.js rename to assets/js/cell_editor/live_editor/monaco_editor_adapter.js diff --git a/assets/js/cell/live_editor/remote_user.js b/assets/js/cell_editor/live_editor/remote_user.js similarity index 100% rename from assets/js/cell/live_editor/remote_user.js rename to assets/js/cell_editor/live_editor/remote_user.js diff --git a/assets/js/cell/live_editor/theme.js b/assets/js/cell_editor/live_editor/theme.js similarity index 100% rename from assets/js/cell/live_editor/theme.js rename to assets/js/cell_editor/live_editor/theme.js diff --git a/assets/js/highlight/index.js b/assets/js/highlight/index.js index 5e0ea0420..7392f72d1 100644 --- a/assets/js/highlight/index.js +++ b/assets/js/highlight/index.js @@ -1,5 +1,5 @@ import { getAttributeOrThrow } from "../lib/attribute"; -import { highlight } from "../cell/live_editor/monaco"; +import { highlight } from "../cell_editor/live_editor/monaco"; import { findChildOrThrow } from "../lib/utils"; /** diff --git a/assets/js/js_view/index.js b/assets/js/js_view/index.js index 3c332241b..0d6987157 100644 --- a/assets/js/js_view/index.js +++ b/assets/js/js_view/index.js @@ -269,14 +269,21 @@ function bindIframeSize(iframe, iframePlaceholder) { ); function repositionIframe() { - const notebookBox = notebookEl.getBoundingClientRect(); - const placeholderBox = iframePlaceholder.getBoundingClientRect(); - const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop; - iframe.style.top = `${top}px`; - const left = placeholderBox.left - notebookBox.left + notebookEl.scrollLeft; - iframe.style.left = `${left}px`; - iframe.style.height = `${placeholderBox.height}px`; - iframe.style.width = `${placeholderBox.width}px`; + if (iframePlaceholder.offsetParent === null) { + // When the placeholder is hidden, we hide the iframe as well + iframe.classList.add("hidden"); + } else { + iframe.classList.remove("hidden"); + const notebookBox = notebookEl.getBoundingClientRect(); + const placeholderBox = iframePlaceholder.getBoundingClientRect(); + const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop; + iframe.style.top = `${top}px`; + const left = + placeholderBox.left - notebookBox.left + notebookEl.scrollLeft; + iframe.style.left = `${left}px`; + iframe.style.height = `${placeholderBox.height}px`; + iframe.style.width = `${placeholderBox.width}px`; + } } // Most placeholder position changes are accompanied by changes to the diff --git a/assets/js/cell/markdown.js b/assets/js/lib/markdown.js similarity index 99% rename from assets/js/cell/markdown.js rename to assets/js/lib/markdown.js index 80dbe4a69..74a773df5 100644 --- a/assets/js/cell/markdown.js +++ b/assets/js/lib/markdown.js @@ -15,7 +15,7 @@ import { visit } from "unist-util-visit"; import { toText } from "hast-util-to-text"; import { removePosition } from "unist-util-remove-position"; -import { highlight } from "./live_editor/monaco"; +import { highlight } from "../cell_editor/live_editor/monaco"; import { renderMermaid } from "./markdown/mermaid"; import { escapeHtml } from "../lib/utils"; diff --git a/assets/js/cell/markdown/mermaid.js b/assets/js/lib/markdown/mermaid.js similarity index 100% rename from assets/js/cell/markdown/mermaid.js rename to assets/js/lib/markdown/mermaid.js diff --git a/assets/js/lib/notebook.js b/assets/js/lib/notebook.js index 72ab085f1..e785f4f0d 100644 --- a/assets/js/lib/notebook.js +++ b/assets/js/lib/notebook.js @@ -6,7 +6,7 @@ export function isEvaluable(cellType) { } /** - * Checks if the given cell type has editable editor. + * Checks if the given cell type has primary editable editor. */ export function isDirectlyEditable(cellType) { return ["markdown", "code"].includes(cellType); diff --git a/assets/js/markdown_renderer/index.js b/assets/js/markdown_renderer/index.js index c215cdd21..4b51a242d 100644 --- a/assets/js/markdown_renderer/index.js +++ b/assets/js/markdown_renderer/index.js @@ -1,5 +1,5 @@ import { getAttributeOrThrow } from "../lib/attribute"; -import Markdown from "../cell/markdown"; +import Markdown from "../lib/markdown"; /** * A hook used to render markdown content on the client. diff --git a/assets/js/session/index.js b/assets/js/session/index.js index 844bf7ae5..1873cdab0 100644 --- a/assets/js/session/index.js +++ b/assets/js/session/index.js @@ -10,7 +10,7 @@ import { import { getAttributeOrDefault } from "../lib/attribute"; import KeyBuffer from "./key_buffer"; import { globalPubSub } from "../lib/pub_sub"; -import monaco from "../cell/live_editor/monaco"; +import monaco from "../cell_editor/live_editor/monaco"; import { leaveChannel } from "../js_view"; import { isDirectlyEditable, isEvaluable } from "../lib/notebook"; @@ -477,12 +477,12 @@ function handleDocumentMouseDown(hook, event) { } } -function editableElementClicked(event, element) { - if (element) { - const editableElement = element.querySelector( +function editableElementClicked(event, focusableEl) { + if (focusableEl) { + const editableElement = event.target.closest( `[data-element="editor-container"], [data-element="heading"]` ); - return editableElement && editableElement.contains(event.target); + return editableElement && focusableEl.contains(editableElement); } return false; @@ -748,9 +748,11 @@ function showShortcuts(hook) { } function isInsertModeAvailable(hook) { + const el = getFocusableEl(hook.state.focusedId); + return ( - hook.state.focusedCellType === null || - isDirectlyEditable(hook.state.focusedCellType) + !isCell(hook.state.focusedId) || + !el.hasAttribute("data-js-insert-mode-disabled") ); } @@ -1011,11 +1013,14 @@ function sendLocationReport(hook, report) { function encodeSelection(selection) { if (selection === null) return null; + const { tag, editorSelection } = selection; + return [ - selection.selectionStartLineNumber, - selection.selectionStartColumn, - selection.positionLineNumber, - selection.positionColumn, + tag, + editorSelection.selectionStartLineNumber, + editorSelection.selectionStartColumn, + editorSelection.positionLineNumber, + editorSelection.positionColumn, ]; } @@ -1023,18 +1028,21 @@ function decodeSelection(encoded) { if (encoded === null) return null; const [ + tag, selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn, ] = encoded; - return new monaco.Selection( + const editorSelection = new monaco.Selection( selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn ); + + return { tag, editorSelection }; } // Helpers diff --git a/lib/livebook/notebook/cell/smart.ex b/lib/livebook/notebook/cell/smart.ex index 02bea0ff4..b614c2807 100644 --- a/lib/livebook/notebook/cell/smart.ex +++ b/lib/livebook/notebook/cell/smart.ex @@ -3,7 +3,7 @@ defmodule Livebook.Notebook.Cell.Smart do # A cell with Elixir code that is edited through a dedicated UI. - defstruct [:id, :source, :outputs, :kind, :attrs, :js_view] + defstruct [:id, :source, :outputs, :kind, :attrs, :js_view, :editor] alias Livebook.Utils alias Livebook.Notebook.Cell @@ -14,11 +14,14 @@ defmodule Livebook.Notebook.Cell.Smart do outputs: list(Cell.indexed_output()), kind: String.t(), attrs: attrs(), - js_view: Livebook.Runtime.js_view() | nil + js_view: Livebook.Runtime.js_view() | nil, + editor: editor() | nil } @type attrs :: map() + @type editor :: %{language: String.t(), placement: :bottom | :top, source: String.t()} + @doc """ Returns an empty cell. """ @@ -30,7 +33,8 @@ defmodule Livebook.Notebook.Cell.Smart do outputs: [], kind: nil, attrs: %{}, - js_view: nil + js_view: nil, + editor: nil } end end diff --git a/lib/livebook/runtime/erl_dist/runtime_server.ex b/lib/livebook/runtime/erl_dist/runtime_server.ex index 1c621a5df..64ddf7643 100644 --- a/lib/livebook/runtime/erl_dist/runtime_server.ex +++ b/lib/livebook/runtime/erl_dist/runtime_server.ex @@ -377,15 +377,17 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do ) do {:ok, pid, info} -> %{ - js_view: js_view, source: source, + js_view: js_view, + editor: editor, scan_binding: scan_binding, scan_eval_result: scan_eval_result } = info send( state.owner, - {:runtime_smart_cell_started, ref, %{js_view: js_view, source: source}} + {:runtime_smart_cell_started, ref, + %{source: source, js_view: js_view, editor: editor}} ) info = %{ @@ -400,7 +402,8 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do info = scan_binding_async(ref, info, state) put_in(state.smart_cells[ref], info) - _ -> + {:error, error} -> + Logger.error("failed to start smart cell, reason: #{inspect(error)}") state end diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 0d538eedb..1c3253ce9 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -380,9 +380,15 @@ defmodule Livebook.Session do @doc """ Sends a cell delta to apply to the server. """ - @spec apply_cell_delta(pid(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok - def apply_cell_delta(pid, cell_id, delta, revision) do - GenServer.cast(pid, {:apply_cell_delta, self(), cell_id, delta, revision}) + @spec apply_cell_delta( + pid(), + Cell.id(), + Data.cell_source_tag(), + Delta.t(), + 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}) end @doc """ @@ -390,9 +396,14 @@ defmodule Livebook.Session do This helps to remove old deltas that are no longer necessary. """ - @spec report_cell_revision(pid(), Cell.id(), Data.cell_revision()) :: :ok - def report_cell_revision(pid, cell_id, revision) do - GenServer.cast(pid, {:report_cell_revision, self(), cell_id, revision}) + @spec report_cell_revision( + pid(), + Cell.id(), + Data.cell_source_tag(), + Data.cell_revision() + ) :: :ok + def report_cell_revision(pid, cell_id, tag, revision) do + GenServer.cast(pid, {:report_cell_revision, self(), cell_id, tag, revision}) end @doc """ @@ -770,13 +781,13 @@ defmodule Livebook.Session do {:noreply, handle_operation(state, operation)} end - def handle_cast({:apply_cell_delta, client_pid, cell_id, delta, revision}, state) do - operation = {:apply_cell_delta, client_pid, cell_id, delta, revision} + def handle_cast({:apply_cell_delta, client_pid, cell_id, tag, delta, revision}, state) do + operation = {:apply_cell_delta, client_pid, cell_id, tag, delta, revision} {:noreply, handle_operation(state, operation)} end - def handle_cast({:report_cell_revision, client_pid, cell_id, revision}, state) do - operation = {:report_cell_revision, client_pid, cell_id, revision} + def handle_cast({:report_cell_revision, client_pid, cell_id, tag, revision}, state) do + operation = {:report_cell_revision, client_pid, cell_id, tag, revision} {:noreply, handle_operation(state, operation)} end @@ -914,7 +925,7 @@ defmodule Livebook.Session do case Notebook.fetch_cell_and_section(state.data.notebook, id) do {:ok, cell, _section} -> delta = Livebook.JSInterop.diff(cell.source, info.source) - operation = {:smart_cell_started, self(), id, delta, info.js_view} + operation = {:smart_cell_started, self(), id, delta, info.js_view, info.editor} {:noreply, handle_operation(state, operation)} :error -> @@ -922,11 +933,11 @@ defmodule Livebook.Session do end end - def handle_info({:runtime_smart_cell_update, id, cell_state, source}, state) do + def handle_info({:runtime_smart_cell_update, id, attrs, source}, state) do case Notebook.fetch_cell_and_section(state.data.notebook, id) do {:ok, cell, _section} -> delta = Livebook.JSInterop.diff(cell.source, source) - operation = {:update_smart_cell, self(), id, cell_state, delta} + operation = {:update_smart_cell, self(), id, attrs, delta} {:noreply, handle_operation(state, operation)} :error -> @@ -1181,6 +1192,20 @@ defmodule Livebook.Session do state end + defp after_operation( + state, + _prev_state, + {:apply_cell_delta, _client_pid, cell_id, tag, _delta, _revision} + ) do + with :secondary <- tag, + {:ok, %Cell.Smart{} = cell, _section} <- + Notebook.fetch_cell_and_section(state.data.notebook, cell_id) do + send(cell.js_view.pid, {:editor_source, cell.editor.source}) + end + + state + end + defp after_operation(state, _prev_state, _operation), do: state defp handle_actions(state, actions) do diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index 22fd43d74..c79974c4c 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -61,20 +61,22 @@ defmodule Livebook.Session.Data do @type cell_info :: markdown_cell_info() | code_cell_info() | smart_cell_info() @type markdown_cell_info :: %{ - source: cell_source_info() + sources: %{primary: cell_source_info()} } @type code_cell_info :: %{ - source: cell_source_info(), + sources: %{primary: cell_source_info()}, eval: cell_eval_info() } @type smart_cell_info :: %{ - source: cell_source_info(), + sources: %{primary: cell_source_info(), secondary: cell_source_info()}, eval: cell_eval_info(), status: smart_cell_status() } + @type cell_source_tag :: atom() + @type cell_source_info :: %{ revision: cell_revision(), deltas: list(Delta.t()), @@ -163,7 +165,8 @@ defmodule Livebook.Session.Data do | {:reflect_main_evaluation_failure, pid()} | {:reflect_evaluation_failure, pid(), Section.id()} | {:cancel_cell_evaluation, pid(), Cell.id()} - | {:smart_cell_started, pid(), Cell.id(), Delta.t(), Runtime.js_view()} + | {:smart_cell_started, pid(), Cell.id(), Delta.t(), Runtime.js_view(), + Cell.Smart.editor() | nil} | {:update_smart_cell, pid(), Cell.id(), Cell.Smart.attrs(), Delta.t()} | {:erase_outputs, pid()} | {:set_notebook_name, pid(), String.t()} @@ -171,8 +174,8 @@ defmodule Livebook.Session.Data do | {:client_join, pid(), User.t()} | {:client_leave, pid()} | {:update_user, pid(), User.t()} - | {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()} - | {:report_cell_revision, pid(), Cell.id(), cell_revision()} + | {:apply_cell_delta, pid(), Cell.id(), cell_source_tag(), Delta.t(), cell_revision()} + | {:report_cell_revision, pid(), Cell.id(), cell_source_tag(), cell_revision()} | {:set_cell_attributes, pid(), Cell.id(), map()} | {:set_input_value, pid(), input_id(), value :: term()} | {:set_runtime, pid(), Runtime.t() | nil} @@ -188,7 +191,7 @@ defmodule Livebook.Session.Data do | {:forget_evaluation, Cell.t(), Section.t()} | {:start_smart_cell, Cell.t(), Section.t()} | {:set_smart_cell_base, Cell.t(), Section.t(), parent :: {Cell.t(), Section.t()} | nil} - | {:broadcast_delta, pid(), Cell.t(), Delta.t()} + | {:broadcast_delta, pid(), Cell.t(), cell_source_tag(), Delta.t()} @doc """ Returns a fresh notebook session state. @@ -541,13 +544,13 @@ defmodule Livebook.Session.Data do end end - def apply_operation(data, {:smart_cell_started, client_pid, id, delta, js_view}) do + def apply_operation(data, {:smart_cell_started, client_pid, id, delta, js_view, editor}) do with {:ok, %Cell.Smart{} = cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, id), :starting <- data.cell_infos[cell.id].status do data |> with_actions() - |> smart_cell_started(cell, client_pid, delta, js_view) + |> smart_cell_started(cell, client_pid, delta, js_view, editor) |> set_dirty() |> wrap_ok() else @@ -628,14 +631,14 @@ defmodule Livebook.Session.Data do end end - def apply_operation(data, {:apply_cell_delta, client_pid, cell_id, delta, revision}) do + def apply_operation(data, {:apply_cell_delta, client_pid, cell_id, tag, delta, revision}) do with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), - info <- data.cell_infos[cell.id], - true <- 0 < revision and revision <= info.source.revision + 1, + source_info <- data.cell_infos[cell_id].sources[tag], + true <- 0 < revision and revision <= source_info.revision + 1, true <- Map.has_key?(data.clients_map, client_pid) do data |> with_actions() - |> apply_delta(client_pid, cell, delta, revision) + |> apply_delta(client_pid, cell, tag, delta, revision) |> set_dirty() |> wrap_ok() else @@ -643,14 +646,14 @@ defmodule Livebook.Session.Data do end end - def apply_operation(data, {:report_cell_revision, client_pid, cell_id, revision}) do + def apply_operation(data, {:report_cell_revision, client_pid, cell_id, tag, revision}) do with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), - info <- data.cell_infos[cell.id], - true <- 0 < revision and revision <= info.source.revision, + source_info <- data.cell_infos[cell_id].sources[tag], + true <- 0 < revision and revision <= source_info.revision, true <- Map.has_key?(data.clients_map, client_pid) do data |> with_actions() - |> report_revision(client_pid, cell, revision) + |> report_revision(client_pid, cell, tag, revision) |> wrap_ok() else _ -> :error @@ -1221,13 +1224,17 @@ defmodule Livebook.Session.Data do end end - defp smart_cell_started({data, _} = data_actions, cell, client_pid, delta, js_view) do - updated_cell = %{cell | js_view: js_view} |> apply_delta_to_cell(delta) + defp smart_cell_started({data, _} = data_actions, cell, client_pid, delta, js_view, editor) do + updated_cell = %{cell | js_view: js_view, editor: editor} |> apply_delta_to_cell(delta) data_actions |> set!(notebook: Notebook.update_cell(data.notebook, cell.id, fn _ -> updated_cell end)) |> update_cell_info!(cell.id, &%{&1 | status: :started}) - |> add_action({:broadcast_delta, client_pid, updated_cell, delta}) + |> update_cell_info!(cell.id, fn info -> + info = %{info | status: :started} + put_in(info.sources.secondary, new_source_info(data.clients_map)) + end) + |> add_action({:broadcast_delta, client_pid, updated_cell, :primary, delta}) end defp update_smart_cell({data, _} = data_actions, cell, client_pid, attrs, delta) do @@ -1241,7 +1248,7 @@ defmodule Livebook.Session.Data do data_actions |> set!(notebook: Notebook.update_cell(data.notebook, cell.id, fn _ -> updated_cell end)) - |> add_action({:broadcast_delta, client_pid, updated_cell, delta}) + |> add_action({:broadcast_delta, client_pid, updated_cell, :primary, delta}) end defp erase_outputs({data, _} = data_actions) do @@ -1280,8 +1287,13 @@ defmodule Livebook.Session.Data do users_map: Map.put(data.users_map, user.id, user) ) |> update_every_cell_info(fn - %{source: _} = info -> - put_in(info.source.revision_by_client_pid[client_pid], info.source.revision) + %{sources: _} = info -> + update_in( + info.sources, + &Map.map(&1, fn {_, source_info} -> + put_in(source_info.revision_by_client_pid[client_pid], source_info.revision) + end) + ) info -> info @@ -1301,9 +1313,14 @@ defmodule Livebook.Session.Data do data_actions |> set!(clients_map: clients_map, users_map: users_map) |> update_every_cell_info(fn - %{source: _} = info -> - {_, info} = pop_in(info.source.revision_by_client_pid[client_pid]) - update_in(info.source, &purge_deltas/1) + %{sources: _} = info -> + update_in( + info.sources, + &Map.map(&1, fn {_, source_info} -> + {_, source_info} = pop_in(source_info.revision_by_client_pid[client_pid]) + purge_deltas(source_info) + end) + ) info -> info @@ -1314,36 +1331,43 @@ 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_pid, cell, delta, revision) do - info = data.cell_infos[cell.id] + defp apply_delta({data, _} = data_actions, client_pid, cell, tag, delta, revision) do + source_info = data.cell_infos[cell.id].sources[tag] - deltas_ahead = Enum.take(info.source.deltas, -(info.source.revision - revision + 1)) + deltas_ahead = Enum.take(source_info.deltas, -(source_info.revision - revision + 1)) transformed_new_delta = Enum.reduce(deltas_ahead, delta, fn delta_ahead, transformed_new_delta -> Delta.transform(delta_ahead, transformed_new_delta, :left) end) - updated_cell = apply_delta_to_cell(cell, transformed_new_delta) + source_info = + source_info + |> Map.update!(:deltas, &(&1 ++ [transformed_new_delta])) + |> Map.update!(:revision, &(&1 + 1)) + + # Before receiving acknowledgement, the client receives all + # the other deltas, so we can assume they are in sync with + # the server and have the same revision. + source_info = + put_in(source_info.revision_by_client_pid[client_pid], source_info.revision) + |> purge_deltas() + + updated_cell = + update_in(cell, source_access(cell, tag), fn + :__pruned__ -> :__pruned__ + source -> JSInterop.apply_delta_to_string(transformed_new_delta, source) + end) data_actions |> set!(notebook: Notebook.update_cell(data.notebook, cell.id, fn _ -> updated_cell end)) - |> update_cell_source_info!(cell.id, fn source_info -> - source_info = %{ - source_info - | deltas: source_info.deltas ++ [transformed_new_delta], - revision: source_info.revision + 1 - } - - # Before receiving acknowledgement, the client receives all - # the other deltas, so we can assume they are in sync with - # the server and have the same revision. - put_in(source_info.revision_by_client_pid[client_pid], source_info.revision) - |> purge_deltas() - end) - |> add_action({:broadcast_delta, client_pid, updated_cell, transformed_new_delta}) + |> update_cell_info!(cell.id, &put_in(&1.sources[tag], source_info)) + |> add_action({:broadcast_delta, client_pid, updated_cell, tag, transformed_new_delta}) end + defp source_access(%Cell.Smart{}, :secondary), do: [Access.key(:editor), :source] + defp source_access(_cell, :primary), do: [Access.key(:source)] + # Note: the clients drop cell's source once it's no longer needed defp apply_delta_to_cell(%{source: :__pruned__} = cell, _delta), do: cell @@ -1351,11 +1375,13 @@ defmodule Livebook.Session.Data do update_in(cell.source, &JSInterop.apply_delta_to_string(delta, &1)) end - defp report_revision(data_actions, client_pid, cell, revision) do + defp report_revision(data_actions, client_pid, cell, tag, revision) do data_actions - |> update_cell_source_info!(cell.id, fn source_info -> - put_in(source_info.revision_by_client_pid[client_pid], revision) - |> purge_deltas() + |> update_cell_info!(cell.id, fn info -> + update_in(info.sources[tag], fn source_info -> + put_in(source_info.revision_by_client_pid[client_pid], revision) + |> purge_deltas() + end) end) end @@ -1417,7 +1443,7 @@ defmodule Livebook.Session.Data do Notebook.update_reduce_cells(data.notebook, data_actions, fn %Cell.Smart{} = cell, data_actions -> { - %{cell | js_view: nil}, + %{cell | js_view: nil, editor: nil}, update_cell_info!(data_actions, cell.id, &%{&1 | status: :dead}) } @@ -1554,20 +1580,20 @@ defmodule Livebook.Session.Data do defp new_cell_info(%Cell.Markdown{}, clients_map) do %{ - source: new_source_info(clients_map) + sources: %{primary: new_source_info(clients_map)} } end defp new_cell_info(%Cell.Code{}, clients_map) do %{ - source: new_source_info(clients_map), + sources: %{primary: new_source_info(clients_map)}, eval: new_eval_info() } end defp new_cell_info(%Cell.Smart{}, clients_map) do %{ - source: new_source_info(clients_map), + sources: %{primary: new_source_info(clients_map), secondary: new_source_info(clients_map)}, eval: new_eval_info(), status: :dead } @@ -1616,10 +1642,6 @@ defmodule Livebook.Session.Data do update_cell_info!(data_actions, cell_id, &update_in(&1.eval, fun)) end - defp update_cell_source_info!(data_actions, cell_id, fun) do - update_cell_info!(data_actions, cell_id, &update_in(&1.source, fun)) - end - defp update_every_cell_info({data, _} = data_actions, fun) do cell_infos = Map.new(data.cell_infos, fn {cell_id, info} -> diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index d79c1f3d2..ef6acaeac 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -173,6 +173,36 @@ defmodule LivebookWeb.Helpers do ) end + @doc """ + Renders a text content skeleton. + + ## Options + + * `:empty` - if the source is empty. Defauls to `false` + + * `:bg_class` - the skeleton background color. Defaults to `"bg-gray-200"` + """ + def content_skeleton(assigns) do + assigns = + assigns + |> assign_new(:empty, fn -> false end) + |> assign_new(:bg_class, fn -> "bg-gray-200" end) + + ~H""" + <%= if @empty do %> +
+ <% else %> +
+
+
+
+
+
+
+ <% end %> + """ + end + @doc """ Determines user platform based on the given *User-Agent* header. """ diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 693b13699..69aa5d5ee 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -648,21 +648,23 @@ defmodule LivebookWeb.SessionLive do def handle_event( "apply_cell_delta", - %{"cell_id" => cell_id, "delta" => delta, "revision" => revision}, + %{"cell_id" => cell_id, "tag" => tag, "delta" => delta, "revision" => revision}, socket ) do + tag = String.to_atom(tag) delta = Delta.from_compressed(delta) - Session.apply_cell_delta(socket.assigns.session.pid, cell_id, delta, revision) + Session.apply_cell_delta(socket.assigns.session.pid, cell_id, tag, delta, revision) {:noreply, socket} end def handle_event( "report_cell_revision", - %{"cell_id" => cell_id, "revision" => revision}, + %{"cell_id" => cell_id, "tag" => tag, "revision" => revision}, socket ) do - Session.report_cell_revision(socket.assigns.session.pid, cell_id, revision) + tag = String.to_atom(tag) + Session.report_cell_revision(socket.assigns.session.pid, cell_id, tag, revision) {:noreply, socket} end @@ -1194,16 +1196,6 @@ defmodule LivebookWeb.SessionLive do end end - defp after_operation( - socket, - _prev_socket, - {:evaluation_started, _client_pid, cell_id, evaluation_digest} - ) do - push_event(socket, "evaluation_started:#{cell_id}", %{ - evaluation_digest: encode_digest(evaluation_digest) - }) - end - defp after_operation( socket, _prev_socket, @@ -1222,17 +1214,25 @@ defmodule LivebookWeb.SessionLive do |> push_event("evaluation_finished:#{cell_id}", %{code_error: metadata.code_error}) end + defp after_operation( + socket, + _prev_socket, + {:smart_cell_started, _client_pid, _cell_id, _delta, _js_view, _editor} + ) do + prune_cell_sources(socket) + end + defp after_operation(socket, _prev_socket, _operation), do: socket defp handle_actions(socket, actions) do Enum.reduce(actions, socket, &handle_action(&2, &1)) end - defp handle_action(socket, {:broadcast_delta, client_pid, cell, delta}) do + defp handle_action(socket, {:broadcast_delta, client_pid, cell, tag, delta}) do if client_pid == self() do - push_event(socket, "cell_acknowledgement:#{cell.id}", %{}) + push_event(socket, "cell_acknowledgement:#{cell.id}:#{tag}", %{}) else - push_event(socket, "cell_delta:#{cell.id}", %{delta: Delta.to_compressed(delta)}) + push_event(socket, "cell_delta:#{cell.id}:#{tag}", %{delta: Delta.to_compressed(delta)}) end end @@ -1416,7 +1416,7 @@ defmodule LivebookWeb.SessionLive do %{ id: cell.id, type: :markdown, - source_view: cell_source_view(cell, info) + source_view: source_view(cell.source, info.sources.primary) } end @@ -1426,7 +1426,7 @@ defmodule LivebookWeb.SessionLive do %{ id: cell.id, type: :code, - source_view: cell_source_view(cell, info), + source_view: source_view(cell.source, info.sources.primary), eval: eval_info_to_view(cell, info.eval, data), reevaluate_automatically: cell.reevaluate_automatically } @@ -1438,10 +1438,17 @@ defmodule LivebookWeb.SessionLive do %{ id: cell.id, type: :smart, - source_view: cell_source_view(cell, info), + source_view: source_view(cell.source, info.sources.primary), eval: eval_info_to_view(cell, info.eval, data), status: info.status, - js_view: cell.js_view + js_view: cell.js_view, + editor: + cell.editor && + %{ + language: cell.editor.language, + placement: cell.editor.placement, + source_view: source_view(cell.editor.source, info.sources.secondary) + } } end @@ -1453,21 +1460,21 @@ defmodule LivebookWeb.SessionLive do evaluation_time_ms: eval_info.evaluation_time_ms, evaluation_start: eval_info.evaluation_start, evaluation_number: eval_info.evaluation_number, + evaluation_digest: encode_digest(eval_info.evaluation_digest), outputs_batch_number: eval_info.outputs_batch_number, # Pass input values relevant to the given cell input_values: input_values_for_cell(cell, data) } end - defp cell_source_view(%{source: :__pruned__}, _info) do + defp source_view(:__pruned__, _source_info) do :__pruned__ end - defp cell_source_view(cell, info) do + defp source_view(source, source_info) do %{ - source: cell.source, - revision: info.source.revision, - evaluation_digest: encode_digest(get_in(info, [:eval, :evaluation_digest])) + source: source, + revision: source_info.revision } end @@ -1485,10 +1492,10 @@ defmodule LivebookWeb.SessionLive do # most common ones we only update the relevant parts. defp update_data_view(data_view, prev_data, data, operation) do case operation do - {:report_cell_revision, _pid, _cell_id, _revision} -> + {:report_cell_revision, _pid, _cell_id, _tag, _revision} -> data_view - {:apply_cell_delta, _pid, _cell_id, _delta, _revision} -> + {:apply_cell_delta, _pid, _cell_id, _tag, _delta, _revision} -> update_dirty_status(data_view, data) {:update_smart_cell, _pid, _cell_id, _cell_state, _delta} -> @@ -1540,14 +1547,25 @@ defmodule LivebookWeb.SessionLive do update_in( data.notebook, &Notebook.update_cells(&1, fn - %Notebook.Cell.Smart{} = cell -> %{cell | source: :__pruned__, attrs: :__pruned__} - %{source: _} = cell -> %{cell | source: :__pruned__} - cell -> cell + %Notebook.Cell.Smart{} = cell -> + %{cell | source: :__pruned__, attrs: :__pruned__} + |> prune_smart_cell_editor_source() + + %{source: _} = cell -> + %{cell | source: :__pruned__} + + cell -> + cell end) ) ) end + defp prune_smart_cell_editor_source(%{editor: %{source: _}} = cell), + do: put_in(cell.editor.source, :__pruned__) + + defp prune_smart_cell_editor_source(cell), do: cell + # Changes that affect only a single cell are still likely to # have impact on dirtiness, so we need to always mirror it defp update_dirty_status(data_view, data) do diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index c96eace6f..a7af25a95 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -1,29 +1,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do use LivebookWeb, :live_component - @impl true - def mount(socket) do - {:ok, assign(socket, initialized: false)} - end - - @impl true - def update(assigns, socket) do - socket = assign(socket, assigns) - - socket = - if not connected?(socket) or socket.assigns.initialized do - socket - else - %{id: id, source_view: source_view} = socket.assigns.cell_view - - socket - |> push_event("cell_init:#{id}", source_view) - |> assign(initialized: true) - end - - {:ok, socket} - end - @impl true def render(assigns) do ~H""" @@ -34,7 +11,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do data-cell-id={@cell_view.id} data-focusable-id={@cell_view.id} data-type={@cell_view.type} - data-session-path={Routes.session_path(@socket, :page, @session_id)}> + data-session-path={Routes.session_path(@socket, :page, @session_id)} + data-evaluation-digest={get_in(@cell_view, [:eval, :evaluation_digest])}> <%= render_cell(assigns) %> """ @@ -54,13 +32,18 @@ defmodule LivebookWeb.SessionLive.CellComponent do <.cell_body>
- <.editor cell_view={@cell_view} /> + <.live_component module={LivebookWeb.SessionLive.CellEditorComponent} + id={"#{@cell_view.id}-primary"} + cell_id={@cell_view.id} + tag="primary" + source_view={@cell_view.source_view} + language="markdown" />
- <.content_placeholder bg_class="bg-gray-200" empty={empty?(@cell_view.source_view)} /> + <.content_skeleton empty={empty?(@cell_view.source_view)} />
""" @@ -89,7 +72,13 @@ defmodule LivebookWeb.SessionLive.CellComponent do <.cell_body>
- <.editor cell_view={@cell_view} /> + <.live_component module={LivebookWeb.SessionLive.CellEditorComponent} + id={"#{@cell_view.id}-primary"} + cell_id={@cell_view.id} + tag="primary" + source_view={@cell_view.source_view} + language="elixir" + intellisense />
<.cell_status cell_view={@cell_view} />
@@ -129,10 +118,20 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<%= case @cell_view.status do %> <% :started -> %> - <.live_component module={LivebookWeb.JSViewComponent} - id={@cell_view.id} - js_view={@cell_view.js_view} - session_id={@session_id} /> +
+ <.live_component module={LivebookWeb.JSViewComponent} + id={@cell_view.id} + js_view={@cell_view.js_view} + session_id={@session_id} /> + <%= if @cell_view.editor do %> + <.live_component module={LivebookWeb.SessionLive.CellEditorComponent} + id={"#{@cell_view.id}-secondary"} + cell_id={@cell_view.id} + tag="secondary" + source_view={@cell_view.editor.source_view} + language={@cell_view.editor.language} /> + <% end %> +
<% :dead -> %>
@@ -141,12 +140,19 @@ defmodule LivebookWeb.SessionLive.CellComponent do <% :starting -> %>
- <.content_placeholder bg_class="bg-gray-200" empty={false} /> + <.content_skeleton empty={false} />
<% end %>
- <.editor cell_view={@cell_view} /> + <.live_component module={LivebookWeb.SessionLive.CellEditorComponent} + id={"#{@cell_view.id}-primary"} + cell_id={@cell_view.id} + tag="primary" + source_view={@cell_view.source_view} + language="elixir" + intellisense + read_only />
<.cell_status cell_view={@cell_view} /> @@ -373,18 +379,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end - defp editor(assigns) do - ~H""" -
-
-
- <.content_placeholder bg_class="bg-gray-500" empty={empty?(@cell_view.source_view)} /> -
-
-
- """ - end - defp evaluation_outputs(assigns) do ~H"""
-
- <% else %> -
-
-
-
-
-
-
- <% end %> - """ - end - defp empty?(%{source: ""} = _source_view), do: true defp empty?(_source_view), do: false diff --git a/lib/livebook_web/live/session_live/cell_editor_component.ex b/lib/livebook_web/live/session_live/cell_editor_component.ex new file mode 100644 index 000000000..accd64e13 --- /dev/null +++ b/lib/livebook_web/live/session_live/cell_editor_component.ex @@ -0,0 +1,56 @@ +defmodule LivebookWeb.SessionLive.CellEditorComponent do + use LivebookWeb, :live_component + + @impl true + def mount(socket) do + {:ok, assign(socket, initialized: false)} + end + + @impl true + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:intellisense, fn -> false end) + |> assign_new(:read_only, fn -> false end) + + socket = + if not connected?(socket) or socket.assigns.initialized do + socket + else + socket + |> push_event( + "cell_editor_init:#{socket.assigns.cell_id}:#{socket.assigns.tag}", + %{ + source_view: socket.assigns.source_view, + language: socket.assigns.language, + intellisense: socket.assigns.intellisense, + read_only: socket.assigns.read_only + } + ) + |> assign(initialized: true) + end + + {:ok, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+ <.content_skeleton bg_class="bg-gray-500" empty={empty?(@source_view)} /> +
+
+
+ """ + end + + defp empty?(%{source: ""} = _source_view), do: true + defp empty?(_source_view), do: false +end diff --git a/test/livebook/runtime/erl_dist/runtime_server_test.exs b/test/livebook/runtime/erl_dist/runtime_server_test.exs index b60244f6d..fa900df03 100644 --- a/test/livebook/runtime/erl_dist/runtime_server_test.exs +++ b/test/livebook/runtime/erl_dist/runtime_server_test.exs @@ -216,8 +216,9 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServerTest do {:ok, pid, %{ - js_view: %{ref: info.ref, pid: pid, assets: %{}}, source: "source", + js_view: %{ref: info.ref, pid: pid, assets: %{}}, + editor: nil, scan_binding: fn pid, _binding, _env -> send(pid, :scan_binding_ping) end, scan_eval_result: fn pid, _result -> send(pid, :scan_eval_result_ping) end }} diff --git a/test/livebook/session/data_test.exs b/test/livebook/session/data_test.exs index 7d9d1d14b..5ca779f47 100644 --- a/test/livebook/session/data_test.exs +++ b/test/livebook/session/data_test.exs @@ -421,7 +421,9 @@ defmodule Livebook.Session.DataTest do assert {:ok, %{ - cell_infos: %{"c1" => %{source: %{revision_by_client_pid: %{^client_pid => 0}}}} + cell_infos: %{ + "c1" => %{sources: %{primary: %{revision_by_client_pid: %{^client_pid => 0}}}} + } }, []} = Data.apply_operation(data, operation) end @@ -461,7 +463,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}}, {:set_runtime, self(), NoopRuntime.new()}, {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, - {:smart_cell_started, self(), "c2", Delta.new(), %{}} + {:smart_cell_started, self(), "c2", Delta.new(), %{}, nil} ]) operation = {:insert_cell, self(), "s1", 0, :code, "c3", %{}} @@ -813,7 +815,7 @@ defmodule Livebook.Session.DataTest do {:set_runtime, self(), NoopRuntime.new()}, {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, {:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}}, - {:smart_cell_started, self(), "c1", Delta.new(), %{}} + {:smart_cell_started, self(), "c1", Delta.new(), %{}, nil} ]) operation = {:delete_cell, self(), "c1"} @@ -830,7 +832,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}}, {:set_runtime, self(), NoopRuntime.new()}, {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, - {:smart_cell_started, self(), "c2", Delta.new(), %{}}, + {:smart_cell_started, self(), "c2", Delta.new(), %{}, nil}, {:queue_cells_evaluation, self(), ["c1"]}, {:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta} ]) @@ -2373,7 +2375,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}}, {:set_runtime, self(), NoopRuntime.new()}, {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, - {:smart_cell_started, self(), "c2", Delta.new(), %{}}, + {:smart_cell_started, self(), "c2", Delta.new(), %{}, nil}, {:queue_cells_evaluation, self(), ["c1"]} ]) @@ -2683,7 +2685,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}} ]) - operation = {:smart_cell_started, self(), "c1", Delta.new(), %{}} + operation = {:smart_cell_started, self(), "c1", Delta.new(), %{}, nil} assert :error = Data.apply_operation(data, operation) end @@ -2699,7 +2701,7 @@ defmodule Livebook.Session.DataTest do delta = Delta.new() |> Delta.insert("content") - operation = {:smart_cell_started, self(), "c1", delta, %{}} + operation = {:smart_cell_started, self(), "c1", delta, %{}, nil} assert {:ok, %{cell_infos: %{"c1" => %{status: :started}}}, _actions} = Data.apply_operation(data, operation) @@ -2718,13 +2720,13 @@ defmodule Livebook.Session.DataTest do delta = Delta.new() |> Delta.insert("content") - operation = {:smart_cell_started, client_pid, "c1", delta, %{}} + operation = {:smart_cell_started, client_pid, "c1", delta, %{}, nil} assert {:ok, %{ notebook: %{sections: [%{cells: [%{id: "c1", source: "content"}]}]} }, - [{:broadcast_delta, ^client_pid, _cell, ^delta}]} = + [{:broadcast_delta, ^client_pid, _cell, :primary, ^delta}]} = Data.apply_operation(data, operation) end end @@ -2741,7 +2743,7 @@ defmodule Livebook.Session.DataTest do {:set_runtime, self(), NoopRuntime.new()}, {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, {:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}}, - {:smart_cell_started, self(), "c1", delta1, %{}} + {:smart_cell_started, self(), "c1", delta1, %{}, nil} ]) attrs = %{"text" => "content!"} @@ -2754,7 +2756,7 @@ defmodule Livebook.Session.DataTest do sections: [%{cells: [%{id: "c1", source: "content!", attrs: ^attrs}]}] } }, - [{:broadcast_delta, ^client_pid, _cell, ^delta2}]} = + [{:broadcast_delta, ^client_pid, _cell, :primary, ^delta2}]} = Data.apply_operation(data, operation) end end @@ -2888,7 +2890,7 @@ defmodule Livebook.Session.DataTest do {:client_join, client1_pid, user}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_pid, "c1", delta1, 1} + {:apply_cell_delta, client1_pid, "c1", :primary, delta1, 1} ]) client2_pid = IEx.Helpers.pid(0, 0, 1) @@ -2896,7 +2898,9 @@ defmodule Livebook.Session.DataTest do assert {:ok, %{ - cell_infos: %{"c1" => %{source: %{revision_by_client_pid: %{^client2_pid => 1}}}} + cell_infos: %{ + "c1" => %{sources: %{primary: %{revision_by_client_pid: %{^client2_pid => 1}}}} + } }, _} = Data.apply_operation(data, operation) end end @@ -2961,7 +2965,7 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_pid, "c1", delta1, 1} + {:apply_cell_delta, client1_pid, "c1", :primary, delta1, 1} ]) operation = {:client_leave, client2_pid} @@ -2969,7 +2973,11 @@ defmodule Livebook.Session.DataTest do assert {:ok, %{ cell_infos: %{ - "c1" => %{source: %{deltas: [], revision_by_client_pid: revision_by_client_pid}} + "c1" => %{ + sources: %{ + primary: %{deltas: [], revision_by_client_pid: revision_by_client_pid} + } + } } }, _} = Data.apply_operation(data, operation) @@ -3008,7 +3016,7 @@ defmodule Livebook.Session.DataTest do {:client_join, self(), User.new()} ]) - operation = {:apply_cell_delta, self(), "nonexistent", Delta.new(), 1} + operation = {:apply_cell_delta, self(), "nonexistent", :primary, Delta.new(), 1} assert :error = Data.apply_operation(data, operation) end @@ -3020,7 +3028,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, self(), "c1", delta, 1} + operation = {:apply_cell_delta, self(), "c1", :primary, delta, 1} assert :error = Data.apply_operation(data, operation) end @@ -3033,7 +3041,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, self(), "c1", delta, 5} + operation = {:apply_cell_delta, self(), "c1", :primary, delta, 5} assert :error = Data.apply_operation(data, operation) end @@ -3047,7 +3055,7 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, self(), "c1", delta, 1} + operation = {:apply_cell_delta, self(), "c1", :primary, delta, 1} assert {:ok, %{ @@ -3056,7 +3064,7 @@ defmodule Livebook.Session.DataTest do %{cells: [%{source: "cats"}]} ] }, - cell_infos: %{"c1" => %{source: %{revision: 1}}} + cell_infos: %{"c1" => %{sources: %{primary: %{revision: 1}}}} }, _actions} = Data.apply_operation(data, operation) end @@ -3072,11 +3080,11 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_pid, "c1", delta1, 1} + {:apply_cell_delta, client1_pid, "c1", :primary, delta1, 1} ]) delta2 = Delta.new() |> Delta.insert("tea") - operation = {:apply_cell_delta, client2_pid, "c1", delta2, 1} + operation = {:apply_cell_delta, client2_pid, "c1", :primary, delta2, 1} assert {:ok, %{ @@ -3085,7 +3093,7 @@ defmodule Livebook.Session.DataTest do %{cells: [%{source: "catstea"}]} ] }, - cell_infos: %{"c1" => %{source: %{revision: 2}}} + cell_infos: %{"c1" => %{sources: %{primary: %{revision: 2}}}} }, _} = Data.apply_operation(data, operation) end @@ -3101,15 +3109,16 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_pid, "c1", delta1, 1} + {:apply_cell_delta, client1_pid, "c1", :primary, delta1, 1} ]) delta2 = Delta.new() |> Delta.insert("tea") - operation = {:apply_cell_delta, client2_pid, "c1", delta2, 1} + operation = {:apply_cell_delta, client2_pid, "c1", :primary, delta2, 1} transformed_delta2 = Delta.new() |> Delta.retain(4) |> Delta.insert("tea") - assert {:ok, _data, [{:broadcast_delta, ^client2_pid, _cell, ^transformed_delta2}]} = + assert {:ok, _data, + [{:broadcast_delta, ^client2_pid, _cell, :primary, ^transformed_delta2}]} = Data.apply_operation(data, operation) end @@ -3124,11 +3133,11 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, client_pid, "c1", delta, 1} + operation = {:apply_cell_delta, client_pid, "c1", :primary, delta, 1} assert {:ok, %{ - cell_infos: %{"c1" => %{source: %{deltas: []}}} + cell_infos: %{"c1" => %{sources: %{primary: %{deltas: []}}}} }, _} = Data.apply_operation(data, operation) end @@ -3145,13 +3154,39 @@ defmodule Livebook.Session.DataTest do ]) delta = Delta.new() |> Delta.insert("cats") - operation = {:apply_cell_delta, client1_pid, "c1", delta, 1} + operation = {:apply_cell_delta, client1_pid, "c1", :primary, delta, 1} assert {:ok, %{ - cell_infos: %{"c1" => %{source: %{deltas: [^delta]}}} + cell_infos: %{"c1" => %{sources: %{primary: %{deltas: [^delta]}}}} }, _} = Data.apply_operation(data, operation) end + + test "updates smart cell editor source given a secondary source delta" do + data = + data_after_operations!([ + {:client_join, self(), User.new()}, + {:insert_section, self(), 0, "s1"}, + {:insert_cell, self(), "s1", 1, :smart, "c1", %{kind: "text"}}, + {:set_runtime, self(), NoopRuntime.new()}, + {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, + {:smart_cell_started, self(), "c1", Delta.new(), %{}, + %{language: "text", placement: :bottom, source: ""}} + ]) + + delta = Delta.new() |> Delta.insert("cats") + operation = {:apply_cell_delta, self(), "c1", :secondary, delta, 1} + + assert {:ok, + %{ + notebook: %{ + sections: [ + %{cells: [%{editor: %{source: "cats"}}]} + ] + }, + cell_infos: %{"c1" => %{sources: %{secondary: %{revision: 1}}}} + }, _actions} = Data.apply_operation(data, operation) + end end describe "apply_operation/2 given :report_cell_revision" do @@ -3161,7 +3196,7 @@ defmodule Livebook.Session.DataTest do {:client_join, self(), User.new()} ]) - operation = {:report_cell_revision, self(), "nonexistent", 1} + operation = {:report_cell_revision, self(), "nonexistent", :primary, 1} assert :error = Data.apply_operation(data, operation) end @@ -3174,10 +3209,10 @@ defmodule Livebook.Session.DataTest do {:client_join, client1_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_pid, "c1", Delta.new(insert: "cats"), 1} + {:apply_cell_delta, client1_pid, "c1", :primary, Delta.new(insert: "cats"), 1} ]) - operation = {:report_cell_revision, client2_pid, "c1", 1} + operation = {:report_cell_revision, client2_pid, "c1", :primary, 1} assert :error = Data.apply_operation(data, operation) end @@ -3189,7 +3224,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, self(), "s1", 0, :code, "c1", %{}} ]) - operation = {:report_cell_revision, self(), "c1", 1} + operation = {:report_cell_revision, self(), "c1", :primary, 1} assert :error = Data.apply_operation(data, operation) end @@ -3205,18 +3240,20 @@ defmodule Livebook.Session.DataTest do {:client_join, client2_pid, User.new()}, {:insert_section, self(), 0, "s1"}, {:insert_cell, self(), "s1", 0, :code, "c1", %{}}, - {:apply_cell_delta, client1_pid, "c1", delta1, 1} + {:apply_cell_delta, client1_pid, "c1", :primary, delta1, 1} ]) - operation = {:report_cell_revision, client2_pid, "c1", 1} + operation = {:report_cell_revision, client2_pid, "c1", :primary, 1} assert {:ok, %{ cell_infos: %{ "c1" => %{ - source: %{ - deltas: [], - revision_by_client_pid: %{^client1_pid => 1, ^client2_pid => 1} + sources: %{ + primary: %{ + deltas: [], + revision_by_client_pid: %{^client1_pid => 1, ^client2_pid => 1} + } } } } @@ -3507,7 +3544,7 @@ defmodule Livebook.Session.DataTest do {:add_cell_evaluation_response, self(), "c3", @eval_resp, @eval_meta}, # Modify cell 2 {:client_join, self(), User.new()}, - {:apply_cell_delta, self(), "c2", Delta.new() |> Delta.insert("cats"), 1} + {:apply_cell_delta, self(), "c2", :primary, Delta.new() |> Delta.insert("cats"), 1} ]) assert Data.cell_ids_for_full_evaluation(data, []) |> Enum.sort() == ["c2", "c3"] diff --git a/test/livebook/session_test.exs b/test/livebook/session_test.exs index d8b75eb37..a34227f73 100644 --- a/test/livebook/session_test.exs +++ b/test/livebook/session_test.exs @@ -190,8 +190,10 @@ defmodule Livebook.SessionTest do delta = Delta.new() |> Delta.insert("cats") revision = 1 - Session.apply_cell_delta(session.pid, cell_id, delta, revision) - assert_receive {:operation, {:apply_cell_delta, ^pid, ^cell_id, ^delta, ^revision}} + Session.apply_cell_delta(session.pid, cell_id, :primary, delta, revision) + + assert_receive {:operation, + {:apply_cell_delta, ^pid, ^cell_id, :primary, ^delta, ^revision}} end end @@ -203,8 +205,8 @@ defmodule Livebook.SessionTest do {_section_id, cell_id} = insert_section_and_cell(session.pid) revision = 1 - Session.report_cell_revision(session.pid, cell_id, revision) - assert_receive {:operation, {:report_cell_revision, ^pid, ^cell_id, ^revision}} + Session.report_cell_revision(session.pid, cell_id, :primary, revision) + assert_receive {:operation, {:report_cell_revision, ^pid, ^cell_id, :primary, ^revision}} end end @@ -561,14 +563,7 @@ defmodule Livebook.SessionTest do describe "smart cells" do test "notifies subcribers when a smart cell starts and passes source diff as delta" do smart_cell = %{Notebook.Cell.new(:smart) | kind: "text", source: "content"} - - notebook = %{ - Notebook.new() - | sections: [ - %{Notebook.Section.new() | cells: [smart_cell]} - ] - } - + notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]} session = start_session(notebook: notebook) runtime = Livebook.Runtime.NoopRuntime.new() @@ -580,13 +575,44 @@ defmodule Livebook.SessionTest do send( session.pid, - {:runtime_smart_cell_started, smart_cell.id, %{source: "content!", js_view: %{}}} + {:runtime_smart_cell_started, smart_cell.id, + %{source: "content!", js_view: %{}, editor: nil}} ) delta = Delta.new() |> Delta.retain(7) |> Delta.insert("!") cell_id = smart_cell.id - assert_receive {:operation, {:smart_cell_started, _, ^cell_id, ^delta, %{}}} + assert_receive {:operation, {:smart_cell_started, _, ^cell_id, ^delta, %{}, nil}} + end + + test "sends an event to the smart cell server when the editor source changes" do + smart_cell = %{Notebook.Cell.new(:smart) | kind: "text", source: ""} + notebook = %{Notebook.new() | sections: [%{Notebook.Section.new() | cells: [smart_cell]}]} + session = start_session(notebook: notebook) + + runtime = Livebook.Runtime.NoopRuntime.new() + Session.connect_runtime(session.pid, runtime) + + send(session.pid, {:runtime_smart_cell_definitions, [%{kind: "text", name: "Text"}]}) + + server_pid = self() + + send( + session.pid, + {:runtime_smart_cell_started, smart_cell.id, + %{ + source: "content", + js_view: %{ref: smart_cell.id, pid: server_pid, assets: %{}}, + editor: %{language: nil, placement: :bottom, source: "content"} + }} + ) + + 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) + + assert_receive {:editor_source, "content!"} end end