diff --git a/lib/livebook_web/live/file_select_component.ex b/lib/livebook_web/live/file_select_component.ex index 8c85572e5a5..5bd71731458 100644 --- a/lib/livebook_web/live/file_select_component.ex +++ b/lib/livebook_web/live/file_select_component.ex @@ -525,7 +525,7 @@ defmodule LivebookWeb.FileSelectComponent do send_event(socket.assigns.target, {:mount_file_system, file_system}) send_event(socket.assigns.target, {:set_file, file, %{exists: true}}) - {:noreply, assign(socket, loading: true)} + {:noreply, socket} end def handle_event("set_path", %{"path" => path}, socket) do @@ -546,7 +546,7 @@ defmodule LivebookWeb.FileSelectComponent do end send_event(socket.assigns.target, {:set_file, file, info}) - {:noreply, assign(socket, loading: socket.assigns.file.path != path)} + {:noreply, socket} end def handle_event("clear_error", %{}, socket) do @@ -642,19 +642,32 @@ defmodule LivebookWeb.FileSelectComponent 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)} + dir_changed? = dir != assigns.current_dir - {:error, error} -> - {current_file_infos, put_error(socket, error)} - end - else - {current_file_infos, socket} + if dir_changed? or force_reload? do + # Start async file listing operation + start_async_file_listing(socket, dir, prefix, current_file_infos) + else + # Just re-annotate with the current prefix (search filter changed) + update_file_display(socket, current_file_infos, prefix) + end + end + + defp start_async_file_listing(socket, dir, prefix, current_file_infos) do + extnames = socket.assigns.extnames + running_files = socket.assigns.running_files + + socket + |> start_async(:list_files, fn -> + case get_file_infos(dir, extnames, running_files) do + {:ok, file_infos} -> {:ok, file_infos, dir, prefix} + {:error, error} -> {:error, error, current_file_infos, prefix} end + end) + |> assign(loading: true) + end + defp update_file_display(socket, file_infos, prefix) do file_infos = annotate_matching(file_infos, prefix) {unhighlighted_file_infos, highlighted_file_infos} = @@ -667,11 +680,40 @@ defmodule LivebookWeb.FileSelectComponent do assign(socket, file_infos: file_infos, unhighlighted_file_infos: unhighlighted_file_infos, - highlighted_file_infos: highlighted_file_infos, - loading: false + highlighted_file_infos: highlighted_file_infos ) end + @impl true + def handle_async(:list_files, {:ok, {:ok, file_infos, dir, prefix}}, socket) do + socket = + socket + |> assign(current_dir: dir) + |> update_file_display(file_infos, prefix) + |> assign(loading: false) + + {:noreply, socket} + end + + def handle_async(:list_files, {:ok, {:error, error, current_file_infos, prefix}}, socket) do + socket = + socket + |> update_file_display(current_file_infos, prefix) + |> put_error(error) + |> assign(loading: false) + + {:noreply, socket} + end + + def handle_async(:list_files, {:exit, _reason}, socket) do + socket = + socket + |> put_error("Listing files failed") + |> assign(loading: false) + + {:noreply, socket} + end + defp annotate_matching(file_infos, prefix) do for %{name: name} = info <- file_infos do case String.split(name, prefix, parts: 2) do diff --git a/test/livebook_web/live/file_select_component_test.exs b/test/livebook_web/live/file_select_component_test.exs index 946e38330e9..621864ca9ec 100644 --- a/test/livebook_web/live/file_select_component_test.exs +++ b/test/livebook_web/live/file_select_component_test.exs @@ -23,6 +23,51 @@ defmodule LivebookWeb.FileSelectComponentTest do refute render_component(FileSelectComponent, attrs(file: file)) =~ ".." end + test "shows loading when changing directories" do + file = FileSystem.File.local(notebooks_path() <> "/") + + # Initial render in one directory + html = render_component(FileSelectComponent, attrs(file: file)) + + # Loading should not be shown after initial render + refute html =~ ~s(role="status") + + # Simulate changing to a different directory via send_update with a new file + new_file = FileSystem.File.local(p("/")) + + html = render_component(FileSelectComponent, attrs(file: new_file)) + + # After changing directory, loading should be reset to false after async listing completes + # Note: render_component is synchronous, so by the time it returns, the async + # file listing via start_async has already completed and loading is false. + # The loading state is properly managed internally: + # 1. When directory changes, start_async is called with loading: true + # 2. When async completes, handle_async sets loading: false + refute html =~ ~s(role="status") + end + + test "does not show loading when only running_files changes" do + file = FileSystem.File.local(notebooks_path() <> "/") + running_file = FileSystem.File.local(notebooks_path() <> "/basic.livemd") + + # Initial render with no running files + html = render_component(FileSelectComponent, attrs(file: file, running_files: [])) + + refute html =~ ~s(role="status") + # Verify basic.livemd is shown but not marked as running + assert html =~ "basic.livemd" + refute html =~ "play-circle-line" + + # Update with running_files changed (same directory, no dir change) + html = render_component(FileSelectComponent, attrs(file: file, running_files: [running_file])) + + # Loading should not be triggered when only running_files changes + # because update_file_infos detects no directory change + refute html =~ ~s(role="status") + # Verify basic.livemd is now marked as running + assert html =~ "play-circle-line" + end + defp attrs(attrs) do Keyword.merge( [