defmodule LivebookWeb.FileSelectComponent do
use LivebookWeb, :live_component
# The component expects:
#
# * `:file` - the currently entered file
#
# * `:hub` - the hub to show file systems from. By default file
# systems from all hubs are available
#
# * `:running_files` - the list of notebook files that are already
# linked to running sessions
#
# * `:extnames` - a list of file extensions that should be shown
#
# * `:on_submit` - `%JS{}` to execute on form submission
#
# * `:target` - either a pid or `{component_module, id}` to send
# events to
#
# The target receives `{:set_file, file, %{exists: boolean()}}` event
# 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.FileSystemComponents
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,
on_submit: nil,
# State
current_dir: nil,
deleting_file: nil,
renaming_file: nil,
renamed_name: nil,
error_message: nil,
configure_path: nil,
file_systems: []
)
|> allow_upload(:folder,
accept: :any,
auto_upload: true,
max_entries: 1,
progress: &handle_progress/3,
writer: fn _name, entry, socket ->
file = FileSystem.File.resolve(socket.assigns.current_dir, entry.client_name)
{LivebookWeb.FileSystemWriter, [file: file]}
end
)}
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?)
{file_systems, configure_hub_id} =
if hub = socket.assigns[:hub],
do: {Livebook.Hubs.get_file_systems(hub), hub.id},
else: {Livebook.Hubs.get_file_systems(), Livebook.Hubs.Personal.id()}
configure_path = ~p"/hub/#{configure_hub_id}/file-systems/new"
{:ok, assign(socket, file_systems: file_systems, configure_path: configure_path)}
end
@impl true
def render(assigns) do
~H"""
"""
end
defp visible_files_limit(), do: 200
defp js_show_new_item_section(js \\ %JS{}, id) do
js
|> JS.show(to: "##{id}")
|> JS.dispatch("lb:set_value", to: "##{id} input", detail: %{value: ""})
|> JS.dispatch("lb:focus", to: "##{id} input")
end
defp js_hide_new_item_section(js \\ %JS{}, id) do
js
|> JS.hide(to: "##{id}")
end
defp handle_progress(:folder, entry, socket) when entry.done? do
:ok = consume_uploaded_entry(socket, entry, fn %{} -> {: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 = Enum.find(socket.assigns.file_systems, &(&1.id == file_system_id))
file = FileSystem.File.new(file_system)
send_event(socket, {:set_file, file, %{exists: true}})
{:noreply, socket}
end
def handle_event("set_path", %{"path" => path}, socket) do
file_system =
Enum.find(socket.assigns.file_systems, &(&1.id == socket.assigns.file.file_system_id))
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_event(socket, {:set_file, file, info})
{:noreply, socket}
end
def handle_event("clear_error", %{}, socket) do
{:noreply, put_error(socket, nil)}
end
def handle_event("create_dir", %{"name" => name}, socket) do
socket =
case create_dir(socket.assigns.current_dir, name) do
:ok -> update_file_infos(socket, true)
{:error, error} -> put_error(socket, error)
end
{:noreply, socket}
end
def handle_event("create_notebook", %{"name" => name}, socket) do
socket =
case create_notebook(socket.assigns.current_dir, name) do
:ok -> update_file_infos(socket, true)
{:error, error} -> put_error(socket, 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
def handle_event("set_default_directory", _params, socket) do
{dir, _prefix} = dir_and_prefix(socket.assigns.file)
Livebook.Settings.set_default_dir(dir)
{:noreply, socket}
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
file_infos = annotate_matching(file_infos, prefix)
{unhighlighted_file_infos, highlighted_file_infos} =
Enum.split_with(file_infos, &(&1.highlighted == ""))
assign(socket,
file_infos: file_infos,
unhighlighted_file_infos: unhighlighted_file_infos,
highlighted_file_infos: highlighted_file_infos
)
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
%{
id: Base.url_encode64(file.path, padding: false),
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, :any), do: true
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 create_notebook(_parent_dir, ""), do: {:error, :ignore}
defp create_notebook(parent_dir, name) do
{source, _warnings} =
Livebook.Session.default_notebook() |> Livebook.LiveMarkdown.notebook_to_livemd()
new_file =
parent_dir
|> FileSystem.File.resolve(name)
|> FileSystem.File.ensure_extension(Livebook.LiveMarkdown.extension())
with {:ok, exists?} <- FileSystem.File.exists?(new_file) do
if exists? do
{:error, :ignore}
else
FileSystem.File.write(new_file, source)
end
end
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
defp send_event(socket, event) do
case socket.assigns.target do
{module, id} ->
send_update(module, id: id, event: event)
pid when is_pid(pid) ->
send(pid, event)
end
end
end