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"""

File system

<.file_system_menu_button id={"#{@id}-file-system-menu"} file={@file} file_systems={@file_systems} configure_path={@configure_path} file_system_select_disabled={@file_system_select_disabled} myself={@myself} />
<.text_field id={"#{@id}-path-input"} aria-label="file path" phx-hook="FocusOnUpdate" name="path" placeholder="File" value={@file.path} spellcheck="false" autocomplete="off" />
<.menu id={"#{@id}-new-item-menu"} disabled={@file_system_select_disabled} position={:bottom_right} > <:toggle> <.icon_button tabindex="-1" aria-label="add"> <.remix_icon icon="add-line" /> <.menu_item> <.menu_item> <.menu_item>
<%= render_slot(@inner_block) %>
<%= @error_message %>

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

<.new_item_section id={"#{@id}-new-dir-section"} type="dir" icon="folder-add-fill" myself={@myself} /> <.new_item_section id={"#{@id}-new-notebook-section"} type="notebook" icon="file-add-line" myself={@myself} />
<.live_file_input upload={@uploads.folder} class="hidden" aria-labelledby="import-from-file" />
<.spinner /> <%= file.client_name %>
<.icon_button type="button" phx-click="clear-file" phx-target={@myself} tabindex="-1"> <.remix_icon icon="close-line" />
<%= for file_info <- Enum.take(@highlighted_file_infos, visible_files_limit()) do %> <.file id={"#{@id}-file-#{file_info.id}"} file_info={file_info} myself={@myself} renaming_file={@renaming_file} renamed_name={@renamed_name} /> <% end %> <.more_files_indicator length={length(@highlighted_file_infos)} />
<%= for file_info <- Enum.take(@unhighlighted_file_infos, visible_files_limit()) do %> <.file id={"#{@id}-file-#{file_info.id}"} file_info={file_info} myself={@myself} renaming_file={@renaming_file} renamed_name={@renamed_name} /> <% end %> <.more_files_indicator length={length(@unhighlighted_file_infos)} />
""" end defp new_item_section(assigns) do ~H""" """ end defp file_system_menu_button(assigns) do ~H""" <.menu id={@id} disabled={@file_system_select_disabled} position={:bottom_left}> <:toggle> <.button color="gray" type="button" class="pl-3 pr-2" aria-label="switch file storage" disabled={@file_system_select_disabled} > <%= file_system_name(@file.file_system_module) %>
<.remix_icon icon="arrow-down-s-line" class="text-lg leading-none" />
<%= for file_system <- @file_systems do %> <%= if file_system.id == @file.file_system_id do %> <.menu_item variant={:selected}> <% else %> <.menu_item> <% end %> <% end %> <.menu_item> <.link navigate={@configure_path} 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={@id} secondary_click> <:toggle> <.menu_item :if={@file_info.editable}> <.menu_item :if={@file_info.editable} variant={:danger}> """ end defp more_files_indicator(assigns) do ~H"""
visible_files_limit()} class="col-span-full text-sm text-medium text-gray-500 flex flex-col items-center gap-1" > <.remix_icon icon="more-line" class="text-lg" /> <%= @length - visible_files_limit() %> more files (search to see)
""" 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