Packages
livebook
0.7.2
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.14.0-rc.1
0.14.0-rc.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
Automate code & data workflows with interactive notebooks
Current section
Files
Jump to
Current section
Files
lib/livebook_web/live/file_select_component.ex
defmodule LivebookWeb.FileSelectComponent do
use LivebookWeb, :live_component
# The component expects:
#
# * `file` - the currently entered file
#
# * `running_files` - the list of notebook files that are already
# linked to running sessions
#
# * `extnames` - a list of file extensions that should be shown
#
# * `submit_event` - the process event sent on form submission,
# use `nil` for no action
#
# The parent live view receives a `{:set_file, file, %{exists: boolean()}}`
# message whenever the file changes.
#
# Optionally inner block may be passed (e.g. with action buttons)
# and it's rendered next to the text input.
#
# To force the component to refetch the displayed files you can
# `send_update` with `force_reload: true` to the component.
import LivebookWeb.FileSystemHelpers
alias Livebook.FileSystem
@impl true
def mount(socket) do
{:ok,
socket
|> assign_new(:inner_block, fn -> nil end)
|> assign(
# Component default attribute values
inner_block: nil,
file_system_select_disabled: false,
submit_event: nil,
# State
current_dir: nil,
deleting_file: nil,
renaming_file: nil,
renamed_name: nil,
error_message: nil,
file_systems: Livebook.Settings.file_systems()
)
|> allow_upload(:folder,
accept: :any,
auto_upload: true,
max_entries: 1,
progress: &handle_progress/3
)}
end
@impl true
def update(assigns, socket) do
{force_reload?, assigns} = Map.pop(assigns, :force_reload, false)
running_files_changed? = assigns.running_files != (socket.assigns[:running_files] || [])
socket =
socket
|> assign(assigns)
|> update_file_infos(force_reload? or running_files_changed?)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="h-full flex flex-col">
<h2 class="sr-only">File system</h2>
<div class="flex space-x-3 items-center mb-4">
<div class="grow flex space-x-1">
<.file_system_menu_button
file={@file}
file_systems={@file_systems}
file_system_select_disabled={@file_system_select_disabled}
socket={@socket}
myself={@myself}
/>
<form
class="grow"
phx-change="set_path"
phx-submit={if @submit_event, do: "submit"}
onsubmit={unless @submit_event, do: "return false"}
phx-target={@myself}
>
<input
class="input"
id="input-path"
aria-label="file path"
phx-hook="FocusOnUpdate"
type="text"
name="path"
placeholder="File"
value={@file.path}
spellcheck="false"
autocomplete="off"
/>
</form>
</div>
<span
class={"tooltip #{if(@inner_block, do: "top", else: "left")}"}
data-tooltip="New directory"
>
<button
class="icon-button"
tabindex="-1"
aria-label="new directory"
phx-click={js_show_new_dir_section()}
>
<.remix_icon icon="add-line" class="text-xl" />
</button>
</span>
<%= if @inner_block do %>
<div>
<%= render_slot(@inner_block) %>
</div>
<% end %>
</div>
<div class="flex flex-col space-y-2">
<%= if @error_message do %>
<div class="error-box flex justify-between items-center">
<span><%= @error_message %></span>
<button phx-click="clear_error" phx-target={@myself}>
<.remix_icon icon="delete-bin-6-line" class="text-lg align-middle" />
</button>
</div>
<% end %>
<%= if @deleting_file do %>
<div class="mb-4 px-4 py-3 flex space-x-4 items-center border border-gray-200 rounded-lg">
<p class="grow text-gray-700 text-sm">
Are you sure you want to irreversibly delete <span class="font-semibold"><%= @deleting_file.path %></span>?
</p>
<div class="flex space-x-4">
<button
class="text-red-600 font-medium text-sm whitespace-nowrap"
phx-click="do_delete_file"
phx-target={@myself}
>
<.remix_icon icon="delete-bin-6-line" class="align-middle mr-1" /> Delete
</button>
<button
class="text-gray-600 font-medium text-sm"
phx-click="cancel_delete_file"
phx-target={@myself}
>
Cancel
</button>
</div>
</div>
<% end %>
</div>
<div
class="hidden grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2 border-b border-dashed border-grey-200 mb-2 pb-2"
id="new_dir_section"
>
<div class="flex space-x-2 items-center p-2 rounded-lg">
<span class="block">
<.remix_icon icon="folder-add-fill" class="text-xl align-middle text-gray-400" />
</span>
<span class="flex font-medium text-gray-500">
<div phx-window-keydown={js_hide_new_dir_section()} phx-key="escape" phx-target={@myself}>
<input
id="new_dir_input"
aria-label="new directory"
type="text"
spellcheck="false"
autocomplete="off"
phx-blur={js_hide_new_dir_section()}
phx-window-keydown={
JS.push("create_dir", target: @myself) |> js_hide_new_dir_section()
}
phx-key="enter"
/>
</div>
</span>
</div>
</div>
<div
class="grow -m-1 p-1 h-full rounded-lg overflow-y-auto tiny-scrollbar"
tabindex="-1"
phx-hook="Dropzone"
id="upload-file-dropzone"
>
<form phx-change="file_validate" phx-drop-target={@uploads.folder.ref} phx-target={@myself}>
<%= live_file_input(@uploads.folder, class: "hidden", aria_labelledby: "import-from-file") %>
<%= if any_highlighted?(@file_infos) do %>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2 border-b border-dashed border-grey-200 mb-2 pb-2">
<%= for file_info <- @file_infos, file_info.highlighted != "" do %>
<.file
file_info={file_info}
myself={@myself}
renaming_file={@renaming_file}
renamed_name={@renamed_name}
/>
<% end %>
</div>
<% end %>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<%= for file_info <- @file_infos, file_info.highlighted == "" do %>
<.file
file_info={file_info}
myself={@myself}
renaming_file={@renaming_file}
renamed_name={@renamed_name}
/>
<% end %>
<%= if @uploads.folder.entries != [] do %>
<%= for file <- @uploads.folder.entries do %>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-400"><%= file.client_name %></span>
<button
type="button"
class="icon-button"
phx-click="clear-file"
phx-target={@myself}
tabindex="-1"
>
<.remix_icon icon="close-line" class="text-xl text-gray-300 hover:text-gray-500" />
</button>
</div>
<% end %>
<% end %>
</div>
</form>
</div>
</div>
"""
end
defp any_highlighted?(file_infos) do
Enum.any?(file_infos, &(&1.highlighted != ""))
end
defp file_system_menu_button(assigns) do
~H"""
<.menu id="file-system-menu" disabled={@file_system_select_disabled} position="bottom-left">
<:toggle>
<button
type="button"
class="button-base button-gray button-square-icon"
aria-label="switch file system"
disabled={@file_system_select_disabled}
>
<.file_system_icon file_system={@file.file_system} />
</button>
</:toggle>
<:content>
<%= for {file_system_id, file_system} <- @file_systems do %>
<%= if file_system == @file.file_system do %>
<button class="menu-item text-gray-900" role="menuitem">
<.file_system_icon file_system={file_system} />
<span class="font-medium"><%= file_system_label(file_system) %></span>
</button>
<% else %>
<button
class="menu-item text-gray-500"
role="menuitem"
phx-target={@myself}
phx-click="set_file_system"
phx-value-id={file_system_id}
>
<.file_system_icon file_system={file_system} />
<span class="font-medium"><%= file_system_label(file_system) %></span>
</button>
<% end %>
<% end %>
<%= live_redirect to: Routes.settings_path(@socket, :page),
class: "menu-item text-gray-500 border-t border-gray-200",
role: "menuitem" do %>
<.remix_icon icon="settings-3-line" />
<span class="font-medium">Configure</span>
<% end %>
</:content>
</.menu>
"""
end
defp file(%{file_info: %{file: file}, renaming_file: file} = assigns) do
~H"""
<div class="flex space-x-2 items-center p-2 rounded-lg">
<span class="block">
<.remix_icon icon="edit-line" class="text-xl align-middle text-gray-400" />
</span>
<span class="flex font-medium text-gray-500">
<div phx-window-keydown="cancel_rename_file" phx-key="escape" phx-target={@myself}>
<input
class="w-full"
type="text"
value={@renamed_name}
autofocus
spellcheck="false"
autocomplete="off"
phx-blur="cancel_rename_file"
phx-window-keydown="do_rename_file"
phx-key="enter"
phx-target={@myself}
/>
</div>
</span>
</div>
"""
end
defp file(assigns) do
icon =
case assigns.file_info do
%{is_running: true} -> "play-circle-line"
%{is_dir: true} -> "folder-fill"
_ -> "file-code-line"
end
assigns = assign(assigns, :icon, icon)
~H"""
<.menu id={"file-#{Base.encode16(@file_info.file.path)}"} secondary_click>
<:toggle>
<button
type="button"
class="w-full flex space-x-2 items-center p-2 rounded-lg hover:bg-gray-100 focus:ring-1 focus:ring-gray-400"
data-toggle
aria-label={"#{if @file_info.name == "..", do: "parent directory", else: @file_info.name}"}
phx-click="set_path"
phx-value-path={@file_info.file.path}
phx-target={@myself}
>
<span class="block">
<.remix_icon
icon={@icon}
class={
"text-xl align-middle #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-400")}"
}
/>
</span>
<span class={
"flex font-medium overflow-hidden whitespace-nowrap #{if(@file_info.is_running, do: "text-green-300", else: "text-gray-500")}"
}>
<%= if @file_info.highlighted != "" do %>
<span class={"font-medium
#{if(@file_info.unhighlighted == "", do: "overflow-hidden text-ellipsis")}
#{if(@file_info.is_running, do: "text-green-400", else: "text-gray-900")}"}>
<%= @file_info.highlighted %>
</span>
<% end %>
<span class="overflow-hidden text-ellipsis">
<%= @file_info.unhighlighted %>
</span>
</span>
</button>
</:toggle>
<:content>
<%= if @file_info.editable do %>
<button
type="button"
class="menu-item text-gray-500"
role="menuitem"
aria-label="rename file"
phx-click="rename_file"
phx-target={@myself}
phx-value-path={@file_info.file.path}
>
<.remix_icon icon="edit-line" />
<span class="font-medium">Rename</span>
</button>
<button
type="button"
class="menu-item text-red-600"
role="menuitem"
aria-label="delete file"
phx-click="delete_file"
phx-target={@myself}
phx-value-path={@file_info.file.path}
>
<.remix_icon icon="delete-bin-6-line" />
<span class="font-medium">Delete</span>
</button>
<% end %>
</:content>
</.menu>
"""
end
defp js_show_new_dir_section(js \\ %JS{}) do
js
|> JS.show(to: "#new_dir_section")
|> JS.dispatch("lb:set_value", to: "#new_dir_input", detail: %{value: ""})
|> JS.dispatch("lb:focus", to: "#new_dir_input")
end
defp js_hide_new_dir_section(js \\ %JS{}) do
js
|> JS.hide(to: "#new_dir_section")
end
defp handle_progress(:folder, entry, socket) when entry.done? do
consume_uploaded_entries(socket, :folder, fn %{path: file_path}, entry ->
content = File.read!(file_path)
file_path =
FileSystem.File.resolve(
socket.assigns.current_dir,
entry.client_name
)
FileSystem.File.write(file_path, content)
{:ok, :ok}
end)
{:noreply, update_file_infos(socket, true)}
end
defp handle_progress(:folder, _entry, socket) do
{:noreply, socket}
end
@impl true
def handle_event("file_validate", _, socket) do
{:noreply, socket}
end
def handle_event("set_file_system", %{"id" => file_system_id}, socket) do
{^file_system_id, file_system} =
Enum.find(socket.assigns.file_systems, fn {id, _file_system} ->
id == file_system_id
end)
file = FileSystem.File.new(file_system)
send(self(), {:set_file, file, %{exists: true}})
{:noreply, socket}
end
def handle_event("set_path", %{"path" => path}, socket) do
file =
socket.assigns.file.file_system
|> FileSystem.File.new()
|> FileSystem.File.resolve(path)
info =
socket.assigns.file_infos
|> Enum.find(&(&1.file.path == path))
|> case do
nil -> %{exists: false}
_info -> %{exists: true}
end
send(self(), {:set_file, file, info})
{:noreply, socket}
end
def handle_event("submit", %{}, socket) do
if submit_event = socket.assigns.submit_event do
send(self(), submit_event)
end
{:noreply, socket}
end
def handle_event("clear_error", %{}, socket) do
{:noreply, put_error(socket, nil)}
end
def handle_event("create_dir", %{"value" => name}, socket) do
socket =
case create_dir(socket.assigns.current_dir, name) do
:ok ->
socket
|> update_file_infos(true)
{:error, error} ->
socket
|> put_error(error)
end
{:noreply, socket}
end
def handle_event("delete_file", %{"path" => path}, socket) do
%{file: file} = Enum.find(socket.assigns.file_infos, &(&1.file.path == path))
{:noreply, assign(socket, deleting_file: file)}
end
def handle_event("cancel_delete_file", %{}, socket) do
{:noreply, assign(socket, deleting_file: nil)}
end
def handle_event("do_delete_file", %{}, socket) do
socket =
case delete_file(socket.assigns.deleting_file) do
:ok ->
socket
|> assign(deleting_file: nil)
|> update_file_infos(true)
{:error, error} ->
put_error(socket, error)
end
{:noreply, socket}
end
def handle_event("rename_file", %{"path" => path}, socket) do
file_info = Enum.find(socket.assigns.file_infos, &(&1.file.path == path))
{:noreply, assign(socket, renaming_file: file_info.file, renamed_name: file_info.name)}
end
def handle_event("cancel_rename_file", %{}, socket) do
{:noreply, assign(socket, renaming_file: nil)}
end
def handle_event("do_rename_file", %{"value" => name}, socket) do
socket =
if renaming_file = socket.assigns.renaming_file do
case rename_file(renaming_file, name) do
:ok ->
socket
|> assign(renaming_file: nil)
|> update_file_infos(true)
{:error, error} ->
socket
|> assign(renamed_name: name)
|> put_error(error)
end
else
socket
end
{:noreply, socket}
end
def handle_event("clear-file", %{}, socket) do
{socket, _entries} = Phoenix.LiveView.Upload.maybe_cancel_uploads(socket)
{:noreply, assign(socket, error: false)}
end
defp update_file_infos(%{assigns: assigns} = socket, force_reload?) do
current_file_infos = assigns[:file_infos] || []
{dir, prefix} = dir_and_prefix(assigns.file)
{file_infos, socket} =
if dir != assigns.current_dir or force_reload? do
case get_file_infos(dir, assigns.extnames, assigns.running_files) do
{:ok, file_infos} ->
{file_infos, assign(socket, :current_dir, dir)}
{:error, error} ->
{current_file_infos, put_error(socket, error)}
end
else
{current_file_infos, socket}
end
assign(socket, :file_infos, annotate_matching(file_infos, prefix))
end
defp annotate_matching(file_infos, prefix) do
for %{name: name} = info <- file_infos do
if String.starts_with?(name, prefix) do
%{info | highlighted: prefix, unhighlighted: String.replace_prefix(name, prefix, "")}
else
%{info | highlighted: "", unhighlighted: name}
end
end
end
# Phrase after the last slash is used as a search prefix within
# the given directory.
#
# Given "/foo/bar", we use "bar" to filter files within "/foo/".
# Given "/foo/bar/", we use "" to filter files within "/foo/bar/".
defp dir_and_prefix(file) do
if FileSystem.File.dir?(file) do
{file, ""}
else
{FileSystem.File.containing_dir(file), FileSystem.File.name(file)}
end
end
defp get_file_infos(dir, extnames, running_files) do
with {:ok, files} <- FileSystem.File.list(dir) do
file_infos =
files
|> Enum.map(fn file ->
name = FileSystem.File.name(file)
file_info(file, name, running_files)
end)
|> Enum.filter(fn info ->
not hidden?(info.name) and (info.is_dir or valid_extension?(info.name, extnames))
end)
|> Kernel.++(
case FileSystem.File.containing_dir(dir) do
^dir -> []
parent -> [file_info(parent, "..", running_files, editable: false)]
end
)
|> Enum.sort_by(fn file -> {!file.is_dir, file.name} end)
{:ok, file_infos}
end
end
defp file_info(file, name, running_files, opts \\ []) do
%{
name: name,
highlighted: "",
unhighlighted: name,
file: file,
is_dir: FileSystem.File.dir?(file),
is_running: file in running_files,
editable: Keyword.get(opts, :editable, true)
}
end
defp hidden?(filename) do
String.starts_with?(filename, ".")
end
defp valid_extension?(filename, extnames) do
Path.extname(filename) in extnames
end
defp put_error(socket, nil) do
assign(socket, :error_message, nil)
end
defp put_error(socket, :ignore) do
socket
end
defp put_error(socket, message) when is_binary(message) do
assign(socket, :error_message, Livebook.Utils.upcase_first(message))
end
defp create_dir(_parent_dir, ""), do: {:error, :ignore}
defp create_dir(parent_dir, name) do
new_dir = FileSystem.File.resolve(parent_dir, name <> "/")
FileSystem.File.create_dir(new_dir)
end
defp delete_file(file) do
FileSystem.File.remove(file)
end
defp rename_file(_file, ""), do: {:error, :ignore}
defp rename_file(file, name) do
parent_dir = FileSystem.File.containing_dir(file)
new_name = if FileSystem.File.dir?(file), do: name <> "/", else: name
new_file = FileSystem.File.resolve(parent_dir, new_name)
FileSystem.File.rename(file, new_file)
end
end