mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-03 02:04:30 +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
17 changed files with 537 additions and 164 deletions
|
@ -14,7 +14,7 @@ solely client-side operations.
|
||||||
}
|
}
|
||||||
|
|
||||||
[phx-hook="Dropzone"][data-js-dragging] {
|
[phx-hook="Dropzone"][data-js-dragging] {
|
||||||
@apply bg-yellow-100 border-yellow-300;
|
@apply bg-blue-100 border-blue-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Session === */
|
/* === Session === */
|
||||||
|
@ -73,6 +73,10 @@ solely client-side operations.
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-el-session]:not([data-js-dragging]) [data-el-insert-drop-area] {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
[data-el-cell][data-js-focused] {
|
[data-el-cell][data-js-focused] {
|
||||||
@apply border-blue-300 border-opacity-100;
|
@apply border-blue-300 border-opacity-100;
|
||||||
}
|
}
|
||||||
|
|
|
@ -142,6 +142,8 @@ const Session = {
|
||||||
(event) => this.toggleCollapseAllSections()
|
(event) => this.toggleCollapseAllSections()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.initializeDragAndDrop();
|
||||||
|
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
"phx:page-loading-stop",
|
"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)
|
// User action handlers (mostly keybindings)
|
||||||
|
|
||||||
toggleSectionsList() {
|
toggleSectionsList() {
|
||||||
|
|
|
@ -843,8 +843,10 @@ defmodule Livebook.Notebook do
|
||||||
"""
|
"""
|
||||||
@spec validate_file_entry_name(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
|
@spec validate_file_entry_name(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
|
||||||
def validate_file_entry_name(changeset, field) do
|
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"
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -273,15 +273,17 @@ defprotocol Livebook.Runtime do
|
||||||
requirement_presets:
|
requirement_presets:
|
||||||
list(%{
|
list(%{
|
||||||
name: String.t(),
|
name: String.t(),
|
||||||
packages: list(%{name: String.t(), dependency: dependency()})
|
packages: list(package())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@type package :: %{name: String.t(), dependency: dependency()}
|
||||||
|
|
||||||
@type dependency :: term()
|
@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(),
|
name: String.t(),
|
||||||
version: String.t(),
|
version: String.t(),
|
||||||
description: String.t() | nil,
|
description: String.t() | nil,
|
||||||
|
@ -290,19 +292,46 @@ defprotocol Livebook.Runtime do
|
||||||
}
|
}
|
||||||
|
|
||||||
@typedoc """
|
@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(),
|
name: String.t(),
|
||||||
icon: String.t(),
|
icon: String.t(),
|
||||||
variants:
|
variants:
|
||||||
list(%{
|
list(%{
|
||||||
name: String.t(),
|
name: String.t(),
|
||||||
source: 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 """
|
@typedoc """
|
||||||
A JavaScript view definition.
|
A JavaScript view definition.
|
||||||
|
|
||||||
|
@ -615,10 +644,10 @@ defprotocol Livebook.Runtime do
|
||||||
def has_dependencies?(runtime, dependencies)
|
def has_dependencies?(runtime, dependencies)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of predefined code blocks.
|
Returns a list of predefined code snippets.
|
||||||
"""
|
"""
|
||||||
@spec code_block_definitions(t()) :: list(code_block_definition())
|
@spec snippet_definitions(t()) :: list(snippet_definition())
|
||||||
def code_block_definitions(runtime)
|
def snippet_definitions(runtime)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Looks up packages matching the given search.
|
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)
|
RuntimeServer.has_dependencies?(runtime.server_pid, dependencies)
|
||||||
end
|
end
|
||||||
|
|
||||||
def code_block_definitions(_runtime) do
|
def snippet_definitions(_runtime) do
|
||||||
Livebook.Runtime.Definitions.code_block_definitions()
|
Livebook.Runtime.Definitions.snippet_definitions()
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_packages(_runtime, _send_to, _search) do
|
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: []}
|
dependency: %{dep: {:kino_explorer, "~> 0.1.4"}, config: []}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jason = %{
|
||||||
|
name: "jason",
|
||||||
|
dependency: %{dep: {:jason, "~> 1.4"}, config: []}
|
||||||
|
}
|
||||||
|
|
||||||
windows? = match?({:win32, _}, :os.type())
|
windows? = match?({:win32, _}, :os.type())
|
||||||
nx_backend_package = if(windows?, do: torchx, else: exla)
|
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",
|
name: "Form",
|
||||||
icon: "bill-line",
|
icon: "bill-line",
|
||||||
variants: [
|
variants: [
|
||||||
|
@ -184,10 +191,47 @@ defmodule Livebook.Runtime.Definitions do
|
||||||
packages: [kino]
|
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 smart_cell_definitions(), do: @smart_cell_definitions
|
||||||
|
|
||||||
def code_block_definitions(), do: @code_block_definitions
|
def snippet_definitions(), do: @snippet_definitions
|
||||||
end
|
end
|
||||||
|
|
|
@ -255,7 +255,7 @@ defmodule Livebook.Runtime.Dependencies do
|
||||||
through the given list of packages.
|
through the given list of packages.
|
||||||
"""
|
"""
|
||||||
@spec search_packages_in_list(
|
@spec search_packages_in_list(
|
||||||
list(Livebook.Runtime.package()),
|
list(Livebook.Runtime.package_details()),
|
||||||
pid(),
|
pid(),
|
||||||
String.t()
|
String.t()
|
||||||
) :: reference()
|
) :: reference()
|
||||||
|
|
|
@ -167,8 +167,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
|
||||||
RuntimeServer.has_dependencies?(runtime.server_pid, dependencies)
|
RuntimeServer.has_dependencies?(runtime.server_pid, dependencies)
|
||||||
end
|
end
|
||||||
|
|
||||||
def code_block_definitions(_runtime) do
|
def snippet_definitions(_runtime) do
|
||||||
Livebook.Runtime.Definitions.code_block_definitions()
|
Livebook.Runtime.Definitions.snippet_definitions()
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_packages(_runtime, send_to, search) do
|
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)
|
RuntimeServer.has_dependencies?(runtime.server_pid, dependencies)
|
||||||
end
|
end
|
||||||
|
|
||||||
def code_block_definitions(_runtime) do
|
def snippet_definitions(_runtime) do
|
||||||
Livebook.Runtime.Definitions.code_block_definitions()
|
Livebook.Runtime.Definitions.snippet_definitions()
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_packages(_runtime, send_to, search) do
|
def search_packages(_runtime, send_to, search) do
|
||||||
|
|
|
@ -410,7 +410,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
client_id={@client_id}
|
client_id={@client_id}
|
||||||
runtime={@data_view.runtime}
|
runtime={@data_view.runtime}
|
||||||
smart_cell_definitions={@data_view.smart_cell_definitions}
|
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?}
|
installing?={@data_view.installing?}
|
||||||
allowed_uri_schemes={@allowed_uri_schemes}
|
allowed_uri_schemes={@allowed_uri_schemes}
|
||||||
section_view={section_view}
|
section_view={section_view}
|
||||||
|
@ -527,6 +527,22 @@ defmodule LivebookWeb.SessionLive do
|
||||||
/>
|
/>
|
||||||
</.modal>
|
</.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}>
|
<.modal :if={@live_action == :bin} id="bin-modal" show width={:big} patch={@self_path}>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={LivebookWeb.SessionLive.BinComponent}
|
module={LivebookWeb.SessionLive.BinComponent}
|
||||||
|
@ -925,15 +941,16 @@ defmodule LivebookWeb.SessionLive do
|
||||||
{:noreply, assign(socket, cell: cell)}
|
{:noreply, assign(socket, cell: cell)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_params(%{"section_id" => section_id, "cell_id" => cell_id}, _url, socket)
|
def handle_params(%{}, _url, socket)
|
||||||
when socket.assigns.live_action == :insert_image do
|
when socket.assigns.live_action == :insert_image and
|
||||||
cell_id =
|
not is_map_key(socket.assigns, :insert_image_metadata) do
|
||||||
case cell_id do
|
{:noreply, redirect_to_self(socket)}
|
||||||
"" -> nil
|
end
|
||||||
id -> id
|
|
||||||
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
|
end
|
||||||
|
|
||||||
def handle_params(%{"path_parts" => path_parts}, requested_url, socket)
|
def handle_params(%{"path_parts" => path_parts}, requested_url, socket)
|
||||||
|
@ -1015,43 +1032,33 @@ defmodule LivebookWeb.SessionLive do
|
||||||
{:noreply, insert_cell_below(socket, params)}
|
{:noreply, insert_cell_below(socket, params)}
|
||||||
end
|
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
|
data = socket.private.data
|
||||||
%{"section_id" => section_id, "cell_id" => cell_id} = params
|
%{"section_id" => section_id, "cell_id" => cell_id} = params
|
||||||
|
|
||||||
case code_block_definition_by_name(data, params["definition_name"]) do
|
if Livebook.Runtime.connected?(socket.private.data.runtime) do
|
||||||
{:ok, definition} ->
|
case example_snippet_definition_by_name(data, params["definition_name"]) do
|
||||||
variant = Enum.fetch!(definition.variants, params["variant_idx"])
|
{:ok, definition} ->
|
||||||
dependencies = Enum.map(variant.packages, & &1.dependency)
|
variant = Enum.fetch!(definition.variants, params["variant_idx"])
|
||||||
|
|
||||||
has_dependencies? =
|
socket =
|
||||||
dependencies == [] or Livebook.Runtime.has_dependencies?(data.runtime, dependencies)
|
ensure_packages_then(socket, variant.packages, definition.name, "block", fn socket ->
|
||||||
|
with {:ok, section, index} <-
|
||||||
cond do
|
section_with_next_index(socket.private.data.notebook, section_id, cell_id) do
|
||||||
has_dependencies? ->
|
attrs = %{source: variant.source}
|
||||||
insert_code_block_below(socket, variant, section_id, cell_id)
|
Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs)
|
||||||
{:noreply, socket}
|
{:ok, 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
|
|
||||||
end
|
end
|
||||||
end
|
end)
|
||||||
|
|
||||||
socket =
|
{:noreply, socket}
|
||||||
confirm_add_packages(socket, on_confirm, variant.packages, definition.name, "block")
|
|
||||||
|
|
||||||
{:noreply, socket}
|
_ ->
|
||||||
end
|
{:noreply, socket}
|
||||||
|
end
|
||||||
_ ->
|
else
|
||||||
{:noreply, socket}
|
reason = "To insert this block, you need a connected runtime."
|
||||||
|
{:noreply, confirm_setup_default_runtime(socket, reason)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1066,32 +1073,19 @@ defmodule LivebookWeb.SessionLive do
|
||||||
Enum.at(definition.requirement_presets, preset_idx)
|
Enum.at(definition.requirement_presets, preset_idx)
|
||||||
end
|
end
|
||||||
|
|
||||||
if preset == nil do
|
packages = if(preset, do: preset.packages, else: [])
|
||||||
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)
|
|
||||||
|
|
||||||
: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
|
end)
|
||||||
|
|
||||||
socket =
|
{:noreply, 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
|
end
|
||||||
|
|
||||||
def handle_event("setup_default_runtime", %{"reason" => reason}, socket) do
|
def handle_event("setup_default_runtime", %{"reason" => reason}, socket) do
|
||||||
on_confirm = fn socket ->
|
{:noreply, confirm_setup_default_runtime(socket, reason)}
|
||||||
{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
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("disconnect_runtime", %{}, socket) do
|
def handle_event("disconnect_runtime", %{}, socket) do
|
||||||
|
@ -1499,9 +1476,7 @@ defmodule LivebookWeb.SessionLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("review_file_entry_access", %{"name" => name}, socket) do
|
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 = find_file_entry(socket, name) do
|
||||||
|
|
||||||
if file_entry do
|
|
||||||
on_confirm = fn socket ->
|
on_confirm = fn socket ->
|
||||||
Session.allow_file_entry(socket.assigns.session.pid, file_entry.name)
|
Session.allow_file_entry(socket.assigns.session.pid, file_entry.name)
|
||||||
socket
|
socket
|
||||||
|
@ -1533,6 +1508,77 @@ defmodule LivebookWeb.SessionLive do
|
||||||
end
|
end
|
||||||
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
|
@impl true
|
||||||
def handle_info({:operation, operation}, socket) do
|
def handle_info({:operation, operation}, socket) do
|
||||||
{:noreply, handle_operation(socket, operation)}
|
{:noreply, handle_operation(socket, operation)}
|
||||||
|
@ -2164,14 +2210,34 @@ defmodule LivebookWeb.SessionLive do
|
||||||
end
|
end
|
||||||
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
|
defp starred_files(starred_notebooks) do
|
||||||
for info <- starred_notebooks, into: MapSet.new(), do: info.file
|
for info <- starred_notebooks, into: MapSet.new(), do: info.file
|
||||||
end
|
end
|
||||||
|
|
||||||
defp code_block_definition_by_name(data, name) do
|
defp example_snippet_definition_by_name(data, name) do
|
||||||
data.runtime
|
data.runtime
|
||||||
|> Livebook.Runtime.code_block_definitions()
|
|> Livebook.Runtime.snippet_definitions()
|
||||||
|> Enum.find_value(:error, &(&1.name == name && {:ok, &1}))
|
|> Enum.find_value(:error, &(&1.type == :example && &1.name == name && {:ok, &1}))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp smart_cell_definition_by_kind(data, kind) do
|
defp smart_cell_definition_by_kind(data, kind) do
|
||||||
|
@ -2191,6 +2257,35 @@ defmodule LivebookWeb.SessionLive do
|
||||||
socket
|
socket
|
||||||
end
|
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
|
defp confirm_add_packages(socket, on_confirm, packages, target_name, target_type) do
|
||||||
assigns = %{packages: packages, target_name: target_name, target_type: target_type}
|
assigns = %{packages: packages, target_name: target_name, target_type: target_type}
|
||||||
|
|
||||||
|
@ -2212,24 +2307,6 @@ defmodule LivebookWeb.SessionLive do
|
||||||
)
|
)
|
||||||
end
|
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
|
defp push_cell_editor_payloads(socket, data, cells, tags \\ :all) do
|
||||||
for cell <- cells,
|
for cell <- cells,
|
||||||
{tag, payload} <- cell_editor_init_payloads(cell, data.cell_infos[cell.id]),
|
{tag, payload} <- cell_editor_init_payloads(cell, data.cell_infos[cell.id]),
|
||||||
|
@ -2299,6 +2376,57 @@ defmodule LivebookWeb.SessionLive do
|
||||||
end)
|
end)
|
||||||
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: "",
|
||||||
|
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
|
# Builds view-specific structure of data by cherry-picking
|
||||||
# only the relevant attributes.
|
# only the relevant attributes.
|
||||||
# We then use `@data_view` in the templates and consequently
|
# We then use `@data_view` in the templates and consequently
|
||||||
|
@ -2315,8 +2443,12 @@ defmodule LivebookWeb.SessionLive do
|
||||||
dirty: data.dirty,
|
dirty: data.dirty,
|
||||||
persistence_warnings: data.persistence_warnings,
|
persistence_warnings: data.persistence_warnings,
|
||||||
runtime: data.runtime,
|
runtime: data.runtime,
|
||||||
smart_cell_definitions: data.smart_cell_definitions,
|
smart_cell_definitions: Enum.sort_by(data.smart_cell_definitions, & &1.name),
|
||||||
code_block_definitions: Livebook.Runtime.code_block_definitions(data.runtime),
|
example_snippet_definitions:
|
||||||
|
data.runtime
|
||||||
|
|> Livebook.Runtime.snippet_definitions()
|
||||||
|
|> Enum.filter(&(&1.type == :example))
|
||||||
|
|> Enum.sort_by(& &1.name),
|
||||||
global_status: global_status(data),
|
global_status: global_status(data),
|
||||||
notebook_name: data.notebook.name,
|
notebook_name: data.notebook.name,
|
||||||
sections_items:
|
sections_items:
|
||||||
|
|
|
@ -45,7 +45,12 @@ defmodule LivebookWeb.SessionLive.FilesListComponent do
|
||||||
<span class="break-all"><%= file_entry.name %></span>
|
<span class="break-all"><%= file_entry.name %></span>
|
||||||
</button>
|
</button>
|
||||||
<% else %>
|
<% 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" />
|
<.remix_icon icon={file_entry_icon(file_entry.type)} class="text-lg align-middle mr-2" />
|
||||||
<span class="break-all"><%= file_entry.name %></span>
|
<span class="break-all"><%= file_entry.name %></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,6 +13,15 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
||||||
aria-label="insert new"
|
aria-label="insert new"
|
||||||
data-el-insert-buttons
|
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={
|
<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"
|
"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>
|
</button>
|
||||||
</.menu_item>
|
</.menu_item>
|
||||||
<.menu_item>
|
<.menu_item>
|
||||||
<.link
|
<button
|
||||||
patch={
|
phx-click="insert_image"
|
||||||
~p"/sessions/#{@session_id}/insert-image?section_id=#{@section_id}&cell_id=#{@cell_id || ""}"
|
phx-value-section_id={@section_id}
|
||||||
}
|
phx-value-cell_id={@cell_id}
|
||||||
aria-label="insert image"
|
aria-label="insert image"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<.remix_icon icon="image-add-line" />
|
<.remix_icon icon="image-add-line" />
|
||||||
<span>Image</span>
|
<span>Image</span>
|
||||||
</.link>
|
</button>
|
||||||
</.menu_item>
|
</.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">
|
<div class="flex items-center mt-4 mb-1 px-5 text-xs text-gray-400 font-light">
|
||||||
CODE
|
CODE
|
||||||
</div>
|
</div>
|
||||||
<.menu_item :for={definition <- Enum.sort_by(@code_block_definitions, & &1.name)}>
|
<.menu_item :for={definition <- @example_snippet_definitions}>
|
||||||
<.code_block_insert_button
|
<.example_snippet_insert_button
|
||||||
definition={definition}
|
definition={definition}
|
||||||
runtime={@runtime}
|
|
||||||
section_id={@section_id}
|
section_id={@section_id}
|
||||||
cell_id={@cell_id}
|
cell_id={@cell_id}
|
||||||
/>
|
/>
|
||||||
|
@ -149,7 +157,7 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
||||||
<:toggle>
|
<:toggle>
|
||||||
<button class="button-base button-small">+ Smart</button>
|
<button class="button-base button-small">+ Smart</button>
|
||||||
</:toggle>
|
</:toggle>
|
||||||
<.menu_item :for={definition <- Enum.sort_by(@smart_cell_definitions, & &1.name)}>
|
<.menu_item :for={definition <- @smart_cell_definitions}>
|
||||||
<.smart_cell_insert_button
|
<.smart_cell_insert_button
|
||||||
definition={definition}
|
definition={definition}
|
||||||
section_id={@section_id}
|
section_id={@section_id}
|
||||||
|
@ -163,7 +171,7 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
||||||
"""
|
"""
|
||||||
end
|
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"""
|
~H"""
|
||||||
<.submenu>
|
<.submenu>
|
||||||
<:primary>
|
<:primary>
|
||||||
|
@ -175,7 +183,7 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
||||||
<.menu_item :for={{variant, idx} <- Enum.with_index(@definition.variants)}>
|
<.menu_item :for={{variant, idx} <- Enum.with_index(@definition.variants)}>
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
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>
|
<span><%= variant.name %></span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -184,11 +192,11 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp code_block_insert_button(assigns) do
|
defp example_snippet_insert_button(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
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} />
|
<.remix_icon icon={@definition.icon} />
|
||||||
<span><%= @definition.name %></span>
|
<span><%= @definition.name %></span>
|
||||||
|
@ -224,21 +232,15 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp on_code_block_click(definition, variant_idx, runtime, section_id, cell_id) do
|
defp on_example_snippet_click(definition, variant_idx, section_id, cell_id) do
|
||||||
if Livebook.Runtime.connected?(runtime) do
|
JS.push("insert_example_snippet_below",
|
||||||
JS.push("insert_code_block_below",
|
value: %{
|
||||||
value: %{
|
definition_name: definition.name,
|
||||||
definition_name: definition.name,
|
variant_idx: variant_idx,
|
||||||
variant_idx: variant_idx,
|
section_id: section_id,
|
||||||
section_id: section_id,
|
cell_id: cell_id
|
||||||
cell_id: cell_id
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
else
|
|
||||||
JS.push("setup_default_runtime",
|
|
||||||
value: %{reason: "To insert this block, you need a connected runtime."}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp on_smart_cell_click(definition, section_id, cell_id) do
|
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"}
|
id={"insert-buttons-#{@section_view.id}-first"}
|
||||||
persistent={@section_view.cell_views == []}
|
persistent={@section_view.cell_views == []}
|
||||||
smart_cell_definitions={@smart_cell_definitions}
|
smart_cell_definitions={@smart_cell_definitions}
|
||||||
code_block_definitions={@code_block_definitions}
|
example_snippet_definitions={@example_snippet_definitions}
|
||||||
runtime={@runtime}
|
runtime={@runtime}
|
||||||
section_id={@section_view.id}
|
section_id={@section_view.id}
|
||||||
cell_id={nil}
|
cell_id={nil}
|
||||||
|
@ -189,7 +189,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
||||||
id={"insert-buttons-#{@section_view.id}-#{index}"}
|
id={"insert-buttons-#{@section_view.id}-#{index}"}
|
||||||
persistent={false}
|
persistent={false}
|
||||||
smart_cell_definitions={@smart_cell_definitions}
|
smart_cell_definitions={@smart_cell_definitions}
|
||||||
code_block_definitions={@code_block_definitions}
|
example_snippet_definitions={@example_snippet_definitions}
|
||||||
runtime={@runtime}
|
runtime={@runtime}
|
||||||
section_id={@section_view.id}
|
section_id={@section_view.id}
|
||||||
cell_id={cell_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/export/:tab", SessionLive, :export
|
||||||
live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings
|
live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings
|
||||||
live "/sessions/:id/insert-image", SessionLive, :insert_image
|
live "/sessions/:id/insert-image", SessionLive, :insert_image
|
||||||
|
live "/sessions/:id/insert-file", SessionLive, :insert_file
|
||||||
live "/sessions/:id/package-search", SessionLive, :package_search
|
live "/sessions/:id/package-search", SessionLive, :package_search
|
||||||
get "/sessions/:id/files/:name", SessionController, :show_file
|
get "/sessions/:id/files/:name", SessionController, :show_file
|
||||||
get "/sessions/:id/images/:name", SessionController, :show_image
|
get "/sessions/:id/images/:name", SessionController, :show_image
|
||||||
|
|
|
@ -244,11 +244,13 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
section_id = insert_section(session.pid)
|
section_id = insert_section(session.pid)
|
||||||
cell_id = insert_text_cell(session.pid, section_id, :code)
|
cell_id = insert_text_cell(session.pid, section_id, :code)
|
||||||
|
|
||||||
{:ok, view, _} =
|
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
|
||||||
live(
|
|
||||||
conn,
|
view
|
||||||
~p"/sessions/#{session.id}/insert-image?section_id=#{section_id}&cell_id=#{cell_id}"
|
|> element(
|
||||||
)
|
~s/[phx-click="insert_image"][phx-value-section_id="#{section_id}"][phx-value-cell_id="#{cell_id}"]/
|
||||||
|
)
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> file_input(~s/#insert-image-modal form/, :image, [
|
|> file_input(~s/#insert-image-modal form/, :image, [
|
||||||
|
@ -279,6 +281,87 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
{:ok, "content"}
|
{:ok, "content"}
|
||||||
end
|
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: ""}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} = Session.get_data(session.pid)
|
||||||
|
end
|
||||||
|
|
||||||
test "deleting section with no cells requires no confirmation",
|
test "deleting section with no cells requires no confirmation",
|
||||||
%{conn: conn, session: session} do
|
%{conn: conn, session: session} do
|
||||||
section_id = insert_section(session.pid)
|
section_id = insert_section(session.pid)
|
||||||
|
|
|
@ -55,7 +55,9 @@ defmodule Livebook.Runtime.NoopRuntime do
|
||||||
|
|
||||||
def has_dependencies?(_runtime, _dependencies), do: true
|
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()
|
def search_packages(_, _, _), do: make_ref()
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue