defmodule LivebookWeb.PathSelectComponent do
use LivebookWeb, :live_component
# The component expects:
#
# * `path` - the currently entered path
# * `running_paths` - the list of notebook paths that are already linked to running sessions
# * `extnames` - a list of file extensions that should be shown
# * `phx_target` - id of the component to send update events to or nil to send to the parent LV
# * `phx_submit` - the event name sent on form submission, use `nil` for no action
#
# The target receives `set_path` events with `%{"path" => path}` payload.
#
# Optionally inner block may be passed (e.g. with action buttons)
# and it's rendered next to the text input.
#
# To force the component refetch the displayed files
# you can `send_update` with `force_reload: true` to the component.
@impl true
def mount(socket) do
{:ok,
socket
|> assign_new(:inner_block, fn -> nil end)
|> assign(
current_dir: nil,
new_directory_name: nil,
deleting_path: nil,
renaming_path: nil,
renamed_name: ""
)}
end
@impl true
def update(assigns, socket) do
{force_reload?, assigns} = Map.pop(assigns, :force_reload, false)
socket =
socket
|> assign(assigns)
|> update_files(force_reload?)
{:ok, socket}
end
defp update_files(%{assigns: assigns} = socket, force_reload?) do
{dir, basename} = split_path(assigns.path)
dir = Path.expand(dir)
files =
if assigns.current_dir != dir or force_reload? do
list_files(dir, assigns.extnames, assigns.running_paths)
else
assigns.files
end
assign(socket, files: annotate_matching(files, basename), current_dir: dir)
end
@impl true
def render(assigns) do
~H"""
<%= if @inner_block do %>
<%= render_block(@inner_block) %>
<% end %>
<%= if @deleting_path do %>
Are you sure you want to irreversibly delete
<%= @deleting_path %>?
<%= for file <- @files, file.highlighted != "" do %>
<.file
file={file}
phx_target={@phx_target}
myself={@myself}
renaming_path={@renaming_path}
renamed_name={@renamed_name} />
<% end %>
<% end %>
<%= for file <- @files, file.highlighted == "" do %>
<.file
file={file}
phx_target={@phx_target}
myself={@myself}
renaming_path={@renaming_path}
renamed_name={@renamed_name} />
<% end %>
"""
end
defp highlighting?(files) do
Enum.any?(files, &(&1.highlighted != ""))
end
defp file(%{file: %{path: path}, renaming_path: path} = assigns) do
~H"""
"""
end
defp file(assigns) do
icon =
case assigns.file do
%{is_running: true} -> "play-circle-line"
%{is_dir: true} -> "folder-fill"
_ -> "file-code-line"
end
assigns = assign(assigns, :icon, icon)
~H"""
"""
end
defp annotate_matching(files, prefix) do
for %{name: name} = file <- files do
if String.starts_with?(name, prefix) do
%{file | highlighted: prefix, unhighlighted: String.replace_prefix(name, prefix, "")}
else
%{file | highlighted: "", unhighlighted: name}
end
end
end
defp list_files(dir, extnames, running_paths) do
if File.exists?(dir) do
file_names =
case File.ls(dir) do
{:ok, names} -> names
{:error, _} -> []
end
file_infos =
file_names
|> Enum.map(fn name ->
file_info(name, Path.join(dir, name), running_paths)
end)
|> Enum.filter(fn file ->
not hidden?(file.name) and (file.is_dir or valid_extension?(file.name, extnames))
end)
parent = Path.dirname(dir)
file_infos =
if parent == dir do
file_infos
else
[file_info("..", parent, running_paths) | file_infos]
end
Enum.sort_by(file_infos, fn file -> {!file.is_dir, file.name} end)
else
[]
end
end
defp file_info(name, path, running_paths) do
is_dir = File.dir?(path)
%{
name: name,
highlighted: "",
unhighlighted: name,
path: if(is_dir, do: ensure_trailing_slash(path), else: path),
is_dir: is_dir,
is_running: path in running_paths
}
end
defp hidden?(filename) do
String.starts_with?(filename, ".")
end
defp valid_extension?(filename, extnames) do
Path.extname(filename) in extnames
end
# Note: to provide an intuitive behavior when typing the path
# we enter a new directory when it has a trailing slash,
# so given "/foo/bar" we list files in "foo" and given "/foo/bar/
# we list files in "bar".
#
# The basename is kinda like search within the current directory,
# so we highlight files starting with that string.
defp split_path(path) do
if String.ends_with?(path, "/") do
{path, ""}
else
{Path.dirname(path), Path.basename(path)}
end
end
defp ensure_trailing_slash(path) do
if String.ends_with?(path, "/") do
path
else
path <> "/"
end
end
@impl true
def handle_event("new_directory", %{}, socket) do
{:noreply, assign(socket, new_directory_name: "")}
end
def handle_event("cancel_new_directory", %{}, socket) do
{:noreply, assign(socket, new_directory_name: nil)}
end
def handle_event("create_directory", %{"value" => name}, socket) do
socket =
case create_directory(socket.assigns.current_dir, name) do
:ok ->
socket
|> assign(new_directory_name: nil)
|> update_files(true)
_ ->
assign(socket, new_directory_name: name)
end
{:noreply, socket}
end
def handle_event("delete_file", %{"path" => path}, socket) do
{:noreply, assign(socket, deleting_path: path)}
end
def handle_event("cancel_delete_file", %{}, socket) do
{:noreply, assign(socket, deleting_path: nil)}
end
def handle_event("do_delete_file", %{}, socket) do
socket =
case delete_file(socket.assigns.deleting_path) do
:ok ->
socket
|> assign(deleting_path: nil)
|> update_files(true)
_ ->
socket
end
{:noreply, socket}
end
def handle_event("rename_file", %{"path" => path}, socket) do
{_, name} = path |> Path.expand() |> split_path()
{:noreply, assign(socket, renaming_path: path, renamed_name: name)}
end
def handle_event("cancel_rename_file", %{}, socket) do
{:noreply, assign(socket, renaming_path: nil)}
end
def handle_event("do_rename_file", %{"value" => name}, socket) do
socket =
case rename_file(socket.assigns.renaming_path, name) do
:ok ->
socket
|> assign(renaming_path: nil)
|> update_files(true)
_ ->
assign(socket, renamed_name: name)
end
{:noreply, socket}
end
defp create_directory(_parent_dir, ""), do: {:error, :empty}
defp create_directory(parent_dir, name) do
new_dir = Path.join(parent_dir, name)
File.mkdir(new_dir)
end
defp delete_file(path) do
with {:ok, _paths} <- File.rm_rf(path) do
:ok
end
end
defp rename_file(_path, ""), do: {:error, :empty}
defp rename_file(path, name) do
dir = path |> Path.expand() |> Path.dirname()
new_path = Path.join(dir, name)
File.rename(path, new_path)
end
end