Add drag and drop actions for notebook files (#2096)

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2023-07-21 20:11:11 +02:00 committed by GitHub
parent 24827f5055
commit 146f89f5f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 537 additions and 164 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,7 +45,12 @@ defmodule LivebookWeb.SessionLive.FilesListComponent do
<span class="break-all"><%= file_entry.name %></span>
</button>
<% else %>
<div class="flex items-center text-gray-500">
<div
class="flex items-center text-gray-500 cursor-grab"
draggable="true"
data-el-file-entry
data-name={file_entry.name}
>
<.remix_icon icon={file_entry_icon(file_entry.type)} class="text-lg align-middle mr-2" />
<span class="break-all"><%= file_entry.name %></span>
</div>

View file

@ -13,6 +13,15 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
aria-label="insert new"
data-el-insert-buttons
>
<div
class="absolute inset-0 h-[30px] z-[100] bg-white rounded-lg border-2 border-dashed border-gray-400"
data-el-insert-drop-area
data-section-id={@section_id}
data-cell-id={@cell_id}
id={"cell-#{@id}-dropzone"}
phx-hook="Dropzone"
>
</div>
<div class={
"w-full md:absolute z-10 hover:z-[11] #{if(@persistent, do: "opacity-100", else: "opacity-0")} hover:opacity-100 focus-within:opacity-100 flex space-x-2 justify-center items-center"
}>
@ -103,25 +112,24 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
</button>
</.menu_item>
<.menu_item>
<.link
patch={
~p"/sessions/#{@session_id}/insert-image?section_id=#{@section_id}&cell_id=#{@cell_id || ""}"
}
<button
phx-click="insert_image"
phx-value-section_id={@section_id}
phx-value-cell_id={@cell_id}
aria-label="insert image"
role="menuitem"
>
<.remix_icon icon="image-add-line" />
<span>Image</span>
</.link>
</button>
</.menu_item>
<%= if @code_block_definitions != [] do %>
<%= if @example_snippet_definitions != [] do %>
<div class="flex items-center mt-4 mb-1 px-5 text-xs text-gray-400 font-light">
CODE
</div>
<.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>
<button class="button-base button-small">+ Smart</button>
</: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)}>
<button
role="menuitem"
phx-click={on_code_block_click(@definition, idx, @runtime, @section_id, @cell_id)}
phx-click={on_example_snippet_click(@definition, idx, @section_id, @cell_id)}
>
<span><%= variant.name %></span>
</button>
@ -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"""
<button
role="menuitem"
phx-click={on_code_block_click(@definition, 0, @runtime, @section_id, @cell_id)}
phx-click={on_example_snippet_click(@definition, 0, @section_id, @cell_id)}
>
<.remix_icon icon={@definition.icon} />
<span><%= @definition.name %></span>
@ -224,21 +232,15 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
"""
end
defp on_code_block_click(definition, variant_idx, runtime, section_id, cell_id) do
if Livebook.Runtime.connected?(runtime) do
JS.push("insert_code_block_below",
value: %{
definition_name: definition.name,
variant_idx: variant_idx,
section_id: section_id,
cell_id: cell_id
}
)
else
JS.push("setup_default_runtime",
value: %{reason: "To insert this block, you need a connected runtime."}
)
end
defp on_example_snippet_click(definition, variant_idx, section_id, cell_id) do
JS.push("insert_example_snippet_below",
value: %{
definition_name: definition.name,
variant_idx: variant_idx,
section_id: section_id,
cell_id: cell_id
}
)
end
defp on_smart_cell_click(definition, section_id, cell_id) do

View file

