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 # # * `: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.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"""

File system

<.file_system_menu_button file={@file} file_systems={@file_systems} file_system_select_disabled={@file_system_select_disabled} myself={@myself} />
<.menu id="new-item-menu" disabled={@file_system_select_disabled} position={:bottom_right}> <:toggle> <.menu_item> <.menu_item>
<%= render_slot(@inner_block) %>
<%= @error_message %>

Are you sure you want to irreversibly delete <%= @deleting_file.path %>?

<.new_item_section type="dir" icon="folder-add-fill" myself={@myself} /> <.new_item_section type="notebook" icon="file-add-line" myself={@myself} />
<.live_file_input upload={@uploads.folder} class="hidden" aria-labelledby="import-from-file" />
<%= 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 %>
<%= 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 %>
<%= file.client_name %>
""" end defp new_item_section(assigns) do ~H""" """ 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> <%= for file_system <- @file_systems do %> <%= if file_system == @file.file_system do %> <.menu_item variant={:selected}> <% else %> <.menu_item> <% end %> <% end %> <.menu_item> <.link navigate={~p"/settings"} class="border-t border-gray-200" role="menuitem"> <.remix_icon icon="settings-3-line" /> Configure """ end defp file(%{file_info: %{file: file}, renaming_file: file} = assigns) do ~H"""
<.remix_icon icon="edit-line" class="text-xl align-middle text-gray-400" />
""" 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> <.menu_item :if={@file_info.editable}> <.menu_item :if={@file_info.editable} variant={:danger}> """ end defp js_show_new_item_section(js \\ %JS{}, type) do js |> JS.show(to: "#new-#{type}-section") |> JS.dispatch("lb:set_value", to: "#new-#{type}-input", detail: %{value: ""}) |> JS.dispatch("lb:focus", to: "#new-#{type}-input") end defp js_hide_new_item_section(js \\ %JS{}, type) do js |> JS.hide(to: "#new-#{type}-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 = 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 = 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_event(socket, {:set_file, file, info}) {:noreply, socket} end def handle_event("submit", %{}, socket) do if submit_event = socket.assigns.submit_event do send_event(socket, submit_event) end {: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 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 create_notebook(_parent_dir, ""), do: {:error, :ignore} defp create_notebook(parent_dir, name) do source = 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