Add support for smart cell editor (#1050)

* Add support for smart cell editor

* Log an error when smart cell fails to start
This commit is contained in:
Jonatan Kłosko 2022-03-14 22:19:56 +01:00 committed by GitHub
parent e6a9cf94ea
commit 6db36ea7e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 717 additions and 404 deletions

View file

@ -151,7 +151,7 @@ Also some spacing adjustments.
/* When in the first line, we want to display cursor and label in the same line */ /* When in the first line, we want to display cursor and label in the same line */
.monaco-cursor-widget-container.inline { .monaco-cursor-widget-container.inline {
display: flex; display: flex !important;
} }
.monaco-cursor-widget-container.inline .monaco-cursor-widget-label { .monaco-cursor-widget-container.inline .monaco-cursor-widget-label {

View file

@ -13,6 +13,7 @@ import topbar from "topbar";
import { LiveSocket } from "phoenix_live_view"; import { LiveSocket } from "phoenix_live_view";
import Headline from "./headline"; import Headline from "./headline";
import Cell from "./cell"; import Cell from "./cell";
import CellEditor from "./cell_editor";
import Session from "./session"; import Session from "./session";
import FocusOnUpdate from "./focus_on_update"; import FocusOnUpdate from "./focus_on_update";
import ScrollOnUpdate from "./scroll_on_update"; import ScrollOnUpdate from "./scroll_on_update";
@ -34,6 +35,7 @@ import { settingsStore } from "./lib/settings";
const hooks = { const hooks = {
Headline, Headline,
Cell, Cell,
CellEditor,
Session, Session,
FocusOnUpdate, FocusOnUpdate,
ScrollOnUpdate, ScrollOnUpdate,

View file

@ -1,6 +1,5 @@
import { getAttributeOrThrow } from "../lib/attribute"; import { getAttributeOrDefault, getAttributeOrThrow } from "../lib/attribute";
import LiveEditor from "./live_editor"; import Markdown from "../lib/markdown";
import Markdown from "./markdown";
import { globalPubSub } from "../lib/pub_sub"; import { globalPubSub } from "../lib/pub_sub";
import { md5Base64, smoothlyScrollToElement } from "../lib/utils"; import { md5Base64, smoothlyScrollToElement } from "../lib/utils";
import scrollIntoView from "scroll-into-view-if-needed"; 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-cell-id` - id of the cell being edited
* * `data-type` - type of the cell * * `data-type` - type of the cell
* * `data-session-path` - root path to the current session * * `data-session-path` - root path to the current session
* * `data-evaluation-digest` - digest of the last evaluated cell source
*/ */
const Cell = { const Cell = {
mounted() { mounted() {
@ -24,13 +24,13 @@ const Cell = {
this.state = { this.state = {
isFocused: false, isFocused: false,
insertMode: false, insertMode: false,
// For text cells (markdown or code) liveEditors: {},
liveEditor: null,
markdown: null,
evaluationDigest: null,
}; };
updateInsertModeAvailability(this);
// Setup action handlers // Setup action handlers
if (this.props.type === "code") { if (this.props.type === "code") {
const amplifyButton = this.el.querySelector( const amplifyButton = this.el.querySelector(
`[data-element="amplify-outputs-button"]` `[data-element="amplify-outputs-button"]`
@ -46,119 +46,21 @@ const Cell = {
); );
toggleSourceButton.addEventListener("click", (event) => { toggleSourceButton.addEventListener("click", (event) => {
this.el.toggleAttribute("data-js-source-visible"); this.el.toggleAttribute("data-js-source-visible");
updateInsertModeAvailability(this);
maybeFocusCurrentEditor(this);
}); });
} }
this.handleEvent(`cell_init:${this.props.cellId}`, (payload) => { // Setup listeners
const { source, revision, evaluation_digest } = payload;
// Setup markdown rendering this.el.addEventListener("lb:cell:editor_created", (event) => {
if (this.props.type === "markdown") { const { tag, liveEditor } = event.detail;
const markdownContainer = this.el.querySelector( handleCellEditorCreated(this, tag, liveEditor);
`[data-element="markdown-container"]`
);
this.state.markdown = new Markdown(markdownContainer, source, {
baseUrl: this.props.sessionPath,
emptyText: "Empty markdown cell",
});
}
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( this.el.addEventListener("lb:cell:editor_removed", (event) => {
`evaluation_finished:${this.props.cellId}`, const { tag } = event.detail;
({ code_error }) => { handleCellEditorRemoved(this, tag);
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._unsubscribeFromNavigationEvents = globalPubSub.subscribe( this._unsubscribeFromNavigationEvents = globalPubSub.subscribe(
@ -187,14 +89,15 @@ const Cell = {
destroyed() { destroyed() {
this._unsubscribeFromNavigationEvents(); this._unsubscribeFromNavigationEvents();
this._unsubscribeFromCellsEvents(); this._unsubscribeFromCellsEvents();
if (this.state.liveEditor) {
this.state.liveEditor.dispose();
}
}, },
updated() { updated() {
const prevProps = this.props;
this.props = getProps(this); 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"), cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
type: getAttributeOrThrow(hook.el, "data-type"), type: getAttributeOrThrow(hook.el, "data-type"),
sessionPath: getAttributeOrThrow(hook.el, "data-session-path"), 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) { function handleInsertModeChanged(hook, insertMode) {
if (hook.state.isFocused && !hook.state.insertMode && insertMode) { if (hook.state.isFocused && !hook.state.insertMode && insertMode) {
hook.state.insertMode = insertMode; hook.state.insertMode = insertMode;
if (hook.state.liveEditor) { if (currentEditor(hook)) {
hook.state.liveEditor.focus(); currentEditor(hook).focus();
// The insert mode may be enabled as a result of clicking the editor, // 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 // 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) { } else if (hook.state.insertMode && !insertMode) {
hook.state.insertMode = insertMode; hook.state.insertMode = insertMode;
if (hook.state.liveEditor) { if (currentEditor(hook)) {
hook.state.liveEditor.blur(); currentEditor(hook).blur();
} }
} }
} }
@ -281,37 +293,44 @@ function handleCellMoved(hook, cellId) {
} }
function handleCellUpload(hook, cellId, url) { function handleCellUpload(hook, cellId, url) {
if (!hook.state.liveEditor) { const liveEditor = hook.state.liveEditors.primary;
if (!liveEditor) {
return; return;
} }
if (hook.props.cellId === cellId) { if (hook.props.cellId === cellId) {
const markdown = `![](${url})`; const markdown = `![](${url})`;
hook.state.liveEditor.insert(markdown); liveEditor.insert(markdown);
} }
} }
function handleLocationReport(hook, client, report) { function handleLocationReport(hook, client, report) {
if (!hook.state.liveEditor) { Object.entries(hook.state.liveEditors).forEach(([tag, liveEditor]) => {
return; if (
} hook.props.cellId === report.focusableId &&
report.selection &&
if (hook.props.cellId === report.focusableId && report.selection) { report.selection.tag === tag
hook.state.liveEditor.updateUserSelection(client, report.selection); ) {
liveEditor.updateUserSelection(client, report.selection.editorSelection);
} else { } else {
hook.state.liveEditor.removeUserSelection(client); liveEditor.removeUserSelection(client);
} }
});
} }
function broadcastSelection(hook, selection = null) { function broadcastSelection(hook, editorSelection = null) {
selection = selection || hook.state.liveEditor.editor.getSelection(); editorSelection =
editorSelection || currentEditor(hook).editor.getSelection();
const tag = currentEditorTag(hook);
// Report new selection only if this cell is in insert mode // Report new selection only if this cell is in insert mode
if (hook.state.isFocused && hook.state.insertMode) { if (hook.state.isFocused && hook.state.insertMode) {
globalPubSub.broadcast("session", { globalPubSub.broadcast("session", {
type: "cursor_selection_changed", type: "cursor_selection_changed",
focusableId: hook.props.cellId, focusableId: hook.props.cellId,
selection, selection: { tag, editorSelection },
}); });
} }
} }

View file

@ -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;

View file

@ -10,12 +10,23 @@ import { settingsStore } from "../lib/settings";
* Mounts cell source editor with real-time collaboration mechanism. * Mounts cell source editor with real-time collaboration mechanism.
*/ */
class LiveEditor { class LiveEditor {
constructor(hook, container, cellId, source, revision, language, readOnly) { constructor(
hook,
container,
cellId,
tag,
source,
revision,
language,
intellisense,
readOnly
) {
this.hook = hook; this.hook = hook;
this.container = container; this.container = container;
this.cellId = cellId; this.cellId = cellId;
this.source = source; this.source = source;
this.language = language; this.language = language;
this.intellisense = intellisense;
this.readOnly = readOnly; this.readOnly = readOnly;
this._onChange = null; this._onChange = null;
this._onBlur = null; this._onBlur = null;
@ -24,11 +35,11 @@ class LiveEditor {
this.__mountEditor(); this.__mountEditor();
if (language === "elixir") { if (this.intellisense) {
this.__setupIntellisense(); this.__setupIntellisense();
} }
const serverAdapter = new HookServerAdapter(hook, cellId); const serverAdapter = new HookServerAdapter(hook, cellId, tag);
const editorAdapter = new MonacoEditorAdapter(this.editor); const editorAdapter = new MonacoEditorAdapter(this.editor);
this.editorClient = new EditorClient( this.editorClient = new EditorClient(
serverAdapter, serverAdapter,
@ -147,7 +158,7 @@ class LiveEditor {
* To clear an existing marker `null` error is also supported. * To clear an existing marker `null` error is also supported.
*/ */
setCodeErrorMarker(error) { setCodeErrorMarker(error) {
const owner = "elixir.error.syntax"; const owner = "livebook.error.syntax";
if (error) { if (error) {
const line = this.editor.getModel().getLineContent(error.line); const line = this.editor.getModel().getLineContent(error.line);
@ -198,17 +209,15 @@ class LiveEditor {
autoIndent: true, autoIndent: true,
formatOnType: true, formatOnType: true,
formatOnPaste: true, formatOnPaste: true,
quickSuggestions: quickSuggestions: this.intellisense && settings.editor_auto_completion,
this.language === "elixir" && settings.editor_auto_completion,
tabCompletion: "on", tabCompletion: "on",
suggestSelection: "first", suggestSelection: "first",
// For Elixir word suggestions are confusing at times. // For Elixir word suggestions are confusing at times.
// For example given `defmodule<CURSOR> Foo do`, if the // For example given `defmodule<CURSOR> Foo do`, if the
// user opens completion list and then jumps to the end // user opens completion list and then jumps to the end
// of the line we would get "defmodule" as a word completion. // of the line we would get "defmodule" as a word completion.
wordBasedSuggestions: this.language !== "elixir", wordBasedSuggestions: !this.intellisense,
parameterHints: parameterHints: this.intellisense && settings.editor_auto_signature,
this.language === "elixir" && settings.editor_auto_signature,
}); });
this.editor.addAction({ this.editor.addAction({
@ -219,6 +228,7 @@ class LiveEditor {
keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyZ], keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyZ],
run: (editor) => editor.updateOptions({ wordWrap: "on" }), run: (editor) => editor.updateOptions({ wordWrap: "on" }),
}); });
this.editor.addAction({ this.editor.addAction({
contextMenuGroupId: "word-wrapping", contextMenuGroupId: "word-wrapping",
id: "disable-word-wrapping", id: "disable-word-wrapping",

View file

@ -6,19 +6,26 @@ import Delta from "../../lib/delta";
* Uses the given hook instance socket for the communication. * Uses the given hook instance socket for the communication.
*/ */
export default class HookServerAdapter { export default class HookServerAdapter {
constructor(hook, cellId) { constructor(hook, cellId, tag) {
this.hook = hook; this.hook = hook;
this.cellId = cellId; this.cellId = cellId;
this.tag = tag;
this._onDelta = null; this._onDelta = null;
this._onAcknowledgement = null; this._onAcknowledgement = null;
this.hook.handleEvent(`cell_delta:${this.cellId}`, ({ delta }) => { this.hook.handleEvent(
`cell_delta:${this.cellId}:${this.tag}`,
({ delta }) => {
this._onDelta && this._onDelta(Delta.fromCompressed(delta)); this._onDelta && this._onDelta(Delta.fromCompressed(delta));
}); }
);
this.hook.handleEvent(`cell_acknowledgement:${this.cellId}`, () => { this.hook.handleEvent(
`cell_acknowledgement:${this.cellId}:${this.tag}`,
() => {
this._onAcknowledgement && this._onAcknowledgement(); this._onAcknowledgement && this._onAcknowledgement();
}); }
);
} }
/** /**
@ -41,6 +48,7 @@ export default class HookServerAdapter {
sendDelta(delta, revision) { sendDelta(delta, revision) {
this.hook.pushEvent("apply_cell_delta", { this.hook.pushEvent("apply_cell_delta", {
cell_id: this.cellId, cell_id: this.cellId,
tag: this.tag,
delta: delta.toCompressed(), delta: delta.toCompressed(),
revision, revision,
}); });
@ -56,6 +64,7 @@ export default class HookServerAdapter {
reportRevision(revision) { reportRevision(revision) {
this.hook.pushEvent("report_cell_revision", { this.hook.pushEvent("report_cell_revision", {
cell_id: this.cellId, cell_id: this.cellId,
tag: this.tag,
revision, revision,
}); });
} }

View file

@ -1,5 +1,5 @@
import { getAttributeOrThrow } from "../lib/attribute"; 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"; import { findChildOrThrow } from "../lib/utils";
/** /**

View file

@ -269,15 +269,22 @@ function bindIframeSize(iframe, iframePlaceholder) {
); );
function repositionIframe() { function repositionIframe() {
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 notebookBox = notebookEl.getBoundingClientRect();
const placeholderBox = iframePlaceholder.getBoundingClientRect(); const placeholderBox = iframePlaceholder.getBoundingClientRect();
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop; const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
iframe.style.top = `${top}px`; iframe.style.top = `${top}px`;
const left = placeholderBox.left - notebookBox.left + notebookEl.scrollLeft; const left =
placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
iframe.style.left = `${left}px`; iframe.style.left = `${left}px`;
iframe.style.height = `${placeholderBox.height}px`; iframe.style.height = `${placeholderBox.height}px`;
iframe.style.width = `${placeholderBox.width}px`; iframe.style.width = `${placeholderBox.width}px`;
} }
}
// Most placeholder position changes are accompanied by changes to the // Most placeholder position changes are accompanied by changes to the
// notebook content element height (adding cells, inserting newlines // notebook content element height (adding cells, inserting newlines

View file

@ -15,7 +15,7 @@ import { visit } from "unist-util-visit";
import { toText } from "hast-util-to-text"; import { toText } from "hast-util-to-text";
import { removePosition } from "unist-util-remove-position"; 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 { renderMermaid } from "./markdown/mermaid";
import { escapeHtml } from "../lib/utils"; import { escapeHtml } from "../lib/utils";

View file

@ -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) { export function isDirectlyEditable(cellType) {
return ["markdown", "code"].includes(cellType); return ["markdown", "code"].includes(cellType);

View file

@ -1,5 +1,5 @@
import { getAttributeOrThrow } from "../lib/attribute"; 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. * A hook used to render markdown content on the client.

View file

@ -10,7 +10,7 @@ import {
import { getAttributeOrDefault } from "../lib/attribute"; import { getAttributeOrDefault } from "../lib/attribute";
import KeyBuffer from "./key_buffer"; import KeyBuffer from "./key_buffer";
import { globalPubSub } from "../lib/pub_sub"; 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 { leaveChannel } from "../js_view";
import { isDirectlyEditable, isEvaluable } from "../lib/notebook"; import { isDirectlyEditable, isEvaluable } from "../lib/notebook";
@ -477,12 +477,12 @@ function handleDocumentMouseDown(hook, event) {
} }
} }
function editableElementClicked(event, element) { function editableElementClicked(event, focusableEl) {
if (element) { if (focusableEl) {
const editableElement = element.querySelector( const editableElement = event.target.closest(
`[data-element="editor-container"], [data-element="heading"]` `[data-element="editor-container"], [data-element="heading"]`
); );
return editableElement && editableElement.contains(event.target); return editableElement && focusableEl.contains(editableElement);
} }
return false; return false;
@ -748,9 +748,11 @@ function showShortcuts(hook) {
} }
function isInsertModeAvailable(hook) { function isInsertModeAvailable(hook) {
const el = getFocusableEl(hook.state.focusedId);
return ( return (
hook.state.focusedCellType === null || !isCell(hook.state.focusedId) ||
isDirectlyEditable(hook.state.focusedCellType) !el.hasAttribute("data-js-insert-mode-disabled")
); );
} }
@ -1011,11 +1013,14 @@ function sendLocationReport(hook, report) {
function encodeSelection(selection) { function encodeSelection(selection) {
if (selection === null) return null; if (selection === null) return null;
const { tag, editorSelection } = selection;
return [ return [
selection.selectionStartLineNumber, tag,
selection.selectionStartColumn, editorSelection.selectionStartLineNumber,
selection.positionLineNumber, editorSelection.selectionStartColumn,
selection.positionColumn, editorSelection.positionLineNumber,
editorSelection.positionColumn,
]; ];
} }
@ -1023,18 +1028,21 @@ function decodeSelection(encoded) {
if (encoded === null) return null; if (encoded === null) return null;
const [ const [
tag,
selectionStartLineNumber, selectionStartLineNumber,
selectionStartColumn, selectionStartColumn,
positionLineNumber, positionLineNumber,
positionColumn, positionColumn,
] = encoded; ] = encoded;
return new monaco.Selection( const editorSelection = new monaco.Selection(
selectionStartLineNumber, selectionStartLineNumber,
selectionStartColumn, selectionStartColumn,
positionLineNumber, positionLineNumber,
positionColumn positionColumn
); );
return { tag, editorSelection };
} }
// Helpers // Helpers

View file

@ -3,7 +3,7 @@ defmodule Livebook.Notebook.Cell.Smart do
# A cell with Elixir code that is edited through a dedicated UI. # 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.Utils
alias Livebook.Notebook.Cell alias Livebook.Notebook.Cell
@ -14,11 +14,14 @@ defmodule Livebook.Notebook.Cell.Smart do
outputs: list(Cell.indexed_output()), outputs: list(Cell.indexed_output()),
kind: String.t(), kind: String.t(),
attrs: attrs(), attrs: attrs(),
js_view: Livebook.Runtime.js_view() | nil js_view: Livebook.Runtime.js_view() | nil,
editor: editor() | nil
} }
@type attrs :: map() @type attrs :: map()
@type editor :: %{language: String.t(), placement: :bottom | :top, source: String.t()}
@doc """ @doc """
Returns an empty cell. Returns an empty cell.
""" """
@ -30,7 +33,8 @@ defmodule Livebook.Notebook.Cell.Smart do
outputs: [], outputs: [],
kind: nil, kind: nil,
attrs: %{}, attrs: %{},
js_view: nil js_view: nil,
editor: nil
} }
end end
end end

View file

@ -377,15 +377,17 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
) do ) do
{:ok, pid, info} -> {:ok, pid, info} ->
%{ %{
js_view: js_view,
source: source, source: source,
js_view: js_view,
editor: editor,
scan_binding: scan_binding, scan_binding: scan_binding,
scan_eval_result: scan_eval_result scan_eval_result: scan_eval_result
} = info } = info
send( send(
state.owner, 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 = %{ info = %{
@ -400,7 +402,8 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
info = scan_binding_async(ref, info, state) info = scan_binding_async(ref, info, state)
put_in(state.smart_cells[ref], info) put_in(state.smart_cells[ref], info)
_ -> {:error, error} ->
Logger.error("failed to start smart cell, reason: #{inspect(error)}")
state state
end end

View file

@ -380,9 +380,15 @@ defmodule Livebook.Session do
@doc """ @doc """
Sends a cell delta to apply to the server. Sends a cell delta to apply to the server.
""" """
@spec apply_cell_delta(pid(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok @spec apply_cell_delta(
def apply_cell_delta(pid, cell_id, delta, revision) do pid(),
GenServer.cast(pid, {:apply_cell_delta, self(), cell_id, delta, revision}) 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 end
@doc """ @doc """
@ -390,9 +396,14 @@ defmodule Livebook.Session do
This helps to remove old deltas that are no longer necessary. This helps to remove old deltas that are no longer necessary.
""" """
@spec report_cell_revision(pid(), Cell.id(), Data.cell_revision()) :: :ok @spec report_cell_revision(
def report_cell_revision(pid, cell_id, revision) do pid(),
GenServer.cast(pid, {:report_cell_revision, self(), cell_id, revision}) 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 end
@doc """ @doc """
@ -770,13 +781,13 @@ defmodule Livebook.Session do
{:noreply, handle_operation(state, operation)} {:noreply, handle_operation(state, operation)}
end end
def handle_cast({:apply_cell_delta, client_pid, cell_id, delta, revision}, state) do def handle_cast({:apply_cell_delta, client_pid, cell_id, tag, delta, revision}, state) do
operation = {:apply_cell_delta, client_pid, cell_id, delta, revision} operation = {:apply_cell_delta, client_pid, cell_id, tag, delta, revision}
{:noreply, handle_operation(state, operation)} {:noreply, handle_operation(state, operation)}
end end
def handle_cast({:report_cell_revision, client_pid, cell_id, revision}, state) do def handle_cast({:report_cell_revision, client_pid, cell_id, tag, revision}, state) do
operation = {:report_cell_revision, client_pid, cell_id, revision} operation = {:report_cell_revision, client_pid, cell_id, tag, revision}
{:noreply, handle_operation(state, operation)} {:noreply, handle_operation(state, operation)}
end end
@ -914,7 +925,7 @@ defmodule Livebook.Session do
case Notebook.fetch_cell_and_section(state.data.notebook, id) do case Notebook.fetch_cell_and_section(state.data.notebook, id) do
{:ok, cell, _section} -> {:ok, cell, _section} ->
delta = Livebook.JSInterop.diff(cell.source, info.source) 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)} {:noreply, handle_operation(state, operation)}
:error -> :error ->
@ -922,11 +933,11 @@ defmodule Livebook.Session do
end end
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 case Notebook.fetch_cell_and_section(state.data.notebook, id) do
{:ok, cell, _section} -> {:ok, cell, _section} ->
delta = Livebook.JSInterop.diff(cell.source, source) 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)} {:noreply, handle_operation(state, operation)}
:error -> :error ->
@ -1181,6 +1192,20 @@ defmodule Livebook.Session do
state state
end 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 after_operation(state, _prev_state, _operation), do: state
defp handle_actions(state, actions) do defp handle_actions(state, actions) do

View file

@ -61,20 +61,22 @@ defmodule Livebook.Session.Data do
@type cell_info :: markdown_cell_info() | code_cell_info() | smart_cell_info() @type cell_info :: markdown_cell_info() | code_cell_info() | smart_cell_info()
@type markdown_cell_info :: %{ @type markdown_cell_info :: %{
source: cell_source_info() sources: %{primary: cell_source_info()}
} }
@type code_cell_info :: %{ @type code_cell_info :: %{
source: cell_source_info(), sources: %{primary: cell_source_info()},
eval: cell_eval_info() eval: cell_eval_info()
} }
@type smart_cell_info :: %{ @type smart_cell_info :: %{
source: cell_source_info(), sources: %{primary: cell_source_info(), secondary: cell_source_info()},
eval: cell_eval_info(), eval: cell_eval_info(),
status: smart_cell_status() status: smart_cell_status()
} }
@type cell_source_tag :: atom()
@type cell_source_info :: %{ @type cell_source_info :: %{
revision: cell_revision(), revision: cell_revision(),
deltas: list(Delta.t()), deltas: list(Delta.t()),
@ -163,7 +165,8 @@ defmodule Livebook.Session.Data do
| {:reflect_main_evaluation_failure, pid()} | {:reflect_main_evaluation_failure, pid()}
| {:reflect_evaluation_failure, pid(), Section.id()} | {:reflect_evaluation_failure, pid(), Section.id()}
| {:cancel_cell_evaluation, pid(), Cell.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()} | {:update_smart_cell, pid(), Cell.id(), Cell.Smart.attrs(), Delta.t()}
| {:erase_outputs, pid()} | {:erase_outputs, pid()}
| {:set_notebook_name, pid(), String.t()} | {:set_notebook_name, pid(), String.t()}
@ -171,8 +174,8 @@ defmodule Livebook.Session.Data do
| {:client_join, pid(), User.t()} | {:client_join, pid(), User.t()}
| {:client_leave, pid()} | {:client_leave, pid()}
| {:update_user, pid(), User.t()} | {:update_user, pid(), User.t()}
| {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()} | {:apply_cell_delta, pid(), Cell.id(), cell_source_tag(), Delta.t(), cell_revision()}
| {:report_cell_revision, pid(), Cell.id(), cell_revision()} | {:report_cell_revision, pid(), Cell.id(), cell_source_tag(), cell_revision()}
| {:set_cell_attributes, pid(), Cell.id(), map()} | {:set_cell_attributes, pid(), Cell.id(), map()}
| {:set_input_value, pid(), input_id(), value :: term()} | {:set_input_value, pid(), input_id(), value :: term()}
| {:set_runtime, pid(), Runtime.t() | nil} | {:set_runtime, pid(), Runtime.t() | nil}
@ -188,7 +191,7 @@ defmodule Livebook.Session.Data do
| {:forget_evaluation, Cell.t(), Section.t()} | {:forget_evaluation, Cell.t(), Section.t()}
| {:start_smart_cell, 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} | {: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 """ @doc """
Returns a fresh notebook session state. Returns a fresh notebook session state.
@ -541,13 +544,13 @@ defmodule Livebook.Session.Data do
end end
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} <- with {:ok, %Cell.Smart{} = cell, _section} <-
Notebook.fetch_cell_and_section(data.notebook, id), Notebook.fetch_cell_and_section(data.notebook, id),
:starting <- data.cell_infos[cell.id].status do :starting <- data.cell_infos[cell.id].status do
data data
|> with_actions() |> with_actions()
|> smart_cell_started(cell, client_pid, delta, js_view) |> smart_cell_started(cell, client_pid, delta, js_view, editor)
|> set_dirty() |> set_dirty()
|> wrap_ok() |> wrap_ok()
else else
@ -628,14 +631,14 @@ defmodule Livebook.Session.Data do
end end
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), with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
info <- data.cell_infos[cell.id], source_info <- data.cell_infos[cell_id].sources[tag],
true <- 0 < revision and revision <= info.source.revision + 1, true <- 0 < revision and revision <= source_info.revision + 1,
true <- Map.has_key?(data.clients_map, client_pid) do true <- Map.has_key?(data.clients_map, client_pid) do
data data
|> with_actions() |> with_actions()
|> apply_delta(client_pid, cell, delta, revision) |> apply_delta(client_pid, cell, tag, delta, revision)
|> set_dirty() |> set_dirty()
|> wrap_ok() |> wrap_ok()
else else
@ -643,14 +646,14 @@ defmodule Livebook.Session.Data do
end end
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), with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
info <- data.cell_infos[cell.id], source_info <- data.cell_infos[cell_id].sources[tag],
true <- 0 < revision and revision <= info.source.revision, true <- 0 < revision and revision <= source_info.revision,
true <- Map.has_key?(data.clients_map, client_pid) do true <- Map.has_key?(data.clients_map, client_pid) do
data data
|> with_actions() |> with_actions()
|> report_revision(client_pid, cell, revision) |> report_revision(client_pid, cell, tag, revision)
|> wrap_ok() |> wrap_ok()
else else
_ -> :error _ -> :error
@ -1221,13 +1224,17 @@ defmodule Livebook.Session.Data do
end end
end end
defp smart_cell_started({data, _} = data_actions, cell, client_pid, delta, js_view) do defp smart_cell_started({data, _} = data_actions, cell, client_pid, delta, js_view, editor) do
updated_cell = %{cell | js_view: js_view} |> apply_delta_to_cell(delta) updated_cell = %{cell | js_view: js_view, editor: editor} |> apply_delta_to_cell(delta)
data_actions data_actions
|> set!(notebook: Notebook.update_cell(data.notebook, cell.id, fn _ -> updated_cell end)) |> set!(notebook: Notebook.update_cell(data.notebook, cell.id, fn _ -> updated_cell end))
|> update_cell_info!(cell.id, &%{&1 | status: :started}) |> 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 end
defp update_smart_cell({data, _} = data_actions, cell, client_pid, attrs, delta) do defp update_smart_cell({data, _} = data_actions, cell, client_pid, attrs, delta) do
@ -1241,7 +1248,7 @@ defmodule Livebook.Session.Data do
data_actions data_actions
|> set!(notebook: Notebook.update_cell(data.notebook, cell.id, fn _ -> updated_cell end)) |> 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 end
defp erase_outputs({data, _} = data_actions) do 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) users_map: Map.put(data.users_map, user.id, user)
) )
|> update_every_cell_info(fn |> update_every_cell_info(fn
%{source: _} = info -> %{sources: _} = info ->
put_in(info.source.revision_by_client_pid[client_pid], info.source.revision) 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 ->
info info
@ -1301,9 +1313,14 @@ defmodule Livebook.Session.Data do
data_actions data_actions
|> set!(clients_map: clients_map, users_map: users_map) |> set!(clients_map: clients_map, users_map: users_map)
|> update_every_cell_info(fn |> update_every_cell_info(fn
%{source: _} = info -> %{sources: _} = info ->
{_, info} = pop_in(info.source.revision_by_client_pid[client_pid]) update_in(
update_in(info.source, &purge_deltas/1) 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 ->
info info
@ -1314,36 +1331,43 @@ defmodule Livebook.Session.Data do
set!(data_actions, users_map: Map.put(data.users_map, user.id, user)) set!(data_actions, users_map: Map.put(data.users_map, user.id, user))
end end
defp apply_delta({data, _} = data_actions, client_pid, cell, delta, revision) do defp apply_delta({data, _} = data_actions, client_pid, cell, tag, delta, revision) do
info = data.cell_infos[cell.id] 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 = transformed_new_delta =
Enum.reduce(deltas_ahead, delta, fn delta_ahead, transformed_new_delta -> Enum.reduce(deltas_ahead, delta, fn delta_ahead, transformed_new_delta ->
Delta.transform(delta_ahead, transformed_new_delta, :left) Delta.transform(delta_ahead, transformed_new_delta, :left)
end) end)
updated_cell = apply_delta_to_cell(cell, transformed_new_delta) source_info =
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 source_info
| deltas: source_info.deltas ++ [transformed_new_delta], |> Map.update!(:deltas, &(&1 ++ [transformed_new_delta]))
revision: source_info.revision + 1 |> Map.update!(:revision, &(&1 + 1))
}
# Before receiving acknowledgement, the client receives all # Before receiving acknowledgement, the client receives all
# the other deltas, so we can assume they are in sync with # the other deltas, so we can assume they are in sync with
# the server and have the same revision. # the server and have the same revision.
source_info =
put_in(source_info.revision_by_client_pid[client_pid], source_info.revision) put_in(source_info.revision_by_client_pid[client_pid], source_info.revision)
|> purge_deltas() |> 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) end)
|> add_action({:broadcast_delta, client_pid, updated_cell, transformed_new_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({:broadcast_delta, client_pid, updated_cell, tag, transformed_new_delta})
end 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 # Note: the clients drop cell's source once it's no longer needed
defp apply_delta_to_cell(%{source: :__pruned__} = cell, _delta), do: cell defp apply_delta_to_cell(%{source: :__pruned__} = cell, _delta), do: cell
@ -1351,12 +1375,14 @@ defmodule Livebook.Session.Data do
update_in(cell.source, &JSInterop.apply_delta_to_string(delta, &1)) update_in(cell.source, &JSInterop.apply_delta_to_string(delta, &1))
end end
defp report_revision(data_actions, client_pid, cell, revision) do defp report_revision(data_actions, client_pid, cell, tag, revision) do
data_actions data_actions
|> update_cell_source_info!(cell.id, fn source_info -> |> 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) put_in(source_info.revision_by_client_pid[client_pid], revision)
|> purge_deltas() |> purge_deltas()
end) end)
end)
end end
defp set_cell_attributes({data, _} = data_actions, cell, attrs) do defp set_cell_attributes({data, _} = data_actions, cell, attrs) do
@ -1417,7 +1443,7 @@ defmodule Livebook.Session.Data do
Notebook.update_reduce_cells(data.notebook, data_actions, fn Notebook.update_reduce_cells(data.notebook, data_actions, fn
%Cell.Smart{} = cell, data_actions -> %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}) 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 defp new_cell_info(%Cell.Markdown{}, clients_map) do
%{ %{
source: new_source_info(clients_map) sources: %{primary: new_source_info(clients_map)}
} }
end end
defp new_cell_info(%Cell.Code{}, clients_map) do 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() eval: new_eval_info()
} }
end end
defp new_cell_info(%Cell.Smart{}, clients_map) do 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(), eval: new_eval_info(),
status: :dead status: :dead
} }
@ -1616,10 +1642,6 @@ defmodule Livebook.Session.Data do
update_cell_info!(data_actions, cell_id, &update_in(&1.eval, fun)) update_cell_info!(data_actions, cell_id, &update_in(&1.eval, fun))
end 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 defp update_every_cell_info({data, _} = data_actions, fun) do
cell_infos = cell_infos =
Map.new(data.cell_infos, fn {cell_id, info} -> Map.new(data.cell_infos, fn {cell_id, info} ->

View file

@ -173,6 +173,36 @@ defmodule LivebookWeb.Helpers do
) )
end 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 %>
<div class="h-4"></div>
<% else %>
<div class="max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4">
<div class={"#{@bg_class} h-4 rounded-lg w-3/4"}></div>
<div class={"#{@bg_class} h-4 rounded-lg"}></div>
<div class={"#{@bg_class} h-4 rounded-lg w-5/6"}></div>
</div>
</div>
<% end %>
"""
end
@doc """ @doc """
Determines user platform based on the given *User-Agent* header. Determines user platform based on the given *User-Agent* header.
""" """

View file

@ -648,21 +648,23 @@ defmodule LivebookWeb.SessionLive do
def handle_event( def handle_event(
"apply_cell_delta", "apply_cell_delta",
%{"cell_id" => cell_id, "delta" => delta, "revision" => revision}, %{"cell_id" => cell_id, "tag" => tag, "delta" => delta, "revision" => revision},
socket socket
) do ) do
tag = String.to_atom(tag)
delta = Delta.from_compressed(delta) 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} {:noreply, socket}
end end
def handle_event( def handle_event(
"report_cell_revision", "report_cell_revision",
%{"cell_id" => cell_id, "revision" => revision}, %{"cell_id" => cell_id, "tag" => tag, "revision" => revision},
socket socket
) do ) 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} {:noreply, socket}
end end
@ -1194,16 +1196,6 @@ defmodule LivebookWeb.SessionLive do
end end
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( defp after_operation(
socket, socket,
_prev_socket, _prev_socket,
@ -1222,17 +1214,25 @@ defmodule LivebookWeb.SessionLive do
|> push_event("evaluation_finished:#{cell_id}", %{code_error: metadata.code_error}) |> push_event("evaluation_finished:#{cell_id}", %{code_error: metadata.code_error})
end 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 after_operation(socket, _prev_socket, _operation), do: socket
defp handle_actions(socket, actions) do defp handle_actions(socket, actions) do
Enum.reduce(actions, socket, &handle_action(&2, &1)) Enum.reduce(actions, socket, &handle_action(&2, &1))
end 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 if client_pid == self() do
push_event(socket, "cell_acknowledgement:#{cell.id}", %{}) push_event(socket, "cell_acknowledgement:#{cell.id}:#{tag}", %{})
else 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
end end
@ -1416,7 +1416,7 @@ defmodule LivebookWeb.SessionLive do
%{ %{
id: cell.id, id: cell.id,
type: :markdown, type: :markdown,
source_view: cell_source_view(cell, info) source_view: source_view(cell.source, info.sources.primary)
} }
end end
@ -1426,7 +1426,7 @@ defmodule LivebookWeb.SessionLive do
%{ %{
id: cell.id, id: cell.id,
type: :code, 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), eval: eval_info_to_view(cell, info.eval, data),
reevaluate_automatically: cell.reevaluate_automatically reevaluate_automatically: cell.reevaluate_automatically
} }
@ -1438,10 +1438,17 @@ defmodule LivebookWeb.SessionLive do
%{ %{
id: cell.id, id: cell.id,
type: :smart, 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), eval: eval_info_to_view(cell, info.eval, data),
status: info.status, 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 end
@ -1453,21 +1460,21 @@ defmodule LivebookWeb.SessionLive do
evaluation_time_ms: eval_info.evaluation_time_ms, evaluation_time_ms: eval_info.evaluation_time_ms,
evaluation_start: eval_info.evaluation_start, evaluation_start: eval_info.evaluation_start,
evaluation_number: eval_info.evaluation_number, evaluation_number: eval_info.evaluation_number,
evaluation_digest: encode_digest(eval_info.evaluation_digest),
outputs_batch_number: eval_info.outputs_batch_number, outputs_batch_number: eval_info.outputs_batch_number,
# Pass input values relevant to the given cell # Pass input values relevant to the given cell
input_values: input_values_for_cell(cell, data) input_values: input_values_for_cell(cell, data)
} }
end end
defp cell_source_view(%{source: :__pruned__}, _info) do defp source_view(:__pruned__, _source_info) do
:__pruned__ :__pruned__
end end
defp cell_source_view(cell, info) do defp source_view(source, source_info) do
%{ %{
source: cell.source, source: source,
revision: info.source.revision, revision: source_info.revision
evaluation_digest: encode_digest(get_in(info, [:eval, :evaluation_digest]))
} }
end end
@ -1485,10 +1492,10 @@ defmodule LivebookWeb.SessionLive do
# most common ones we only update the relevant parts. # most common ones we only update the relevant parts.
defp update_data_view(data_view, prev_data, data, operation) do defp update_data_view(data_view, prev_data, data, operation) do
case operation do case operation do
{:report_cell_revision, _pid, _cell_id, _revision} -> {:report_cell_revision, _pid, _cell_id, _tag, _revision} ->
data_view 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_dirty_status(data_view, data)
{:update_smart_cell, _pid, _cell_id, _cell_state, _delta} -> {:update_smart_cell, _pid, _cell_id, _cell_state, _delta} ->
@ -1540,14 +1547,25 @@ defmodule LivebookWeb.SessionLive do
update_in( update_in(
data.notebook, data.notebook,
&Notebook.update_cells(&1, fn &Notebook.update_cells(&1, fn
%Notebook.Cell.Smart{} = cell -> %{cell | source: :__pruned__, attrs: :__pruned__} %Notebook.Cell.Smart{} = cell ->
%{source: _} = cell -> %{cell | source: :__pruned__} %{cell | source: :__pruned__, attrs: :__pruned__}
cell -> cell |> prune_smart_cell_editor_source()
%{source: _} = cell ->
%{cell | source: :__pruned__}
cell ->
cell
end) end)
) )
) )
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 # Changes that affect only a single cell are still likely to
# have impact on dirtiness, so we need to always mirror it # have impact on dirtiness, so we need to always mirror it
defp update_dirty_status(data_view, data) do defp update_dirty_status(data_view, data) do

View file

@ -1,29 +1,6 @@
defmodule LivebookWeb.SessionLive.CellComponent do defmodule LivebookWeb.SessionLive.CellComponent do
use LivebookWeb, :live_component 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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -34,7 +11,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
data-cell-id={@cell_view.id} data-cell-id={@cell_view.id}
data-focusable-id={@cell_view.id} data-focusable-id={@cell_view.id}
data-type={@cell_view.type} 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) %> <%= render_cell(assigns) %>
</div> </div>
""" """
@ -54,13 +32,18 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</.cell_actions> </.cell_actions>
<.cell_body> <.cell_body>
<div class="pb-4" data-element="editor-box"> <div class="pb-4" data-element="editor-box">
<.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" />
</div> </div>
<div class="markdown" <div class="markdown"
data-element="markdown-container" data-element="markdown-container"
id={"markdown-container-#{@cell_view.id}"} id={"markdown-container-#{@cell_view.id}"}
phx-update="ignore"> phx-update="ignore">
<.content_placeholder bg_class="bg-gray-200" empty={empty?(@cell_view.source_view)} /> <.content_skeleton empty={empty?(@cell_view.source_view)} />
</div> </div>
</.cell_body> </.cell_body>
""" """
@ -89,7 +72,13 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</.cell_actions> </.cell_actions>
<.cell_body> <.cell_body>
<div class="relative"> <div class="relative">
<.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 />
<div class="absolute bottom-2 right-2"> <div class="absolute bottom-2 right-2">
<.cell_status cell_view={@cell_view} /> <.cell_status cell_view={@cell_view} />
</div> </div>
@ -129,10 +118,20 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<div data-element="ui-box"> <div data-element="ui-box">
<%= case @cell_view.status do %> <%= case @cell_view.status do %>
<% :started -> %> <% :started -> %>
<div class={"flex #{if(@cell_view.editor && @cell_view.editor.placement == :top, do: "flex-col-reverse", else: "flex-col")}"}>
<.live_component module={LivebookWeb.JSViewComponent} <.live_component module={LivebookWeb.JSViewComponent}
id={@cell_view.id} id={@cell_view.id}
js_view={@cell_view.js_view} js_view={@cell_view.js_view}
session_id={@session_id} /> 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 %>
</div>
<% :dead -> %> <% :dead -> %>
<div class="p-4 bg-gray-100 text-sm text-gray-500 font-medium rounded-lg"> <div class="p-4 bg-gray-100 text-sm text-gray-500 font-medium rounded-lg">
@ -141,12 +140,19 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<% :starting -> %> <% :starting -> %>
<div class="delay-200"> <div class="delay-200">
<.content_placeholder bg_class="bg-gray-200" empty={false} /> <.content_skeleton empty={false} />
</div> </div>
<% end %> <% end %>
</div> </div>
<div data-element="editor-box"> <div data-element="editor-box">
<.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 />
</div> </div>
<div data-element="cell-status-container"> <div data-element="cell-status-container">
<.cell_status cell_view={@cell_view} /> <.cell_status cell_view={@cell_view} />
@ -373,18 +379,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end end
defp editor(assigns) do
~H"""
<div id={"editor-#{@cell_view.id}"} phx-update="ignore">
<div class="py-3 rounded-lg bg-editor" data-element="editor-container">
<div class="px-8">
<.content_placeholder bg_class="bg-gray-500" empty={empty?(@cell_view.source_view)} />
</div>
</div>
</div>
"""
end
defp evaluation_outputs(assigns) do defp evaluation_outputs(assigns) do
~H""" ~H"""
<div class="flex flex-col" <div class="flex flex-col"
@ -403,26 +397,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end end
# The whole page has to load and then hooks are mounted.
# There may be a tiny delay before the markdown is rendered
# or editors are mounted, so show neat placeholders immediately.
defp content_placeholder(assigns) do
~H"""
<%= if @empty do %>
<div class="h-4"></div>
<% else %>
<div class="max-w-2xl w-full animate-pulse">
<div class="flex-1 space-y-4">
<div class={"#{@bg_class} h-4 rounded-lg w-3/4"}></div>
<div class={"#{@bg_class} h-4 rounded-lg"}></div>
<div class={"#{@bg_class} h-4 rounded-lg w-5/6"}></div>
</div>
</div>
<% end %>
"""
end
defp empty?(%{source: ""} = _source_view), do: true defp empty?(%{source: ""} = _source_view), do: true
defp empty?(_source_view), do: false defp empty?(_source_view), do: false

View file

@ -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"""
<div id={"cell-editor-#{@id}"}
phx-update="ignore"
phx-hook="CellEditor"
data-cell-id={@cell_id}
data-tag={@tag}>
<div class="py-3 rounded-lg bg-editor" data-element="editor-container">
<div class="px-8">
<.content_skeleton bg_class="bg-gray-500" empty={empty?(@source_view)} />
</div>
</div>
</div>
"""
end
defp empty?(%{source: ""} = _source_view), do: true
defp empty?(_source_view), do: false
end

View file

@ -216,8 +216,9 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServerTest do
{:ok, pid, {:ok, pid,
%{ %{
js_view: %{ref: info.ref, pid: pid, assets: %{}},
source: "source", 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_binding: fn pid, _binding, _env -> send(pid, :scan_binding_ping) end,
scan_eval_result: fn pid, _result -> send(pid, :scan_eval_result_ping) end scan_eval_result: fn pid, _result -> send(pid, :scan_eval_result_ping) end
}} }}

View file

@ -421,7 +421,9 @@ defmodule Livebook.Session.DataTest do
assert {:ok, 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) }, []} = Data.apply_operation(data, operation)
end end
@ -461,7 +463,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}}, {:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}},
{:set_runtime, self(), NoopRuntime.new()}, {:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, {: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", %{}} operation = {:insert_cell, self(), "s1", 0, :code, "c3", %{}}
@ -813,7 +815,7 @@ defmodule Livebook.Session.DataTest do
{:set_runtime, self(), NoopRuntime.new()}, {:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "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"} operation = {:delete_cell, self(), "c1"}
@ -830,7 +832,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}}, {:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}},
{:set_runtime, self(), NoopRuntime.new()}, {:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, {: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"]}, {:queue_cells_evaluation, self(), ["c1"]},
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta} {: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"}}, {:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}},
{:set_runtime, self(), NoopRuntime.new()}, {:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, {: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"]} {:queue_cells_evaluation, self(), ["c1"]}
]) ])
@ -2683,7 +2685,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}} {: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) assert :error = Data.apply_operation(data, operation)
end end
@ -2699,7 +2701,7 @@ defmodule Livebook.Session.DataTest do
delta = Delta.new() |> Delta.insert("content") 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} = assert {:ok, %{cell_infos: %{"c1" => %{status: :started}}}, _actions} =
Data.apply_operation(data, operation) Data.apply_operation(data, operation)
@ -2718,13 +2720,13 @@ defmodule Livebook.Session.DataTest do
delta = Delta.new() |> Delta.insert("content") 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, assert {:ok,
%{ %{
notebook: %{sections: [%{cells: [%{id: "c1", source: "content"}]}]} 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) Data.apply_operation(data, operation)
end end
end end
@ -2741,7 +2743,7 @@ defmodule Livebook.Session.DataTest do
{:set_runtime, self(), NoopRuntime.new()}, {:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}, {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "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!"} attrs = %{"text" => "content!"}
@ -2754,7 +2756,7 @@ defmodule Livebook.Session.DataTest do
sections: [%{cells: [%{id: "c1", source: "content!", attrs: ^attrs}]}] 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) Data.apply_operation(data, operation)
end end
end end
@ -2888,7 +2890,7 @@ defmodule Livebook.Session.DataTest do
{:client_join, client1_pid, user}, {:client_join, client1_pid, user},
{:insert_section, self(), 0, "s1"}, {:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}, {: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) client2_pid = IEx.Helpers.pid(0, 0, 1)
@ -2896,7 +2898,9 @@ defmodule Livebook.Session.DataTest do
assert {:ok, 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) }, _} = Data.apply_operation(data, operation)
end end
end end
@ -2961,7 +2965,7 @@ defmodule Livebook.Session.DataTest do
{:client_join, client2_pid, User.new()}, {:client_join, client2_pid, User.new()},
{:insert_section, self(), 0, "s1"}, {:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}, {: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} operation = {:client_leave, client2_pid}
@ -2969,7 +2973,11 @@ defmodule Livebook.Session.DataTest do
assert {:ok, assert {:ok,
%{ %{
cell_infos: %{ 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) }, _} = Data.apply_operation(data, operation)
@ -3008,7 +3016,7 @@ defmodule Livebook.Session.DataTest do
{:client_join, self(), User.new()} {: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) assert :error = Data.apply_operation(data, operation)
end end
@ -3020,7 +3028,7 @@ defmodule Livebook.Session.DataTest do
]) ])
delta = Delta.new() |> Delta.insert("cats") 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) assert :error = Data.apply_operation(data, operation)
end end
@ -3033,7 +3041,7 @@ defmodule Livebook.Session.DataTest do
]) ])
delta = Delta.new() |> Delta.insert("cats") 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) assert :error = Data.apply_operation(data, operation)
end end
@ -3047,7 +3055,7 @@ defmodule Livebook.Session.DataTest do
]) ])
delta = Delta.new() |> Delta.insert("cats") 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, assert {:ok,
%{ %{
@ -3056,7 +3064,7 @@ defmodule Livebook.Session.DataTest do
%{cells: [%{source: "cats"}]} %{cells: [%{source: "cats"}]}
] ]
}, },
cell_infos: %{"c1" => %{source: %{revision: 1}}} cell_infos: %{"c1" => %{sources: %{primary: %{revision: 1}}}}
}, _actions} = Data.apply_operation(data, operation) }, _actions} = Data.apply_operation(data, operation)
end end
@ -3072,11 +3080,11 @@ defmodule Livebook.Session.DataTest do
{:client_join, client2_pid, User.new()}, {:client_join, client2_pid, User.new()},
{:insert_section, self(), 0, "s1"}, {:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}, {: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") 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, assert {:ok,
%{ %{
@ -3085,7 +3093,7 @@ defmodule Livebook.Session.DataTest do
%{cells: [%{source: "catstea"}]} %{cells: [%{source: "catstea"}]}
] ]
}, },
cell_infos: %{"c1" => %{source: %{revision: 2}}} cell_infos: %{"c1" => %{sources: %{primary: %{revision: 2}}}}
}, _} = Data.apply_operation(data, operation) }, _} = Data.apply_operation(data, operation)
end end
@ -3101,15 +3109,16 @@ defmodule Livebook.Session.DataTest do
{:client_join, client2_pid, User.new()}, {:client_join, client2_pid, User.new()},
{:insert_section, self(), 0, "s1"}, {:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}, {: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") 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") 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) Data.apply_operation(data, operation)
end end
@ -3124,11 +3133,11 @@ defmodule Livebook.Session.DataTest do
]) ])
delta = Delta.new() |> Delta.insert("cats") 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, assert {:ok,
%{ %{
cell_infos: %{"c1" => %{source: %{deltas: []}}} cell_infos: %{"c1" => %{sources: %{primary: %{deltas: []}}}}
}, _} = Data.apply_operation(data, operation) }, _} = Data.apply_operation(data, operation)
end end
@ -3145,13 +3154,39 @@ defmodule Livebook.Session.DataTest do
]) ])
delta = Delta.new() |> Delta.insert("cats") 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, assert {:ok,
%{ %{
cell_infos: %{"c1" => %{source: %{deltas: [^delta]}}} cell_infos: %{"c1" => %{sources: %{primary: %{deltas: [^delta]}}}}
}, _} = Data.apply_operation(data, operation) }, _} = Data.apply_operation(data, operation)
end 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 end
describe "apply_operation/2 given :report_cell_revision" do describe "apply_operation/2 given :report_cell_revision" do
@ -3161,7 +3196,7 @@ defmodule Livebook.Session.DataTest do
{:client_join, self(), User.new()} {: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) assert :error = Data.apply_operation(data, operation)
end end
@ -3174,10 +3209,10 @@ defmodule Livebook.Session.DataTest do
{:client_join, client1_pid, User.new()}, {:client_join, client1_pid, User.new()},
{:insert_section, self(), 0, "s1"}, {:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}, {: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) assert :error = Data.apply_operation(data, operation)
end end
@ -3189,7 +3224,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 0, :code, "c1", %{}} {: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) assert :error = Data.apply_operation(data, operation)
end end
@ -3205,21 +3240,23 @@ defmodule Livebook.Session.DataTest do
{:client_join, client2_pid, User.new()}, {:client_join, client2_pid, User.new()},
{:insert_section, self(), 0, "s1"}, {:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}, {: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, assert {:ok,
%{ %{
cell_infos: %{ cell_infos: %{
"c1" => %{ "c1" => %{
source: %{ sources: %{
primary: %{
deltas: [], deltas: [],
revision_by_client_pid: %{^client1_pid => 1, ^client2_pid => 1} revision_by_client_pid: %{^client1_pid => 1, ^client2_pid => 1}
} }
} }
} }
}
}, _} = Data.apply_operation(data, operation) }, _} = Data.apply_operation(data, operation)
end end
end end
@ -3507,7 +3544,7 @@ defmodule Livebook.Session.DataTest do
{:add_cell_evaluation_response, self(), "c3", @eval_resp, @eval_meta}, {:add_cell_evaluation_response, self(), "c3", @eval_resp, @eval_meta},
# Modify cell 2 # Modify cell 2
{:client_join, self(), User.new()}, {: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"] assert Data.cell_ids_for_full_evaluation(data, []) |> Enum.sort() == ["c2", "c3"]

View file

@ -190,8 +190,10 @@ defmodule Livebook.SessionTest do
delta = Delta.new() |> Delta.insert("cats") delta = Delta.new() |> Delta.insert("cats")
revision = 1 revision = 1
Session.apply_cell_delta(session.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, ^delta, ^revision}}
assert_receive {:operation,
{:apply_cell_delta, ^pid, ^cell_id, :primary, ^delta, ^revision}}
end end
end end
@ -203,8 +205,8 @@ defmodule Livebook.SessionTest do
{_section_id, cell_id} = insert_section_and_cell(session.pid) {_section_id, cell_id} = insert_section_and_cell(session.pid)
revision = 1 revision = 1
Session.report_cell_revision(session.pid, cell_id, revision) Session.report_cell_revision(session.pid, cell_id, :primary, revision)
assert_receive {:operation, {:report_cell_revision, ^pid, ^cell_id, ^revision}} assert_receive {:operation, {:report_cell_revision, ^pid, ^cell_id, :primary, ^revision}}
end end
end end
@ -561,14 +563,7 @@ defmodule Livebook.SessionTest do
describe "smart cells" do describe "smart cells" do
test "notifies subcribers when a smart cell starts and passes source diff as delta" 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"} 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) session = start_session(notebook: notebook)
runtime = Livebook.Runtime.NoopRuntime.new() runtime = Livebook.Runtime.NoopRuntime.new()
@ -580,13 +575,44 @@ defmodule Livebook.SessionTest do
send( send(
session.pid, 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("!") delta = Delta.new() |> Delta.retain(7) |> Delta.insert("!")
cell_id = smart_cell.id 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
end end