mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-05 20:44:30 +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-amplify-outputs-button]
|
||||
[data-el-zoom-in-icon] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-el-cell]:not([data-js-amplified])
|
||||
[data-el-amplify-outputs-button]
|
||||
[data-el-zoom-out-icon] {
|
||||
@apply hidden;
|
||||
.icon-button {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
[data-el-cell][data-type="smart"]:not([data-js-source-visible])
|
||||
|
@ -167,14 +161,10 @@ solely client-side operations.
|
|||
@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-show-code-icon] {
|
||||
@apply hidden;
|
||||
[data-el-toggle-source-button]
|
||||
.icon-button {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
[data-el-cell][data-type="smart"][data-js-source-visible]
|
||||
|
|
|
@ -26,6 +26,7 @@ const ConfirmModal = {
|
|||
confirm_text,
|
||||
confirm_icon,
|
||||
danger,
|
||||
html,
|
||||
opt_out_id,
|
||||
} = event.detail;
|
||||
|
||||
|
@ -33,7 +34,13 @@ const ConfirmModal = {
|
|||
liveSocket.execJS(event.target, event.detail.on_confirm);
|
||||
} else {
|
||||
titleEl.textContent = title;
|
||||
descriptionEl.textContent = description;
|
||||
|
||||
if (html) {
|
||||
descriptionEl.innerHTML = description;
|
||||
} else {
|
||||
descriptionEl.textContent = description;
|
||||
}
|
||||
|
||||
confirmTextEl.textContent = confirm_text;
|
||||
|
||||
if (confirm_icon) {
|
||||
|
|
|
@ -76,6 +76,10 @@ const JSView = {
|
|||
window.addEventListener("message", this._handleWindowMessage);
|
||||
});
|
||||
|
||||
this.hiddenInput = document.createElement("input");
|
||||
this.hiddenInput.style.display = "none";
|
||||
this.el.appendChild(this.hiddenInput);
|
||||
|
||||
this.loadIframe();
|
||||
|
||||
// Channel events
|
||||
|
@ -283,7 +287,11 @@ const JSView = {
|
|||
// Replicate the child events on the current element,
|
||||
// so that they are detected upstream in the session hook
|
||||
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") {
|
||||
const { event, payload } = message;
|
||||
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
|
||||
// (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) {
|
||||
const iframeUrl = getIframeUrl(iframePort);
|
||||
|
@ -42,8 +42,8 @@ export function initializeIframeSource(iframe, iframePort) {
|
|||
|
||||
function getIframeUrl(iframePort) {
|
||||
return window.location.protocol === "https:"
|
||||
? "https://livebook.space/iframe/v2.html"
|
||||
: `http://${window.location.hostname}:${iframePort}/iframe/v2.html`;
|
||||
? "https://livebook.space/iframe/v3.html"
|
||||
: `http://${window.location.hostname}:${iframePort}/iframe/v3.html`;
|
||||
}
|
||||
|
||||
let iframeVerificationPromise = null;
|
||||
|
|
|
@ -284,6 +284,23 @@ const Session = {
|
|||
const key = event.key;
|
||||
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) {
|
||||
keyBuffer.reset();
|
||||
|
||||
|
@ -292,46 +309,23 @@ const Session = {
|
|||
if (!this.escapesMonacoWidget(event)) {
|
||||
this.escapeInsertMode();
|
||||
}
|
||||
} else if (cmd && shift && !alt && key === "Enter") {
|
||||
cancelEvent(event);
|
||||
this.queueFullCellsEvaluation(true);
|
||||
} else if (cmd && !alt && key === "Enter") {
|
||||
cancelEvent(event);
|
||||
if (isEvaluable(this.focusedCellType())) {
|
||||
this.queueFocusedCellEvaluation();
|
||||
}
|
||||
} else if (cmd && key === "s") {
|
||||
cancelEvent(event);
|
||||
this.saveNotebook();
|
||||
}
|
||||
// Ignore keystrokes on input fields
|
||||
} else if (isEditableElement(event.target)) {
|
||||
keyBuffer.reset();
|
||||
|
||||
// Use Escape for universal blur
|
||||
if (key === "Escape") {
|
||||
event.target.blur();
|
||||
}
|
||||
} 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);
|
||||
|
||||
if (cmd && key === "s") {
|
||||
cancelEvent(event);
|
||||
this.saveNotebook();
|
||||
} else if (keyBuffer.tryMatch(["d", "d"])) {
|
||||
if (keyBuffer.tryMatch(["d", "d"])) {
|
||||
this.deleteFocusedCell();
|
||||
} else if (cmd && shift && !alt && key === "Enter") {
|
||||
this.queueFullCellsEvaluation(true);
|
||||
} else if (keyBuffer.tryMatch(["e", "a"])) {
|
||||
this.queueFullCellsEvaluation(false);
|
||||
} else if (
|
||||
keyBuffer.tryMatch(["e", "e"]) ||
|
||||
(cmd && !alt && key === "Enter")
|
||||
) {
|
||||
} else if (keyBuffer.tryMatch(["e", "e"])) {
|
||||
if (isEvaluable(this.focusedCellType())) {
|
||||
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 :: %{
|
||||
name: String.t(),
|
||||
variants:
|
||||
list(%{
|
||||
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
|
||||
}
|
||||
|
||||
kino_vega_lite_dep = {:kino_vega_lite, "~> 0.1.0"}
|
||||
kino_db_dep = {:kino_db, "~> 0.1.0"}
|
||||
kino_vega_lite = %{name: "kino_vega_lite", dependency: {:kino_vega_lite, "~> 0.1.0"}}
|
||||
kino_db = %{name: "kino_db", dependency: {:kino_db, "~> 0.1.0"}}
|
||||
|
||||
@extra_smart_cell_definitions [
|
||||
%{
|
||||
kind: "Elixir.KinoDB.ConnectionCell",
|
||||
name: "Database connection",
|
||||
requirement: %{
|
||||
name: "KinoDB",
|
||||
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",
|
||||
name: "SQL query",
|
||||
requirement: %{
|
||||
name: "KinoDB",
|
||||
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",
|
||||
name: "Chart",
|
||||
requirement: %{
|
||||
name: "KinoVegaLite",
|
||||
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})
|
||||
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 """
|
||||
Sends cell evaluation cancellation request to the server.
|
||||
"""
|
||||
|
@ -794,6 +805,13 @@ defmodule Livebook.Session do
|
|||
{:noreply, handle_operation(state, operation)}
|
||||
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
|
||||
operation = {:cancel_cell_evaluation, client_pid, cell_id}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
|
|
|
@ -1898,4 +1898,35 @@ defmodule Livebook.Session.Data do
|
|||
cell.id in cell_ids,
|
||||
do: cell.id
|
||||
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
|
||||
|
|
|
@ -70,6 +70,30 @@ defmodule LivebookWeb.Helpers do
|
|||
iex> LivebookWeb.Helpers.pluralize(3, "notebook is not persisted", "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(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
|
||||
|
|
|
@ -131,6 +131,8 @@ defmodule LivebookWeb.LiveHelpers do
|
|||
|
||||
* `: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"
|
||||
checkbox. Once checked by the user, the confirmation with this
|
||||
id is never shown again. Optional
|
||||
|
@ -161,7 +163,8 @@ defmodule LivebookWeb.LiveHelpers do
|
|||
:opt_out_id,
|
||||
title: "Are you sure?",
|
||||
confirm_text: "Yes",
|
||||
danger: true
|
||||
danger: true,
|
||||
html: false
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -173,6 +176,7 @@ defmodule LivebookWeb.LiveHelpers do
|
|||
confirm_text: opts[:confirm_text],
|
||||
confirm_icon: opts[:confirm_icon],
|
||||
danger: opts[:danger],
|
||||
html: opts[:html],
|
||||
opt_out_id: opts[:opt_out_id]
|
||||
}
|
||||
)
|
||||
|
|
|
@ -196,6 +196,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
id={@data_view.setup_cell_view.id}
|
||||
session_id={@session.id}
|
||||
runtime={@data_view.runtime}
|
||||
installing?={@data_view.installing?}
|
||||
cell_view={@data_view.setup_cell_view} />
|
||||
</div>
|
||||
<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}
|
||||
runtime={@data_view.runtime}
|
||||
smart_cell_definitions={@data_view.smart_cell_definitions}
|
||||
installing?={@data_view.installing?}
|
||||
section_view={section_view} />
|
||||
<% end %>
|
||||
<div style="height: 80vh"></div>
|
||||
|
@ -755,7 +757,8 @@ defmodule LivebookWeb.SessionLive do
|
|||
|
||||
with %{requirement: %{variants: variants}} <-
|
||||
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)
|
||||
end
|
||||
|
||||
|
@ -809,6 +812,13 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, socket}
|
||||
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
|
||||
assert_policy!(socket, :edit)
|
||||
|
||||
|
@ -1430,6 +1440,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
data.clients_map
|
||||
|> 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),
|
||||
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},
|
||||
section_views: section_views(data.notebook.sections, data),
|
||||
bin_entries: data.bin_entries
|
||||
|
|
|
@ -181,7 +181,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
|
||||
<% :dead -> %>
|
||||
<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>
|
||||
|
||||
<% :starting -> %>
|
||||
|
@ -347,8 +351,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
~H"""
|
||||
<span class="tooltip top" data-tooltip="Toggle source" data-el-toggle-source-button>
|
||||
<button class="icon-button" aria-label="toggle source">
|
||||
<.remix_icon icon="code-line" class="text-xl" data-el-show-code-icon />
|
||||
<.remix_icon icon="pencil-line" class="text-xl" data-el-show-ui-icon />
|
||||
<.remix_icon icon="code-line" class="text-xl" />
|
||||
</button>
|
||||
</span>
|
||||
"""
|
||||
|
@ -370,7 +373,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
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>
|
||||
</span>
|
||||
"""
|
||||
|
@ -413,10 +416,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
def amplify_output_button(assigns) do
|
||||
~H"""
|
||||
<span class="tooltip top" data-tooltip="Amplify output" data-el-amplify-outputs-button>
|
||||
<button class="icon-button"
|
||||
aria-label="amplify outputs">
|
||||
<.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 class="icon-button" aria-label="amplify outputs">
|
||||
<.remix_icon icon="zoom-in-line" class="text-xl" />
|
||||
</button>
|
||||
</span>
|
||||
"""
|
||||
|
|
|
@ -124,22 +124,40 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
|||
end
|
||||
|
||||
defp on_smart_cell_click(%{requirement: %{}} = definition, variant_idx, section_id, cell_id) do
|
||||
variant = Enum.fetch!(definition.requirement.variants, variant_idx)
|
||||
|
||||
with_confirm(
|
||||
JS.push("add_smart_cell_dependencies",
|
||||
value: %{kind: definition.kind, variant_idx: variant_idx}
|
||||
)
|
||||
|> insert_smart_cell(definition, section_id, cell_id),
|
||||
title: "Add package",
|
||||
description: ~s'''
|
||||
The “#{definition.name}“ smart cell requires #{definition.requirement.name}.
|
||||
Do you want to add it as a dependency and reinstall dependencies?
|
||||
''',
|
||||
confirm_text: "Add and reinstall",
|
||||
|> insert_smart_cell(definition, section_id, cell_id)
|
||||
|> JS.push("queue_cells_reevaluation"),
|
||||
title: "Add packages",
|
||||
description:
|
||||
case variant.packages do
|
||||
[%{name: name}] ->
|
||||
~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",
|
||||
danger: false
|
||||
danger: false,
|
||||
html: true
|
||||
)
|
||||
end
|
||||
|
||||
defp code_tag(text), do: "<code>#{text}</code>"
|
||||
|
||||
defp insert_smart_cell(js \\ %JS{}, definition, section_id, cell_id) do
|
||||
JS.push(js, "insert_cell_below",
|
||||
value: %{
|
||||
|
|
|
@ -110,6 +110,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
id={cell_view.id}
|
||||
session_id={@session_id}
|
||||
runtime={@runtime}
|
||||
installing?={@installing?}
|
||||
cell_view={cell_view} />
|
||||
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
|
||||
id={"insert-buttons-#{@section_view.id}-#{index}"}
|
||||
|
|
|
@ -3564,6 +3564,80 @@ defmodule Livebook.Session.DataTest do
|
|||
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
|
||||
[
|
||||
{:queue_cells_evaluation, self(), cell_ids},
|
||||
|
|
Loading…
Add table
Reference in a new issue