mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
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:
parent
e6a9cf94ea
commit
6db36ea7e6
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
62
assets/js/cell_editor/index.js
Normal file
62
assets/js/cell_editor/index.js
Normal 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;
|
|
@ -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",
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} ->
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
56
lib/livebook_web/live/session_live/cell_editor_component.ex
Normal file
56
lib/livebook_web/live/session_live/cell_editor_component.ex
Normal 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
|
|
@ -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
|
||||
}}
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue