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:
Jonatan Kłosko 2022-04-30 12:26:03 +02:00 committed by GitHub
parent 8f72d0175e
commit 8e063792ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 476 additions and 82 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: %{

View file

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

View file

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