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 */
.monaco-cursor-widget-container.inline {
display: flex;
display: flex !important;
}
.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 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,

View file

@ -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 },
});
}
}

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.
*/
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<CURSOR> 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",

View file

@ -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,
});
}

View file

@ -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";
/**

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 %>
<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 """
Determines user platform based on the given *User-Agent* header.
"""

View file

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

View file

@ -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) %>
</div>
"""
@ -54,13 +32,18 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</.cell_actions>
<.cell_body>
<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 class="markdown"
data-element="markdown-container"
id={"markdown-container-#{@cell_view.id}"}
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>
</.cell_body>
"""
@ -89,7 +72,13 @@ defmodule LivebookWeb.SessionLive.CellComponent do
</.cell_actions>
<.cell_body>
<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">
<.cell_status cell_view={@cell_view} />
</div>
@ -129,10 +118,20 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<div data-element="ui-box">
<%= 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} />
<div class={"flex #{if(@cell_view.editor && @cell_view.editor.placement == :top, do: "flex-col-reverse", else: "flex-col")}"}>
<.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 %>
</div>
<% :dead -> %>
<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 -> %>
<div class="delay-200">
<.content_placeholder bg_class="bg-gray-200" empty={false} />
<.content_skeleton empty={false} />
</div>
<% end %>
</div>
<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 data-element="cell-status-container">
<.cell_status cell_view={@cell_view} />
@ -373,18 +379,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
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
~H"""
<div class="flex flex-col"
@ -403,26 +397,6 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
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_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,
%{
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
}}

View file

@ -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"]

View file

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