diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index b57443c75..56bac12a4 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -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] diff --git a/assets/js/hooks/confirm_modal.js b/assets/js/hooks/confirm_modal.js index 4666e0ffc..705a69e24 100644 --- a/assets/js/hooks/confirm_modal.js +++ b/assets/js/hooks/confirm_modal.js @@ -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) { diff --git a/assets/js/hooks/js_view.js b/assets/js/hooks/js_view.js index 1e1607fe6..b022021f6 100644 --- a/assets/js/hooks/js_view.js +++ b/assets/js/hooks/js_view.js @@ -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); diff --git a/assets/js/hooks/js_view/iframe.js b/assets/js/hooks/js_view/iframe.js index dadceb76a..41eb0497f 100644 --- a/assets/js/hooks/js_view/iframe.js +++ b/assets/js/hooks/js_view/iframe.js @@ -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; diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index b236e1270..5b9dda398 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -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(); } diff --git a/iframe/priv/static/iframe/v3.html b/iframe/priv/static/iframe/v3.html new file mode 100644 index 000000000..faa5bdac4 --- /dev/null +++ b/iframe/priv/static/iframe/v3.html @@ -0,0 +1,205 @@ + + + + Output + + + +
+ + + diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index 860457b71..34ff3ac8d 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -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()}) }) } diff --git a/lib/livebook/runtime/elixir_standalone.ex b/lib/livebook/runtime/elixir_standalone.ex index 1624c6c4e..3665f46e4 100644 --- a/lib/livebook/runtime/elixir_standalone.ex +++ b/lib/livebook/runtime/elixir_standalone.ex @@ -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] + } ] } } diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index d4da95ade..4897f45f4 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -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)} diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index 135a972d1..b5c690870 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -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 diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index b16bdd19d..a865a5320 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -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 diff --git a/lib/livebook_web/live/live_helpers.ex b/lib/livebook_web/live/live_helpers.ex index c8c7c51d6..39f9ae351 100644 --- a/lib/livebook_web/live/live_helpers.ex +++ b/lib/livebook_web/live/live_helpers.ex @@ -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] } ) diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 143625465..a0c8a4473 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -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} />
@@ -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 %>
@@ -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 diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index 6878564f5..0a0fba62b 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -181,7 +181,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do <% :dead -> %>
- 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 %>
<% :starting -> %> @@ -347,8 +351,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do ~H""" """ @@ -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" /> """ @@ -413,10 +416,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do def amplify_output_button(assigns) do ~H""" - """ diff --git a/lib/livebook_web/live/session_live/insert_buttons_component.ex b/lib/livebook_web/live/session_live/insert_buttons_component.ex index 28342b209..3d8a965c6 100644 --- a/lib/livebook_web/live/session_live/insert_buttons_component.ex +++ b/lib/livebook_web/live/session_live/insert_buttons_component.ex @@ -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 “#{definition.name}“ + smart cell requires the #{code_tag(name)} package. Do you want to add + it as a dependency and restart? + ''' + + packages -> + ~s''' + The “#{definition.name}“ + 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: "#{text}" + defp insert_smart_cell(js \\ %JS{}, definition, section_id, cell_id) do JS.push(js, "insert_cell_below", value: %{ diff --git a/lib/livebook_web/live/session_live/section_component.ex b/lib/livebook_web/live/session_live/section_component.ex index a68846468..eb8ab8065 100644 --- a/lib/livebook_web/live/session_live/section_component.ex +++ b/lib/livebook_web/live/session_live/section_component.ex @@ -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}"} diff --git a/test/livebook/session/data_test.exs b/test/livebook/session/data_test.exs index 3f20af258..32e3bbc7a 100644 --- a/test/livebook/session/data_test.exs +++ b/test/livebook/session/data_test.exs @@ -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},