mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-12 07:54:49 +08:00
Improvements to the smart cells flow (#1150)
* Reword reinstall to restart * Clarify Smart cell message when dependencies are installing * Handle universal shortcuts in inside iframe inputs * Adjust smart cell icons * Improve wording in the dependency install prompt * Reevaluate cells after installing smart cell dependencies * Update lib/livebook_web/live/session_live/cell_component.ex Co-authored-by: José Valim <jose.valim@dashbit.co> Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
parent
8f72d0175e
commit
8e063792ff
17 changed files with 476 additions and 82 deletions
|
@ -113,14 +113,8 @@ solely client-side operations.
|
||||||
|
|
||||||
[data-el-cell][data-js-amplified]
|
[data-el-cell][data-js-amplified]
|
||||||
[data-el-amplify-outputs-button]
|
[data-el-amplify-outputs-button]
|
||||||
[data-el-zoom-in-icon] {
|
.icon-button {
|
||||||
@apply hidden;
|
@apply bg-gray-100 text-gray-900;
|
||||||
}
|
|
||||||
|
|
||||||
[data-el-cell]:not([data-js-amplified])
|
|
||||||
[data-el-amplify-outputs-button]
|
|
||||||
[data-el-zoom-out-icon] {
|
|
||||||
@apply hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-el-cell][data-type="smart"]:not([data-js-source-visible])
|
[data-el-cell][data-type="smart"]:not([data-js-source-visible])
|
||||||
|
@ -167,14 +161,10 @@ solely client-side operations.
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-el-cell][data-type="smart"]:not([data-js-source-visible])
|
|
||||||
[data-el-show-ui-icon] {
|
|
||||||
@apply hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-el-cell][data-type="smart"][data-js-source-visible]
|
[data-el-cell][data-type="smart"][data-js-source-visible]
|
||||||
[data-el-show-code-icon] {
|
[data-el-toggle-source-button]
|
||||||
@apply hidden;
|
.icon-button {
|
||||||
|
@apply bg-gray-100 text-gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-el-cell][data-type="smart"][data-js-source-visible]
|
[data-el-cell][data-type="smart"][data-js-source-visible]
|
||||||
|
|
|
@ -26,6 +26,7 @@ const ConfirmModal = {
|
||||||
confirm_text,
|
confirm_text,
|
||||||
confirm_icon,
|
confirm_icon,
|
||||||
danger,
|
danger,
|
||||||
|
html,
|
||||||
opt_out_id,
|
opt_out_id,
|
||||||
} = event.detail;
|
} = event.detail;
|
||||||
|
|
||||||
|
@ -33,7 +34,13 @@ const ConfirmModal = {
|
||||||
liveSocket.execJS(event.target, event.detail.on_confirm);
|
liveSocket.execJS(event.target, event.detail.on_confirm);
|
||||||
} else {
|
} else {
|
||||||
titleEl.textContent = title;
|
titleEl.textContent = title;
|
||||||
descriptionEl.textContent = description;
|
|
||||||
|
if (html) {
|
||||||
|
descriptionEl.innerHTML = description;
|
||||||
|
} else {
|
||||||
|
descriptionEl.textContent = description;
|
||||||
|
}
|
||||||
|
|
||||||
confirmTextEl.textContent = confirm_text;
|
confirmTextEl.textContent = confirm_text;
|
||||||
|
|
||||||
if (confirm_icon) {
|
if (confirm_icon) {
|
||||||
|
|
|
@ -76,6 +76,10 @@ const JSView = {
|
||||||
window.addEventListener("message", this._handleWindowMessage);
|
window.addEventListener("message", this._handleWindowMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.hiddenInput = document.createElement("input");
|
||||||
|
this.hiddenInput.style.display = "none";
|
||||||
|
this.el.appendChild(this.hiddenInput);
|
||||||
|
|
||||||
this.loadIframe();
|
this.loadIframe();
|
||||||
|
|
||||||
// Channel events
|
// Channel events
|
||||||
|
@ -283,7 +287,11 @@ const JSView = {
|
||||||
// Replicate the child events on the current element,
|
// Replicate the child events on the current element,
|
||||||
// so that they are detected upstream in the session hook
|
// so that they are detected upstream in the session hook
|
||||||
const event = this.replicateDomEvent(message.event);
|
const event = this.replicateDomEvent(message.event);
|
||||||
this.el.dispatchEvent(event);
|
if (message.isTargetEditable) {
|
||||||
|
this.hiddenInput.dispatchEvent(event);
|
||||||
|
} else {
|
||||||
|
this.el.dispatchEvent(event);
|
||||||
|
}
|
||||||
} else if (message.type === "event") {
|
} else if (message.type === "event") {
|
||||||
const { event, payload } = message;
|
const { event, payload } = message;
|
||||||
const raw = transportEncode([event, this.props.ref], payload);
|
const raw = transportEncode([event, this.props.ref], payload);
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { sha256Base64 } from "../../lib/utils";
|
||||||
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
|
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
|
||||||
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
|
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
|
||||||
|
|
||||||
const IFRAME_SHA256 = "+uJyGu0Ey7uVV7WwRwg7GyjwCkMNRBnyNc25iGFpYXc=";
|
const IFRAME_SHA256 = "4gyeA71Bpb4SGj2M0BUdT1jtk6wjUqOf6Q8wVYp7htc=";
|
||||||
|
|
||||||
export function initializeIframeSource(iframe, iframePort) {
|
export function initializeIframeSource(iframe, iframePort) {
|
||||||
const iframeUrl = getIframeUrl(iframePort);
|
const iframeUrl = getIframeUrl(iframePort);
|
||||||
|
@ -42,8 +42,8 @@ export function initializeIframeSource(iframe, iframePort) {
|
||||||
|
|
||||||
function getIframeUrl(iframePort) {
|
function getIframeUrl(iframePort) {
|
||||||
return window.location.protocol === "https:"
|
return window.location.protocol === "https:"
|
||||||
? "https://livebook.space/iframe/v2.html"
|
? "https://livebook.space/iframe/v3.html"
|
||||||
: `http://${window.location.hostname}:${iframePort}/iframe/v2.html`;
|
: `http://${window.location.hostname}:${iframePort}/iframe/v3.html`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let iframeVerificationPromise = null;
|
let iframeVerificationPromise = null;
|
||||||
|
|
|
@ -284,6 +284,23 @@ const Session = {
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
const keyBuffer = this.keyBuffer;
|
const keyBuffer = this.keyBuffer;
|
||||||
|
|
||||||
|
// Universal shortcuts
|
||||||
|
if (cmd && shift && !alt && key === "Enter") {
|
||||||
|
cancelEvent(event);
|
||||||
|
this.queueFullCellsEvaluation(true);
|
||||||
|
return;
|
||||||
|
} else if (cmd && !alt && key === "Enter") {
|
||||||
|
cancelEvent(event);
|
||||||
|
if (isEvaluable(this.focusedCellType())) {
|
||||||
|
this.queueFocusedCellEvaluation();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (cmd && key === "s") {
|
||||||
|
cancelEvent(event);
|
||||||
|
this.saveNotebook();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.insertMode) {
|
if (this.insertMode) {
|
||||||
keyBuffer.reset();
|
keyBuffer.reset();
|
||||||
|
|
||||||
|
@ -292,46 +309,23 @@ const Session = {
|
||||||
if (!this.escapesMonacoWidget(event)) {
|
if (!this.escapesMonacoWidget(event)) {
|
||||||
this.escapeInsertMode();
|
this.escapeInsertMode();
|
||||||
}
|
}
|
||||||
} else if (cmd && shift && !alt && key === "Enter") {
|
}
|
||||||
cancelEvent(event);
|
// Ignore keystrokes on input fields
|
||||||
this.queueFullCellsEvaluation(true);
|
} else if (isEditableElement(event.target)) {
|
||||||
} else if (cmd && !alt && key === "Enter") {
|
keyBuffer.reset();
|
||||||
cancelEvent(event);
|
|
||||||
if (isEvaluable(this.focusedCellType())) {
|
// Use Escape for universal blur
|
||||||
this.queueFocusedCellEvaluation();
|
if (key === "Escape") {
|
||||||
}
|
event.target.blur();
|
||||||
} else if (cmd && key === "s") {
|
|
||||||
cancelEvent(event);
|
|
||||||
this.saveNotebook();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Ignore keystrokes on input fields
|
|
||||||
if (isEditableElement(event.target)) {
|
|
||||||
keyBuffer.reset();
|
|
||||||
|
|
||||||
// Use Escape for universal blur
|
|
||||||
if (key === "Escape") {
|
|
||||||
event.target.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
keyBuffer.push(event.key);
|
keyBuffer.push(event.key);
|
||||||
|
|
||||||
if (cmd && key === "s") {
|
if (keyBuffer.tryMatch(["d", "d"])) {
|
||||||
cancelEvent(event);
|
|
||||||
this.saveNotebook();
|
|
||||||
} else if (keyBuffer.tryMatch(["d", "d"])) {
|
|
||||||
this.deleteFocusedCell();
|
this.deleteFocusedCell();
|
||||||
} else if (cmd && shift && !alt && key === "Enter") {
|
|
||||||
this.queueFullCellsEvaluation(true);
|
|
||||||
} else if (keyBuffer.tryMatch(["e", "a"])) {
|
} else if (keyBuffer.tryMatch(["e", "a"])) {
|
||||||
this.queueFullCellsEvaluation(false);
|
this.queueFullCellsEvaluation(false);
|
||||||
} else if (
|
} else if (keyBuffer.tryMatch(["e", "e"])) {
|
||||||
keyBuffer.tryMatch(["e", "e"]) ||
|
|
||||||
(cmd && !alt && key === "Enter")
|
|
||||||
) {
|
|
||||||
if (isEvaluable(this.focusedCellType())) {
|
if (isEvaluable(this.focusedCellType())) {
|
||||||
this.queueFocusedCellEvaluation();
|
this.queueFocusedCellEvaluation();
|
||||||
}
|
}
|
||||||
|
|
205
iframe/priv/static/iframe/v3.html
Normal file
205
iframe/priv/static/iframe/v3.html
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Output</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Invoke the init function in a separate context for better isolation
|
||||||
|
function applyInit(init, ctx, data) {
|
||||||
|
init(ctx, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const state = {
|
||||||
|
token: null,
|
||||||
|
importPromise: null,
|
||||||
|
eventHandlers: {},
|
||||||
|
eventQueue: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function postMessage(message) {
|
||||||
|
window.parent.postMessage({ token: state.token, ...message }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
root: document.getElementById("root"),
|
||||||
|
|
||||||
|
handleEvent(event, callback) {
|
||||||
|
if (state.eventHandlers[event]) {
|
||||||
|
throw new Error(
|
||||||
|
`Handler has already been defined for event "${event}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.eventHandlers[event] = callback;
|
||||||
|
|
||||||
|
while (
|
||||||
|
state.eventQueue.length > 0 &&
|
||||||
|
state.eventHandlers[state.eventQueue[0].event]
|
||||||
|
) {
|
||||||
|
const { event, payload } = state.eventQueue.shift();
|
||||||
|
const handler = state.eventHandlers[event];
|
||||||
|
handler(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pushEvent(event, payload = null) {
|
||||||
|
postMessage({ type: "event", event, payload });
|
||||||
|
},
|
||||||
|
|
||||||
|
importCSS(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const linkEl = document.createElement("link");
|
||||||
|
linkEl.addEventListener(
|
||||||
|
"load",
|
||||||
|
(event) => {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
linkEl.rel = "stylesheet";
|
||||||
|
linkEl.href = url;
|
||||||
|
document.head.appendChild(linkEl);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.source === window.parent) {
|
||||||
|
handleParentMessage(event.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleParentMessage(message) {
|
||||||
|
if (message.type === "readyReply") {
|
||||||
|
state.token = message.token;
|
||||||
|
onReady();
|
||||||
|
|
||||||
|
// Set the base URL for relative URLs
|
||||||
|
const baseUrlEl = document.createElement("base");
|
||||||
|
baseUrlEl.href = message.baseUrl;
|
||||||
|
document.head.appendChild(baseUrlEl);
|
||||||
|
// We already entered the script and the base URL change
|
||||||
|
// doesn't impact this import call, so we use the absolute
|
||||||
|
// URL instead
|
||||||
|
state.importPromise = import(`${message.baseUrl}${message.jsPath}`);
|
||||||
|
} else if (message.type === "init") {
|
||||||
|
state.importPromise
|
||||||
|
.then((module) => {
|
||||||
|
const init = module.init;
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
const fns = Object.keys(module);
|
||||||
|
throw new Error(
|
||||||
|
`Expected the module to export an init function, but found: ${fns.join(
|
||||||
|
", "
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyInit(init, ctx, message.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
renderErrorMessage(
|
||||||
|
`Failed to load the widget JS module, got the following error:\n\n ${error.message}\n\nSee the browser console for more details. If running behind an authentication proxy, make sure the /public/* routes are publicly accessible.`
|
||||||
|
);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
} else if (message.type === "event") {
|
||||||
|
const { event, payload } = message;
|
||||||
|
const handler = state.eventHandlers[event];
|
||||||
|
|
||||||
|
if (state.eventQueue.length === 0 && handler) {
|
||||||
|
handler(payload);
|
||||||
|
} else {
|
||||||
|
state.eventQueue.push({ event, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage({ type: "ready" });
|
||||||
|
|
||||||
|
function onReady() {
|
||||||
|
// Report height changes
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
postMessage({ type: "resize", height: document.body.scrollHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(document.body);
|
||||||
|
|
||||||
|
// Forward relevant DOM events
|
||||||
|
|
||||||
|
window.addEventListener("mousedown", (event) => {
|
||||||
|
postMessage({ type: "domEvent", event: { type: "mousedown" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("focus", (event) => {
|
||||||
|
postMessage({ type: "domEvent", event: { type: "focus" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
postMessage({
|
||||||
|
type: "domEvent",
|
||||||
|
event: keyboardEventToPayload(event),
|
||||||
|
isTargetEditable: isEditableElement(event.target),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableElement(element) {
|
||||||
|
return element.matches("input, textarea, [contenteditable]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardEventToPayload(event) {
|
||||||
|
const {
|
||||||
|
altKey,
|
||||||
|
code,
|
||||||
|
ctrlKey,
|
||||||
|
isComposing,
|
||||||
|
key,
|
||||||
|
location,
|
||||||
|
metaKey,
|
||||||
|
repeat,
|
||||||
|
shiftKey,
|
||||||
|
} = event;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: event.type,
|
||||||
|
props: {
|
||||||
|
altKey,
|
||||||
|
code,
|
||||||
|
ctrlKey,
|
||||||
|
isComposing,
|
||||||
|
key,
|
||||||
|
location,
|
||||||
|
metaKey,
|
||||||
|
repeat,
|
||||||
|
shiftKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderErrorMessage(message) {
|
||||||
|
ctx.root.innerHTML = `
|
||||||
|
<div style="color: #FF3E38; white-space: pre-wrap; word-break: break-word;">${message}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -199,11 +199,10 @@ defprotocol Livebook.Runtime do
|
||||||
}
|
}
|
||||||
|
|
||||||
@type smart_cell_requirement :: %{
|
@type smart_cell_requirement :: %{
|
||||||
name: String.t(),
|
|
||||||
variants:
|
variants:
|
||||||
list(%{
|
list(%{
|
||||||
name: String.t(),
|
name: String.t(),
|
||||||
dependencies: list(dependency())
|
packages: list(%{name: String.t(), dependency: dependency()})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,18 +17,23 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
||||||
server_pid: pid() | nil
|
server_pid: pid() | nil
|
||||||
}
|
}
|
||||||
|
|
||||||
kino_vega_lite_dep = {:kino_vega_lite, "~> 0.1.0"}
|
kino_vega_lite = %{name: "kino_vega_lite", dependency: {:kino_vega_lite, "~> 0.1.0"}}
|
||||||
kino_db_dep = {:kino_db, "~> 0.1.0"}
|
kino_db = %{name: "kino_db", dependency: {:kino_db, "~> 0.1.0"}}
|
||||||
|
|
||||||
@extra_smart_cell_definitions [
|
@extra_smart_cell_definitions [
|
||||||
%{
|
%{
|
||||||
kind: "Elixir.KinoDB.ConnectionCell",
|
kind: "Elixir.KinoDB.ConnectionCell",
|
||||||
name: "Database connection",
|
name: "Database connection",
|
||||||
requirement: %{
|
requirement: %{
|
||||||
name: "KinoDB",
|
|
||||||
variants: [
|
variants: [
|
||||||
%{name: "PostgreSQL", dependencies: [kino_db_dep, {:postgrex, "~> 0.16.3"}]},
|
%{
|
||||||
%{name: "MySQL", dependencies: [kino_db_dep, {:myxql, "~> 0.6.2"}]}
|
name: "PostgreSQL",
|
||||||
|
packages: [kino_db, %{name: "postgrex", dependency: {:postgrex, "~> 0.16.3"}}]
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "MySQL",
|
||||||
|
packages: [kino_db, %{name: "myxql", dependency: {:myxql, "~> 0.6.2"}}]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -36,9 +41,11 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
||||||
kind: "Elixir.KinoDB.SQLCell",
|
kind: "Elixir.KinoDB.SQLCell",
|
||||||
name: "SQL query",
|
name: "SQL query",
|
||||||
requirement: %{
|
requirement: %{
|
||||||
name: "KinoDB",
|
|
||||||
variants: [
|
variants: [
|
||||||
%{name: "Default", dependencies: [kino_db_dep]}
|
%{
|
||||||
|
name: "Default",
|
||||||
|
packages: [kino_db]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -46,9 +53,11 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
||||||
kind: "Elixir.KinoVegaLite.ChartCell",
|
kind: "Elixir.KinoVegaLite.ChartCell",
|
||||||
name: "Chart",
|
name: "Chart",
|
||||||
requirement: %{
|
requirement: %{
|
||||||
name: "KinoVegaLite",
|
|
||||||
variants: [
|
variants: [
|
||||||
%{name: "Default", dependencies: [kino_vega_lite_dep]}
|
%{
|
||||||
|
name: "Default",
|
||||||
|
packages: [kino_vega_lite]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -371,6 +371,17 @@ defmodule Livebook.Session do
|
||||||
GenServer.cast(pid, {:queue_full_evaluation, self(), forced_cell_ids})
|
GenServer.cast(pid, {:queue_full_evaluation, self(), forced_cell_ids})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends reevaluation request to the server.
|
||||||
|
|
||||||
|
Schedules evaluation of all cells that have been evaluated
|
||||||
|
previously, until the first fresh cell.
|
||||||
|
"""
|
||||||
|
@spec queue_cells_reevaluation(pid()) :: :ok
|
||||||
|
def queue_cells_reevaluation(pid) do
|
||||||
|
GenServer.cast(pid, {:queue_cells_reevaluation, self()})
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends cell evaluation cancellation request to the server.
|
Sends cell evaluation cancellation request to the server.
|
||||||
"""
|
"""
|
||||||
|
@ -794,6 +805,13 @@ defmodule Livebook.Session do
|
||||||
{:noreply, handle_operation(state, operation)}
|
{:noreply, handle_operation(state, operation)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_cast({:queue_cells_reevaluation, client_pid}, state) do
|
||||||
|
cell_ids = Data.cell_ids_for_reevaluation(state.data)
|
||||||
|
|
||||||
|
operation = {:queue_cells_evaluation, client_pid, cell_ids}
|
||||||
|
{:noreply, handle_operation(state, operation)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_cast({:cancel_cell_evaluation, client_pid, cell_id}, state) do
|
def handle_cast({:cancel_cell_evaluation, client_pid, cell_id}, state) do
|
||||||
operation = {:cancel_cell_evaluation, client_pid, cell_id}
|
operation = {:cancel_cell_evaluation, client_pid, cell_id}
|
||||||
{:noreply, handle_operation(state, operation)}
|
{:noreply, handle_operation(state, operation)}
|
||||||
|
|
|
@ -1898,4 +1898,35 @@ defmodule Livebook.Session.Data do
|
||||||
cell.id in cell_ids,
|
cell.id in cell_ids,
|
||||||
do: cell.id
|
do: cell.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of cell ids for reevaluation.
|
||||||
|
|
||||||
|
The list includes cells that have been evaluated, but the
|
||||||
|
reevaluation flow ends at the first fresh cell in each branch.
|
||||||
|
"""
|
||||||
|
@spec cell_ids_for_reevaluation(t()) :: list(Cell.id())
|
||||||
|
def cell_ids_for_reevaluation(data) do
|
||||||
|
data.notebook
|
||||||
|
|> Notebook.evaluable_cells_with_section()
|
||||||
|
|> Enum.reject(fn {cell, _section} -> Cell.setup?(cell) end)
|
||||||
|
|> Enum.reduce_while({[], nil}, fn
|
||||||
|
{_cell, %{id: skip_section_id} = _section}, {ids, skip_section_id} ->
|
||||||
|
{ids, skip_section_id}
|
||||||
|
|
||||||
|
{cell, section}, {ids, _skip_section_id} ->
|
||||||
|
info = data.cell_infos[cell.id]
|
||||||
|
|
||||||
|
if info.eval.validity == :fresh do
|
||||||
|
if section.parent_id do
|
||||||
|
{:cont, {ids, section.parent_id}}
|
||||||
|
else
|
||||||
|
{:halt, {ids, nil}}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:cont, {[cell.id | ids], nil}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> elem(0)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -70,6 +70,30 @@ defmodule LivebookWeb.Helpers do
|
||||||
iex> LivebookWeb.Helpers.pluralize(3, "notebook is not persisted", "notebooks are not persisted")
|
iex> LivebookWeb.Helpers.pluralize(3, "notebook is not persisted", "notebooks are not persisted")
|
||||||
"3 notebooks are not persisted"
|
"3 notebooks are not persisted"
|
||||||
"""
|
"""
|
||||||
|
@spec pluralize(non_neg_integer(), String.t(), String.t()) :: String.t()
|
||||||
def pluralize(1, singular, _plural), do: "1 #{singular}"
|
def pluralize(1, singular, _plural), do: "1 #{singular}"
|
||||||
def pluralize(count, _singular, plural), do: "#{count} #{plural}"
|
def pluralize(count, _singular, plural), do: "#{count} #{plural}"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the text in singular or plural depending on the quantity
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> LivebookWeb.Helpers.format_items(["tea"])
|
||||||
|
"tea"
|
||||||
|
|
||||||
|
iex> LivebookWeb.Helpers.format_items(["tea", "coffee"])
|
||||||
|
"tea and coffee"
|
||||||
|
|
||||||
|
iex> LivebookWeb.Helpers.format_items(["wine", "tea", "coffee"])
|
||||||
|
"wine, tea and coffee"
|
||||||
|
"""
|
||||||
|
@spec format_items(list(String.t())) :: String.t()
|
||||||
|
def format_items([]), do: ""
|
||||||
|
def format_items([item]), do: item
|
||||||
|
|
||||||
|
def format_items(list) do
|
||||||
|
{leading, [last]} = Enum.split(list, -1)
|
||||||
|
Enum.join(leading, ", ") <> " and " <> last
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -131,6 +131,8 @@ defmodule LivebookWeb.LiveHelpers do
|
||||||
|
|
||||||
* `:danger` - whether the action is destructive or regular. Defaults to `true`
|
* `:danger` - whether the action is destructive or regular. Defaults to `true`
|
||||||
|
|
||||||
|
* `:html` - whether the `:description` is a raw HTML. Defaults to `false`
|
||||||
|
|
||||||
* `:opt_out_id` - enables the "Don't show this message again"
|
* `:opt_out_id` - enables the "Don't show this message again"
|
||||||
checkbox. Once checked by the user, the confirmation with this
|
checkbox. Once checked by the user, the confirmation with this
|
||||||
id is never shown again. Optional
|
id is never shown again. Optional
|
||||||
|
@ -161,7 +163,8 @@ defmodule LivebookWeb.LiveHelpers do
|
||||||
:opt_out_id,
|
:opt_out_id,
|
||||||
title: "Are you sure?",
|
title: "Are you sure?",
|
||||||
confirm_text: "Yes",
|
confirm_text: "Yes",
|
||||||
danger: true
|
danger: true,
|
||||||
|
html: false
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -173,6 +176,7 @@ defmodule LivebookWeb.LiveHelpers do
|
||||||
confirm_text: opts[:confirm_text],
|
confirm_text: opts[:confirm_text],
|
||||||
confirm_icon: opts[:confirm_icon],
|
confirm_icon: opts[:confirm_icon],
|
||||||
danger: opts[:danger],
|
danger: opts[:danger],
|
||||||
|
html: opts[:html],
|
||||||
opt_out_id: opts[:opt_out_id]
|
opt_out_id: opts[:opt_out_id]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -196,6 +196,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
id={@data_view.setup_cell_view.id}
|
id={@data_view.setup_cell_view.id}
|
||||||
session_id={@session.id}
|
session_id={@session.id}
|
||||||
runtime={@data_view.runtime}
|
runtime={@data_view.runtime}
|
||||||
|
installing?={@data_view.installing?}
|
||||||
cell_view={@data_view.setup_cell_view} />
|
cell_view={@data_view.setup_cell_view} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 flex flex-col w-full space-y-16" data-el-sections-container>
|
<div class="mt-8 flex flex-col w-full space-y-16" data-el-sections-container>
|
||||||
|
@ -214,6 +215,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
session_id={@session.id}
|
session_id={@session.id}
|
||||||
runtime={@data_view.runtime}
|
runtime={@data_view.runtime}
|
||||||
smart_cell_definitions={@data_view.smart_cell_definitions}
|
smart_cell_definitions={@data_view.smart_cell_definitions}
|
||||||
|
installing?={@data_view.installing?}
|
||||||
section_view={section_view} />
|
section_view={section_view} />
|
||||||
<% end %>
|
<% end %>
|
||||||
<div style="height: 80vh"></div>
|
<div style="height: 80vh"></div>
|
||||||
|
@ -755,7 +757,8 @@ defmodule LivebookWeb.SessionLive do
|
||||||
|
|
||||||
with %{requirement: %{variants: variants}} <-
|
with %{requirement: %{variants: variants}} <-
|
||||||
Enum.find(socket.private.data.smart_cell_definitions, &(&1.kind == kind)),
|
Enum.find(socket.private.data.smart_cell_definitions, &(&1.kind == kind)),
|
||||||
{:ok, %{dependencies: dependencies}} <- Enum.fetch(variants, variant_idx) do
|
{:ok, variant} <- Enum.fetch(variants, variant_idx) do
|
||||||
|
dependencies = Enum.map(variant.packages, & &1.dependency)
|
||||||
Session.add_dependencies(socket.assigns.session.pid, dependencies)
|
Session.add_dependencies(socket.assigns.session.pid, dependencies)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -809,6 +812,13 @@ defmodule LivebookWeb.SessionLive do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("queue_cells_reevaluation", %{}, socket) do
|
||||||
|
assert_policy!(socket, :execute)
|
||||||
|
Session.queue_cells_reevaluation(socket.assigns.session.pid)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("save", %{}, socket) do
|
def handle_event("save", %{}, socket) do
|
||||||
assert_policy!(socket, :edit)
|
assert_policy!(socket, :edit)
|
||||||
|
|
||||||
|
@ -1430,6 +1440,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
data.clients_map
|
data.clients_map
|
||||||
|> Enum.map(fn {client_pid, user_id} -> {client_pid, data.users_map[user_id]} end)
|
|> Enum.map(fn {client_pid, user_id} -> {client_pid, data.users_map[user_id]} end)
|
||||||
|> Enum.sort_by(fn {_client_pid, user} -> user.name end),
|
|> Enum.sort_by(fn {_client_pid, user} -> user.name end),
|
||||||
|
installing?: data.cell_infos[Cell.setup_cell_id()].eval.status == :evaluating,
|
||||||
setup_cell_view: %{cell_to_view(hd(data.notebook.setup_section.cells), data) | type: :setup},
|
setup_cell_view: %{cell_to_view(hd(data.notebook.setup_section.cells), data) | type: :setup},
|
||||||
section_views: section_views(data.notebook.sections, data),
|
section_views: section_views(data.notebook.sections, data),
|
||||||
bin_entries: data.bin_entries
|
bin_entries: data.bin_entries
|
||||||
|
|
|
@ -181,7 +181,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
||||||
|
|
||||||
<% :dead -> %>
|
<% :dead -> %>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
Evaluate and install dependencies to show the contents of this Smart cell.
|
<%= if @installing? do %>
|
||||||
|
Waiting for dependency installation to complete...
|
||||||
|
<% else %>
|
||||||
|
Run the notebook setup to show the contents of this Smart cell.
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% :starting -> %>
|
<% :starting -> %>
|
||||||
|
@ -347,8 +351,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
||||||
~H"""
|
~H"""
|
||||||
<span class="tooltip top" data-tooltip="Toggle source" data-el-toggle-source-button>
|
<span class="tooltip top" data-tooltip="Toggle source" data-el-toggle-source-button>
|
||||||
<button class="icon-button" aria-label="toggle source">
|
<button class="icon-button" aria-label="toggle source">
|
||||||
<.remix_icon icon="code-line" class="text-xl" data-el-show-code-icon />
|
<.remix_icon icon="code-line" class="text-xl" />
|
||||||
<.remix_icon icon="pencil-line" class="text-xl" data-el-show-ui-icon />
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
"""
|
"""
|
||||||
|
@ -370,7 +373,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
||||||
opt_out_id: "convert-smart-cell"
|
opt_out_id: "convert-smart-cell"
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
<.remix_icon icon="arrow-up-down-line" class="text-xl" />
|
<.remix_icon icon="pencil-line" class="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
"""
|
"""
|
||||||
|
@ -413,10 +416,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
||||||
def amplify_output_button(assigns) do
|
def amplify_output_button(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<span class="tooltip top" data-tooltip="Amplify output" data-el-amplify-outputs-button>
|
<span class="tooltip top" data-tooltip="Amplify output" data-el-amplify-outputs-button>
|
||||||
<button class="icon-button"
|
<button class="icon-button" aria-label="amplify outputs">
|
||||||
aria-label="amplify outputs">
|
<.remix_icon icon="zoom-in-line" class="text-xl" />
|
||||||
<.remix_icon icon="zoom-in-line" class="text-xl" data-el-zoom-in-icon />
|
|
||||||
<.remix_icon icon="zoom-out-line" class="text-xl" data-el-zoom-out-icon />
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -124,22 +124,40 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp on_smart_cell_click(%{requirement: %{}} = definition, variant_idx, section_id, cell_id) do
|
defp on_smart_cell_click(%{requirement: %{}} = definition, variant_idx, section_id, cell_id) do
|
||||||
|
variant = Enum.fetch!(definition.requirement.variants, variant_idx)
|
||||||
|
|
||||||
with_confirm(
|
with_confirm(
|
||||||
JS.push("add_smart_cell_dependencies",
|
JS.push("add_smart_cell_dependencies",
|
||||||
value: %{kind: definition.kind, variant_idx: variant_idx}
|
value: %{kind: definition.kind, variant_idx: variant_idx}
|
||||||
)
|
)
|
||||||
|> insert_smart_cell(definition, section_id, cell_id),
|
|> insert_smart_cell(definition, section_id, cell_id)
|
||||||
title: "Add package",
|
|> JS.push("queue_cells_reevaluation"),
|
||||||
description: ~s'''
|
title: "Add packages",
|
||||||
The “#{definition.name}“ smart cell requires #{definition.requirement.name}.
|
description:
|
||||||
Do you want to add it as a dependency and reinstall dependencies?
|
case variant.packages do
|
||||||
''',
|
[%{name: name}] ->
|
||||||
confirm_text: "Add and reinstall",
|
~s'''
|
||||||
|
The <span class="font-semibold">“#{definition.name}“</span>
|
||||||
|
smart cell requires the #{code_tag(name)} package. Do you want to add
|
||||||
|
it as a dependency and restart?
|
||||||
|
'''
|
||||||
|
|
||||||
|
packages ->
|
||||||
|
~s'''
|
||||||
|
The <span class="font-semibold">“#{definition.name}“</span>
|
||||||
|
smart cell requires the #{packages |> Enum.map(&code_tag(&1.name)) |> format_items()}
|
||||||
|
packages. Do you want to add them as dependencies and restart?
|
||||||
|
'''
|
||||||
|
end,
|
||||||
|
confirm_text: "Add and restart",
|
||||||
confirm_icon: "add-line",
|
confirm_icon: "add-line",
|
||||||
danger: false
|
danger: false,
|
||||||
|
html: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp code_tag(text), do: "<code>#{text}</code>"
|
||||||
|
|
||||||
defp insert_smart_cell(js \\ %JS{}, definition, section_id, cell_id) do
|
defp insert_smart_cell(js \\ %JS{}, definition, section_id, cell_id) do
|
||||||
JS.push(js, "insert_cell_below",
|
JS.push(js, "insert_cell_below",
|
||||||
value: %{
|
value: %{
|
||||||
|
|
|
@ -110,6 +110,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
||||||
id={cell_view.id}
|
id={cell_view.id}
|
||||||
session_id={@session_id}
|
session_id={@session_id}
|
||||||
runtime={@runtime}
|
runtime={@runtime}
|
||||||
|
installing?={@installing?}
|
||||||
cell_view={cell_view} />
|
cell_view={cell_view} />
|
||||||
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
|
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
|
||||||
id={"insert-buttons-#{@section_view.id}-#{index}"}
|
id={"insert-buttons-#{@section_view.id}-#{index}"}
|
||||||
|
|
|
@ -3564,6 +3564,80 @@ defmodule Livebook.Session.DataTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "cell_ids_for_reevaluation/2" do
|
||||||
|
test "does not include the setup cell" do
|
||||||
|
data =
|
||||||
|
data_after_operations!([
|
||||||
|
{:insert_section, self(), 0, "s1"},
|
||||||
|
{:set_runtime, self(), connected_noop_runtime()},
|
||||||
|
evaluate_cells_operations(["setup"])
|
||||||
|
])
|
||||||
|
|
||||||
|
assert Data.cell_ids_for_reevaluation(data) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes evaluated cells" do
|
||||||
|
data =
|
||||||
|
data_after_operations!([
|
||||||
|
{:insert_section, self(), 0, "s1"},
|
||||||
|
{:insert_cell, self(), "s1", 0, :code, "c1", %{}},
|
||||||
|
{:insert_cell, self(), "s1", 1, :code, "c2", %{}},
|
||||||
|
{:set_runtime, self(), connected_noop_runtime()},
|
||||||
|
evaluate_cells_operations(["setup", "c1", "c2"])
|
||||||
|
])
|
||||||
|
|
||||||
|
assert Data.cell_ids_for_reevaluation(data) |> Enum.sort() == ["c1", "c2"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes stale cells" do
|
||||||
|
data =
|
||||||
|
data_after_operations!([
|
||||||
|
{:insert_section, self(), 0, "s1"},
|
||||||
|
{:insert_cell, self(), "s1", 0, :code, "c1", %{}},
|
||||||
|
{:insert_cell, self(), "s1", 1, :code, "c2", %{}},
|
||||||
|
{:set_runtime, self(), connected_noop_runtime()},
|
||||||
|
evaluate_cells_operations(["setup", "c1", "c2"]),
|
||||||
|
# Reevaluate cell 1
|
||||||
|
evaluate_cells_operations(["c1"])
|
||||||
|
])
|
||||||
|
|
||||||
|
assert Data.cell_ids_for_reevaluation(data) |> Enum.sort() == ["c1", "c2"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stops reevaluation on the first fresh cell" do
|
||||||
|
data =
|
||||||
|
data_after_operations!([
|
||||||
|
{:insert_section, self(), 0, "s1"},
|
||||||
|
{:insert_cell, self(), "s1", 0, :code, "c1", %{}},
|
||||||
|
{:insert_cell, self(), "s1", 1, :code, "c2", %{}},
|
||||||
|
{:set_runtime, self(), connected_noop_runtime()},
|
||||||
|
evaluate_cells_operations(["setup", "c1", "c2"]),
|
||||||
|
# Reevaluate cell 1
|
||||||
|
{:insert_cell, self(), "s1", 1, :code, "c3", %{}}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert Data.cell_ids_for_reevaluation(data) |> Enum.sort() == ["c1"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "considers each branch separately" do
|
||||||
|
data =
|
||||||
|
data_after_operations!([
|
||||||
|
{:insert_section, self(), 0, "s1"},
|
||||||
|
{:insert_cell, self(), "s1", 0, :code, "c1", %{}},
|
||||||
|
{:insert_section, self(), 1, "s2"},
|
||||||
|
{:insert_cell, self(), "s2", 0, :code, "c2", %{}},
|
||||||
|
{:insert_cell, self(), "s2", 1, :code, "c3", %{}},
|
||||||
|
{:insert_section, self(), 2, "s3"},
|
||||||
|
{:insert_cell, self(), "s3", 0, :code, "c4", %{}},
|
||||||
|
{:set_section_parent, self(), "s2", "s1"},
|
||||||
|
{:set_runtime, self(), connected_noop_runtime()},
|
||||||
|
evaluate_cells_operations(["setup", "c1", "c2", "c4"])
|
||||||
|
])
|
||||||
|
|
||||||
|
assert Data.cell_ids_for_reevaluation(data) |> Enum.sort() == ["c1", "c2", "c4"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp evaluate_cells_operations(cell_ids) do
|
defp evaluate_cells_operations(cell_ids) do
|
||||||
[
|
[
|
||||||
{:queue_cells_evaluation, self(), cell_ids},
|
{:queue_cells_evaluation, self(), cell_ids},
|
||||||
|
|
Loading…
Add table
Reference in a new issue