@ -0,0 +1,28 @@
defmodule LivebookWeb.SessionLive.InsertFileComponent do
use LivebookWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div class="p-6">
<h3 class="text-2xl font-semibold text-gray-800">
Insert file
</h3>
<p class="mt-8 text-gray-700">
What do you want to do with the file?
</p>
<div class="mt-4 w-full flex flex-col space-y-4">
<div
:for={{handler, idx} <- Enum.with_index(@insert_file_metadata.handlers)}
class="px-4 py-3 border border-gray-200 rounded-xl text-gray-800 pointer hover:bg-gray-50 cursor-pointer"
phx-click={JS.push("insert_file_action", value: %{idx: idx})}
>
<span>
<%= handler.definition.description %>
</span>
</div>
</div>
</div>
"""
end
end

View file

@ -165,7 +165,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
id={"insert-buttons-#{@section_view.id}-first"}
persistent={@section_view.cell_views == []}
smart_cell_definitions={@smart_cell_definitions}
code_block_definitions={@code_block_definitions}
example_snippet_definitions={@example_snippet_definitions}
runtime={@runtime}
section_id={@section_view.id}
cell_id={nil}
@ -189,7 +189,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
id={"insert-buttons-#{@section_view.id}-#{index}"}
persistent={false}
smart_cell_definitions={@smart_cell_definitions}
code_block_definitions={@code_block_definitions}
example_snippet_definitions={@example_snippet_definitions}
runtime={@runtime}
section_id={@section_view.id}
cell_id={cell_view.id}

View file

@ -92,6 +92,7 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/export/:tab", SessionLive, :export
live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings
live "/sessions/:id/insert-image", SessionLive, :insert_image
live "/sessions/:id/insert-file", SessionLive, :insert_file
live "/sessions/:id/package-search", SessionLive, :package_search
get "/sessions/:id/files/:name", SessionController, :show_file
get "/sessions/:id/images/:name", SessionController, :show_image

View file

@ -244,11 +244,13 @@ defmodule LivebookWeb.SessionLiveTest do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :code)
{:ok, view, _} =
live(
conn,
~p"/sessions/#{session.id}/insert-image?section_id=#{section_id}&cell_id=#{cell_id}"
)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
view
|> element(
~s/[phx-click="insert_image"][phx-value-section_id="#{section_id}"][phx-value-cell_id="#{cell_id}"]/
)
|> render_click()
view
|> file_input(~s/#insert-image-modal form/, :image, [
@ -279,6 +281,87 @@ defmodule LivebookWeb.SessionLiveTest do
{:ok, "content"}
end
test "inserting a file", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :code)
%{files_dir: files_dir} = session
image_file = FileSystem.File.resolve(files_dir, "file.bin")
:ok = FileSystem.File.write(image_file, "content")
Session.add_file_entries(session.pid, [%{type: :attachment, name: "file.bin"}])
{:ok, runtime} = Livebook.Runtime.NoopRuntime.new() |> Livebook.Runtime.connect()
Session.set_runtime(session.pid, runtime)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
view
|> element(~s{[data-el-session]})
|> render_hook("insert_file", %{
"file_entry_name" => "file.bin",
"section_id" => section_id,
"cell_id" => cell_id
})
view
|> element(~s/#insert-file-modal [phx-click]/, "Read file content")
|> render_click()
assert %{
notebook: %{
sections: [
%{
cells: [
_first_cell,
%Cell.Code{
source: """
content =
Kino.FS.file_path("file.bin")
|> File.read!()\
"""
}
]
}
]
}
} = Session.get_data(session.pid)
end
test "inserting a file as markdown image", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :code)
%{files_dir: files_dir} = session
image_file = FileSystem.File.resolve(files_dir, "image.jpg")
:ok = FileSystem.File.write(image_file, "content")
Session.add_file_entries(session.pid, [%{type: :attachment, name: "image.jpg"}])
{:ok, runtime} = Livebook.Runtime.NoopRuntime.new() |> Livebook.Runtime.connect()
Session.set_runtime(session.pid, runtime)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
view
|> element(~s{[data-el-session]})
|> render_hook("insert_file", %{
"file_entry_name" => "image.jpg",
"section_id" => section_id,
"cell_id" => cell_id
})
view
|> element(~s/#insert-file-modal [phx-click]/, "Insert as Markdown image")
|> render_click()
assert %{
notebook: %{
sections: [
%{cells: [_first_cell, %Cell.Markdown{source: "![](files/image.jpg)"}]}
]
}
} = Session.get_data(session.pid)
end
test "deleting section with no cells requires no confirmation",
%{conn: conn, session: session} do
section_id = insert_section(session.pid)

View file

@ -55,7 +55,9 @@ defmodule Livebook.Runtime.NoopRuntime do
def has_dependencies?(_runtime, _dependencies), do: true
def code_block_definitions(_runtime), do: []
def snippet_definitions(_runtime) do
Livebook.Runtime.Definitions.snippet_definitions()
end
def search_packages(_, _, _), do: make_ref()