mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Add drag and drop actions for notebook files (#2096)
Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
parent
24827f5055
commit
146f89f5f5
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
28
lib/livebook_web/live/session_live/insert_file_component.ex
Normal file
28
lib/livebook_web/live/session_live/insert_file_component.ex
Normal 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
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in a new issue