From 146f89f5f51108f9eaf08bec1317555a26c170c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Fri, 21 Jul 2023 20:11:11 +0200 Subject: [PATCH] Add drag and drop actions for notebook files (#2096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- assets/css/js_interop.css | 6 +- assets/js/hooks/session.js | 41 +++ lib/livebook/notebook.ex | 4 +- lib/livebook/runtime.ex | 47 ++- lib/livebook/runtime/attached.ex | 4 +- lib/livebook/runtime/definitions.ex | 48 ++- lib/livebook/runtime/dependencies.ex | 2 +- lib/livebook/runtime/elixir_standalone.ex | 4 +- lib/livebook/runtime/embedded.ex | 4 +- lib/livebook_web/live/session_live.ex | 344 ++++++++++++------ .../live/session_live/files_list_component.ex | 7 +- .../session_live/insert_buttons_component.ex | 60 +-- .../session_live/insert_file_component.ex | 28 ++ .../live/session_live/section_component.ex | 4 +- lib/livebook_web/router.ex | 1 + test/livebook_web/live/session_live_test.exs | 93 ++++- test/support/noop_runtime.ex | 4 +- 17 files changed, 537 insertions(+), 164 deletions(-) create mode 100644 lib/livebook_web/live/session_live/insert_file_component.ex diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index a056fb7db..b53eec2d8 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -14,7 +14,7 @@ solely client-side operations. } [phx-hook="Dropzone"][data-js-dragging] { - @apply bg-yellow-100 border-yellow-300; + @apply bg-blue-100 border-blue-300; } /* === Session === */ @@ -73,6 +73,10 @@ solely client-side operations. @apply hidden; } +[data-el-session]:not([data-js-dragging]) [data-el-insert-drop-area] { + @apply hidden; +} + [data-el-cell][data-js-focused] { @apply border-blue-300 border-opacity-100; } diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index 74769d4f1..06acba13b 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -142,6 +142,8 @@ const Session = { (event) => this.toggleCollapseAllSections() ); + this.initializeDragAndDrop(); + window.addEventListener( "phx:page-loading-stop", () => { @@ -707,6 +709,45 @@ const Session = { } }, + /** + * Initializes drag and drop event handlers. + */ + initializeDragAndDrop() { + let draggedEl = null; + + this.el.addEventListener("dragstart", (event) => { + draggedEl = event.target; + this.el.setAttribute("data-js-dragging", ""); + }); + + this.el.addEventListener("dragend", (event) => { + this.el.removeAttribute("data-js-dragging"); + }); + + this.el.addEventListener("dragover", (event) => { + const dropEl = event.target.closest(`[data-el-insert-drop-area]`); + + if (dropEl) { + event.preventDefault(); + } + }); + + this.el.addEventListener("drop", (event) => { + const dropEl = event.target.closest(`[data-el-insert-drop-area]`); + + if (draggedEl.matches("[data-el-file-entry]") && dropEl) { + const fileEntryName = draggedEl.getAttribute("data-name"); + const sectionId = dropEl.getAttribute("data-section-id") || null; + const cellId = dropEl.getAttribute("data-cell-id") || null; + this.pushEvent("insert_file", { + file_entry_name: fileEntryName, + section_id: sectionId, + cell_id: cellId, + }); + } + }); + }, + // User action handlers (mostly keybindings) toggleSectionsList() { diff --git a/lib/livebook/notebook.ex b/lib/livebook/notebook.ex index 506df347a..e137d4d15 100644 --- a/lib/livebook/notebook.ex +++ b/lib/livebook/notebook.ex @@ -843,8 +843,10 @@ defmodule Livebook.Notebook do """ @spec validate_file_entry_name(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t() def validate_file_entry_name(changeset, field) do - Ecto.Changeset.validate_format(changeset, field, ~r/^[\w-.]+$/, + changeset + |> Ecto.Changeset.validate_format(field, ~r/^[\w-.]+$/, message: "should contain only alphanumeric characters, dash, underscore and dot" ) + |> Ecto.Changeset.validate_format(field, ~r/\.\w+$/, message: "should end with an extension") end end diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index f7adece30..121958904 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -273,15 +273,17 @@ defprotocol Livebook.Runtime do requirement_presets: list(%{ name: String.t(), - packages: list(%{name: String.t(), dependency: dependency()}) + packages: list(package()) }) } + @type package :: %{name: String.t(), dependency: dependency()} + @type dependency :: term() - @type search_packages_response :: {:ok, list(package())} | {:error, String.t()} + @type search_packages_response :: {:ok, list(package_details())} | {:error, String.t()} - @type package :: %{ + @type package_details :: %{ name: String.t(), version: String.t(), description: String.t() | nil, @@ -290,19 +292,46 @@ defprotocol Livebook.Runtime do } @typedoc """ - An information about a predefined code block. + An information about a predefined code snippets. """ - @type code_block_definition :: %{ + @type snippet_definition :: example_snippet_definition() | file_action_snippet_definition() + + @typedoc """ + Code snippet with fixed source, serving as an example or boilerplate. + """ + @type example_snippet_definition :: %{ + type: :example, name: String.t(), icon: String.t(), variants: list(%{ name: String.t(), source: String.t(), - packages: list(%{name: String.t(), dependency: dependency()}) + packages: list(package()) }) } + @typedoc """ + Code snippet for acting on files of the given type. + + The action is applicable to files matching any of the specified types, + where a type can be either: + + * specific MIME type, like `text/csv` + * MIME type family, like `image/*` + * file extension, like `.csv` + + The source is expected to include `{{NAME}}`, which is replaced with + the actual file name. + """ + @type file_action_snippet_definition :: %{ + type: :file_action, + file_types: :any | list(String.t()), + description: String.t(), + source: String.t(), + packages: list(package()) + } + @typedoc """ A JavaScript view definition. @@ -615,10 +644,10 @@ defprotocol Livebook.Runtime do def has_dependencies?(runtime, dependencies) @doc """ - Returns a list of predefined code blocks. + Returns a list of predefined code snippets. """ - @spec code_block_definitions(t()) :: list(code_block_definition()) - def code_block_definitions(runtime) + @spec snippet_definitions(t()) :: list(snippet_definition()) + def snippet_definitions(runtime) @doc """ Looks up packages matching the given search. diff --git a/lib/livebook/runtime/attached.ex b/lib/livebook/runtime/attached.ex index f58fbd9a9..eb29c6bf9 100644 --- a/lib/livebook/runtime/attached.ex +++ b/lib/livebook/runtime/attached.ex @@ -165,8 +165,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do RuntimeServer.has_dependencies?(runtime.server_pid, dependencies) end - def code_block_definitions(_runtime) do - Livebook.Runtime.Definitions.code_block_definitions() + def snippet_definitions(_runtime) do + Livebook.Runtime.Definitions.snippet_definitions() end def search_packages(_runtime, _send_to, _search) do diff --git a/lib/livebook/runtime/definitions.ex b/lib/livebook/runtime/definitions.ex index 2c28833a5..31bd699ce 100644 --- a/lib/livebook/runtime/definitions.ex +++ b/lib/livebook/runtime/definitions.ex @@ -46,6 +46,11 @@ defmodule Livebook.Runtime.Definitions do dependency: %{dep: {:kino_explorer, "~> 0.1.4"}, config: []} } + jason = %{ + name: "jason", + dependency: %{dep: {:jason, "~> 1.4"}, config: []} + } + windows? = match?({:win32, _}, :os.type()) nx_backend_package = if(windows?, do: torchx, else: exla) @@ -159,8 +164,10 @@ defmodule Livebook.Runtime.Definitions do } ] - @code_block_definitions [ + @snippet_definitions [ + # Examples %{ + type: :example, name: "Form", icon: "bill-line", variants: [ @@ -184,10 +191,47 @@ defmodule Livebook.Runtime.Definitions do packages: [kino] } ] + }, + # File actions + %{ + type: :file_action, + file_types: :any, + description: "Read file content", + source: """ + content = + Kino.FS.file_path("{{NAME}}") + |> File.read!()\ + """, + packages: [kino] + }, + %{ + type: :file_action, + file_types: ["application/json"], + description: "Parse JSON content", + source: """ + data = + Kino.FS.file_path("{{NAME}}") + |> File.read!() + |> Jason.decode!() + + Kino.Tree.new(data)\ + """, + packages: [kino, jason] + }, + %{ + type: :file_action, + file_types: ["text/csv"], + description: "Create a dataframe", + source: """ + df = + Kino.FS.file_path("{{NAME}}") + |> Explorer.DataFrame.from_csv!()\ + """, + packages: [kino, kino_explorer] } ] def smart_cell_definitions(), do: @smart_cell_definitions - def code_block_definitions(), do: @code_block_definitions + def snippet_definitions(), do: @snippet_definitions end diff --git a/lib/livebook/runtime/dependencies.ex b/lib/livebook/runtime/dependencies.ex index 30dd06d9e..0355e3ddf 100644 --- a/lib/livebook/runtime/dependencies.ex +++ b/lib/livebook/runtime/dependencies.ex @@ -255,7 +255,7 @@ defmodule Livebook.Runtime.Dependencies do through the given list of packages. """ @spec search_packages_in_list( - list(Livebook.Runtime.package()), + list(Livebook.Runtime.package_details()), pid(), String.t() ) :: reference() diff --git a/lib/livebook/runtime/elixir_standalone.ex b/lib/livebook/runtime/elixir_standalone.ex index 3df54bcaa..c2d438cbb 100644 --- a/lib/livebook/runtime/elixir_standalone.ex +++ b/lib/livebook/runtime/elixir_standalone.ex @@ -167,8 +167,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do RuntimeServer.has_dependencies?(runtime.server_pid, dependencies) end - def code_block_definitions(_runtime) do - Livebook.Runtime.Definitions.code_block_definitions() + def snippet_definitions(_runtime) do + Livebook.Runtime.Definitions.snippet_definitions() end def search_packages(_runtime, send_to, search) do diff --git a/lib/livebook/runtime/embedded.ex b/lib/livebook/runtime/embedded.ex index 4a0fa3cc0..28252d499 100644 --- a/lib/livebook/runtime/embedded.ex +++ b/lib/livebook/runtime/embedded.ex @@ -135,8 +135,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do RuntimeServer.has_dependencies?(runtime.server_pid, dependencies) end - def code_block_definitions(_runtime) do - Livebook.Runtime.Definitions.code_block_definitions() + def snippet_definitions(_runtime) do + Livebook.Runtime.Definitions.snippet_definitions() end def search_packages(_runtime, send_to, search) do diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index b38ab013c..de306be39 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -410,7 +410,7 @@ defmodule LivebookWeb.SessionLive do client_id={@client_id} runtime={@data_view.runtime} smart_cell_definitions={@data_view.smart_cell_definitions} - code_block_definitions={@data_view.code_block_definitions} + example_snippet_definitions={@data_view.example_snippet_definitions} installing?={@data_view.installing?} allowed_uri_schemes={@allowed_uri_schemes} section_view={section_view} @@ -527,6 +527,22 @@ defmodule LivebookWeb.SessionLive do /> + <.modal + :if={@live_action == :insert_file} + id="insert-file-modal" + show + width={:medium} + patch={@self_path} + > + <.live_component + module={LivebookWeb.SessionLive.InsertFileComponent} + id="insert-file" + session={@session} + return_to={@self_path} + insert_file_metadata={@insert_file_metadata} + /> + + <.modal :if={@live_action == :bin} id="bin-modal" show width={:big} patch={@self_path}> <.live_component module={LivebookWeb.SessionLive.BinComponent} @@ -925,15 +941,16 @@ defmodule LivebookWeb.SessionLive do {:noreply, assign(socket, cell: cell)} end - def handle_params(%{"section_id" => section_id, "cell_id" => cell_id}, _url, socket) - when socket.assigns.live_action == :insert_image do - cell_id = - case cell_id do - "" -> nil - id -> id - end + def handle_params(%{}, _url, socket) + when socket.assigns.live_action == :insert_image and + not is_map_key(socket.assigns, :insert_image_metadata) do + {:noreply, redirect_to_self(socket)} + end - {:noreply, assign(socket, insert_image_metadata: %{section_id: section_id, cell_id: cell_id})} + def handle_params(%{}, _url, socket) + when socket.assigns.live_action == :insert_file and + not is_map_key(socket.assigns, :insert_file_metadata) do + {:noreply, redirect_to_self(socket)} end def handle_params(%{"path_parts" => path_parts}, requested_url, socket) @@ -1015,43 +1032,33 @@ defmodule LivebookWeb.SessionLive do {:noreply, insert_cell_below(socket, params)} end - def handle_event("insert_code_block_below", params, socket) do + def handle_event("insert_example_snippet_below", params, socket) do data = socket.private.data %{"section_id" => section_id, "cell_id" => cell_id} = params - case code_block_definition_by_name(data, params["definition_name"]) do - {:ok, definition} -> - variant = Enum.fetch!(definition.variants, params["variant_idx"]) - dependencies = Enum.map(variant.packages, & &1.dependency) + if Livebook.Runtime.connected?(socket.private.data.runtime) do + case example_snippet_definition_by_name(data, params["definition_name"]) do + {:ok, definition} -> + variant = Enum.fetch!(definition.variants, params["variant_idx"]) - has_dependencies? = - dependencies == [] or Livebook.Runtime.has_dependencies?(data.runtime, dependencies) - - cond do - has_dependencies? -> - insert_code_block_below(socket, variant, section_id, cell_id) - {:noreply, socket} - - Livebook.Runtime.fixed_dependencies?(data.runtime) -> - {:noreply, - put_flash(socket, :error, "This runtime doesn't support adding dependencies")} - - true -> - on_confirm = fn socket -> - case insert_code_block_below(socket, variant, section_id, cell_id) do - :ok -> add_dependencies_and_reevaluate(socket, dependencies) - :error -> socket + socket = + ensure_packages_then(socket, variant.packages, definition.name, "block", fn socket -> + with {:ok, section, index} <- + section_with_next_index(socket.private.data.notebook, section_id, cell_id) do + attrs = %{source: variant.source} + Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs) + {:ok, socket} end - end + end) - socket = - confirm_add_packages(socket, on_confirm, variant.packages, definition.name, "block") + {:noreply, socket} - {:noreply, socket} - end - - _ -> - {:noreply, socket} + _ -> + {:noreply, socket} + end + else + reason = "To insert this block, you need a connected runtime." + {:noreply, confirm_setup_default_runtime(socket, reason)} end end @@ -1066,32 +1073,19 @@ defmodule LivebookWeb.SessionLive do Enum.at(definition.requirement_presets, preset_idx) end - if preset == nil do - insert_smart_cell_below(socket, definition, section_id, cell_id) - {:noreply, socket} - else - on_confirm = fn socket -> - case insert_smart_cell_below(socket, definition, section_id, cell_id) do - :ok -> - dependencies = Enum.map(preset.packages, & &1.dependency) - add_dependencies_and_reevaluate(socket, dependencies) + packages = if(preset, do: preset.packages, else: []) - :error -> - socket + socket = + ensure_packages_then(socket, packages, definition.name, "smart cell", fn socket -> + with {:ok, section, index} <- + section_with_next_index(socket.private.data.notebook, section_id, cell_id) do + attrs = %{kind: definition.kind} + Session.insert_cell(socket.assigns.session.pid, section.id, index, :smart, attrs) + {:ok, socket} end - end + end) - socket = - confirm_add_packages( - socket, - on_confirm, - preset.packages, - definition.name, - "smart cell" - ) - - {:noreply, socket} - end + {:noreply, socket} _ -> {:noreply, socket} @@ -1336,24 +1330,7 @@ defmodule LivebookWeb.SessionLive do end def handle_event("setup_default_runtime", %{"reason" => reason}, socket) do - on_confirm = fn socket -> - {status, socket} = connect_runtime(socket) - - if status == :ok do - Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id()) - end - - socket - end - - {:noreply, - confirm(socket, on_confirm, - title: "Setup runtime", - description: "#{reason} Do you want to connect and setup the default one?", - confirm_text: "Setup runtime", - confirm_icon: "play-line", - danger: false - )} + {:noreply, confirm_setup_default_runtime(socket, reason)} end def handle_event("disconnect_runtime", %{}, socket) do @@ -1499,9 +1476,7 @@ defmodule LivebookWeb.SessionLive do end def handle_event("review_file_entry_access", %{"name" => name}, socket) do - file_entry = Enum.find(socket.private.data.notebook.file_entries, &(&1.name == name)) - - if file_entry do + if file_entry = find_file_entry(socket, name) do on_confirm = fn socket -> Session.allow_file_entry(socket.assigns.session.pid, file_entry.name) socket @@ -1533,6 +1508,77 @@ defmodule LivebookWeb.SessionLive do end end + def handle_event("insert_image", params, socket) do + {:noreply, + socket + |> assign( + insert_image_metadata: %{section_id: params["section_id"], cell_id: params["cell_id"]} + ) + |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-image")} + end + + def handle_event( + "insert_file", + %{"section_id" => section_id, "cell_id" => cell_id, "file_entry_name" => file_entry_name}, + socket + ) do + if file_entry = find_file_entry(socket, file_entry_name) do + if Livebook.Runtime.connected?(socket.private.data.runtime) do + {:noreply, + socket + |> assign( + insert_file_metadata: %{ + section_id: section_id, + cell_id: cell_id, + file_entry: file_entry, + handlers: handlers_for_file_entry(file_entry, socket.private.data.runtime) + } + ) + |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-file")} + else + reason = "To see the available options, you need a connected runtime." + {:noreply, confirm_setup_default_runtime(socket, reason)} + end + else + {:noreply, socket} + end + end + + def handle_event("insert_file_action", %{"idx" => idx}, socket) do + %{section_id: section_id, cell_id: cell_id, file_entry: file_entry, handlers: handlers} = + socket.assigns.insert_file_metadata + + handler = Enum.fetch!(handlers, idx) + source = String.replace(handler.definition.source, "{{NAME}}", file_entry.name) + + socket = + socket + |> redirect_to_self() + |> ensure_packages_then( + handler.definition.packages, + handler.definition.description, + "action", + fn socket -> + with {:ok, section, index} <- + section_with_next_index(socket.private.data.notebook, section_id, cell_id) do + attrs = %{source: source} + + Session.insert_cell( + socket.assigns.session.pid, + section.id, + index, + handler.cell_type, + attrs + ) + + {:ok, socket} + end + end + ) + + {:noreply, socket} + end + @impl true def handle_info({:operation, operation}, socket) do {:noreply, handle_operation(socket, operation)} @@ -2164,14 +2210,34 @@ defmodule LivebookWeb.SessionLive do end end + defp confirm_setup_default_runtime(socket, reason) do + on_confirm = fn socket -> + {status, socket} = connect_runtime(socket) + + if status == :ok do + Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id()) + end + + socket + end + + confirm(socket, on_confirm, + title: "Setup runtime", + description: "#{reason} Do you want to connect and setup the default one?", + confirm_text: "Setup runtime", + confirm_icon: "play-line", + danger: false + ) + end + defp starred_files(starred_notebooks) do for info <- starred_notebooks, into: MapSet.new(), do: info.file end - defp code_block_definition_by_name(data, name) do + defp example_snippet_definition_by_name(data, name) do data.runtime - |> Livebook.Runtime.code_block_definitions() - |> Enum.find_value(:error, &(&1.name == name && {:ok, &1})) + |> Livebook.Runtime.snippet_definitions() + |> Enum.find_value(:error, &(&1.type == :example && &1.name == name && {:ok, &1})) end defp smart_cell_definition_by_kind(data, kind) do @@ -2191,6 +2257,35 @@ defmodule LivebookWeb.SessionLive do socket end + defp ensure_packages_then(socket, packages, target_name, target_type, fun) do + dependencies = Enum.map(packages, & &1.dependency) + + has_dependencies? = + dependencies == [] or + Livebook.Runtime.has_dependencies?(socket.private.data.runtime, dependencies) + + cond do + has_dependencies? -> + case fun.(socket) do + {:ok, socket} -> socket + :error -> socket + end + + Livebook.Runtime.fixed_dependencies?(socket.private.data.runtime) -> + put_flash(socket, :error, "This runtime doesn't support adding dependencies") + + true -> + on_confirm = fn socket -> + case fun.(socket) do + {:ok, socket} -> add_dependencies_and_reevaluate(socket, dependencies) + :error -> socket + end + end + + confirm_add_packages(socket, on_confirm, packages, target_name, target_type) + end + end + defp confirm_add_packages(socket, on_confirm, packages, target_name, target_type) do assigns = %{packages: packages, target_name: target_name, target_type: target_type} @@ -2212,24 +2307,6 @@ defmodule LivebookWeb.SessionLive do ) end - defp insert_code_block_below(socket, variant, section_id, cell_id) do - with {:ok, section, index} <- - section_with_next_index(socket.private.data.notebook, section_id, cell_id) do - attrs = %{source: variant.source} - Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs) - :ok - end - end - - defp insert_smart_cell_below(socket, definition, section_id, cell_id) do - with {:ok, section, index} <- - section_with_next_index(socket.private.data.notebook, section_id, cell_id) do - attrs = %{kind: definition.kind} - Session.insert_cell(socket.assigns.session.pid, section.id, index, :smart, attrs) - :ok - end - end - defp push_cell_editor_payloads(socket, data, cells, tags \\ :all) do for cell <- cells, {tag, payload} <- cell_editor_init_payloads(cell, data.cell_infos[cell.id]), @@ -2299,6 +2376,57 @@ defmodule LivebookWeb.SessionLive do end) end + defp find_file_entry(socket, name) do + Enum.find(socket.private.data.notebook.file_entries, &(&1.name == name)) + end + + defp handlers_for_file_entry(file_entry, runtime) do + handlers = + for definition <- Livebook.Runtime.snippet_definitions(runtime), + definition.type == :file_action, + do: %{definition: definition, cell_type: :code} + + handlers = + if file_entry.type == :attachment do + [ + %{ + cell_type: :markdown, + definition: %{ + type: :file_action, + description: "Insert as Markdown image", + source: "![](files/{{NAME}})", + file_types: ["image/*"], + packages: [] + } + } + | handlers + ] + else + handlers + end + + handlers + |> Enum.filter(&matches_file_types?(file_entry.name, &1.definition.file_types)) + |> Enum.sort_by(& &1.definition.description) + end + + defp matches_file_types?(_name, :any), do: true + + defp matches_file_types?(name, file_types) do + mime_type = MIME.from_path(name) + extension = Path.extname(name) + + Enum.any?(file_types, fn file_type -> + case String.split(file_type, "/") do + [group, "*"] -> + String.starts_with?(mime_type, group <> "/") + + _ -> + file_type == mime_type or file_type == extension + end + end) + end + # Builds view-specific structure of data by cherry-picking # only the relevant attributes. # We then use `@data_view` in the templates and consequently @@ -2315,8 +2443,12 @@ defmodule LivebookWeb.SessionLive do dirty: data.dirty, persistence_warnings: data.persistence_warnings, runtime: data.runtime, - smart_cell_definitions: data.smart_cell_definitions, - code_block_definitions: Livebook.Runtime.code_block_definitions(data.runtime), + smart_cell_definitions: Enum.sort_by(data.smart_cell_definitions, & &1.name), + example_snippet_definitions: + data.runtime + |> Livebook.Runtime.snippet_definitions() + |> Enum.filter(&(&1.type == :example)) + |> Enum.sort_by(& &1.name), global_status: global_status(data), notebook_name: data.notebook.name, sections_items: diff --git a/lib/livebook_web/live/session_live/files_list_component.ex b/lib/livebook_web/live/session_live/files_list_component.ex index d6e5e6bc0..cbf17d752 100644 --- a/lib/livebook_web/live/session_live/files_list_component.ex +++ b/lib/livebook_web/live/session_live/files_list_component.ex @@ -45,7 +45,12 @@ defmodule LivebookWeb.SessionLive.FilesListComponent do <%= file_entry.name %> <% else %> -
+
<.remix_icon icon={file_entry_icon(file_entry.type)} class="text-lg align-middle mr-2" /> <%= file_entry.name %>
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 8b7323061..bed9eba73 100644 --- a/lib/livebook_web/live/session_live/insert_buttons_component.ex +++ b/lib/livebook_web/live/session_live/insert_buttons_component.ex @@ -13,6 +13,15 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do aria-label="insert new" data-el-insert-buttons > +
+
@@ -103,25 +112,24 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do <.menu_item> - <.link - patch={ - ~p"/sessions/#{@session_id}/insert-image?section_id=#{@section_id}&cell_id=#{@cell_id || ""}" - } + - <%= if @code_block_definitions != [] do %> + <%= if @example_snippet_definitions != [] do %>
CODE
- <.menu_item :for={definition <- Enum.sort_by(@code_block_definitions, & &1.name)}> - <.code_block_insert_button + <.menu_item :for={definition <- @example_snippet_definitions}> + <.example_snippet_insert_button definition={definition} - runtime={@runtime} section_id={@section_id} cell_id={@cell_id} /> @@ -149,7 +157,7 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do <:toggle> - <.menu_item :for={definition <- Enum.sort_by(@smart_cell_definitions, & &1.name)}> + <.menu_item :for={definition <- @smart_cell_definitions}> <.smart_cell_insert_button definition={definition} section_id={@section_id} @@ -163,7 +171,7 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do """ end - defp code_block_insert_button(assigns) when is_many(assigns.definition.variants) do + defp example_snippet_insert_button(assigns) when is_many(assigns.definition.variants) do ~H""" <.submenu> <:primary> @@ -175,7 +183,7 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do <.menu_item :for={{variant, idx} <- Enum.with_index(@definition.variants)}> @@ -184,11 +192,11 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do """ end - defp code_block_insert_button(assigns) do + defp example_snippet_insert_button(assigns) do ~H"""