diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index eeb3a234a..64905cdd4 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -6,7 +6,7 @@ defmodule LivebookWeb.SessionLive do import Livebook.Utils, only: [format_bytes: 1] alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown} - alias Livebook.Notebook.Cell + alias Livebook.Notebook.{Cell, ContentLoader} alias Livebook.JSInterop @impl true @@ -1242,7 +1242,8 @@ defmodule LivebookWeb.SessionLive do |> redirect_to_self() resolution_location -> - origin = Notebook.ContentLoader.resolve_location(resolution_location, relative_path) + origin = ContentLoader.resolve_location(resolution_location, relative_path) + fallback_locations = fallback_relative_locations(resolution_location, relative_path) case session_id_by_location(origin) do {:ok, session_id} -> @@ -1252,7 +1253,7 @@ defmodule LivebookWeb.SessionLive do push_redirect(socket, to: redirect_path) {:error, :none} -> - open_notebook(socket, origin, requested_url) + open_notebook(socket, origin, fallback_locations, requested_url) {:error, :many} -> origin_str = @@ -1282,12 +1283,12 @@ defmodule LivebookWeb.SessionLive do end end - defp open_notebook(socket, origin, requested_url) do - case Notebook.ContentLoader.fetch_content_from_location(origin) do + defp open_notebook(socket, origin, fallback_locations, requested_url) do + case fetch_content_with_fallbacks(origin, fallback_locations) do {:ok, content} -> {notebook, messages} = Livebook.LiveMarkdown.notebook_from_livemd(content) - # If the current session has no path, fork the notebook + # If the current session has no file, fork the notebook fork? = socket.private.data.file == nil {file, notebook} = file_and_notebook(fork?, origin, notebook) url_hash = get_url_hash(requested_url) @@ -1308,6 +1309,32 @@ defmodule LivebookWeb.SessionLive do end end + def fallback_relative_locations({:file, _}, _relative_path), do: [] + + def fallback_relative_locations(resolution_location, relative_path) do + # Other locations to check in case the relative location doesn't + # exist. For example, in ExDoc all pages (including notebooks) are + # flat, regardless of how they are structured in the file system + + name = relative_path |> String.split("/") |> Enum.at(-1) + flat_location = ContentLoader.resolve_location(resolution_location, name) + [flat_location] + end + + defp fetch_content_with_fallbacks(location, fallbacks) do + case ContentLoader.fetch_content_from_location(location) do + {:ok, content} -> + {:ok, content} + + error -> + fallbacks + |> Enum.reject(&(&1 == location)) + |> Enum.find_value(error, fn fallback -> + with {:error, _} <- ContentLoader.fetch_content_from_location(fallback), do: nil + end) + end + end + defp file_and_notebook(fork?, origin, notebook) defp file_and_notebook(false, {:file, file}, notebook), do: {file, notebook} defp file_and_notebook(true, {:file, _file}, notebook), do: {nil, Notebook.forked(notebook)} diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index 4d7d34da5..33f0151e6 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -834,6 +834,34 @@ defmodule LivebookWeb.SessionLiveTest do close_session_by_id(session_id) end + test "when a remote URL cannot be loaded, attempts to resolve a flat URL", %{conn: conn} do + bypass = Bypass.open() + + # Multi-level path is not available + Bypass.expect_once(bypass, "GET", "/nested/path/to/notebook.livemd", fn conn -> + Plug.Conn.resp(conn, 500, "Error") + end) + + # A flat path is available + Bypass.expect_once(bypass, "GET", "/notebook.livemd", fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/plain") + |> Plug.Conn.resp(200, "# My notebook") + end) + + index_url = url(bypass.port) <> "/index.livemd" + {:ok, session} = Sessions.create_session(origin: {:url, index_url}) + + assert {:error, {:live_redirect, %{to: "/sessions/" <> session_id}}} = + result = live(conn, "/sessions/#{session.id}/nested/path/to/notebook.livemd") + + {:ok, view, _} = follow_redirect(result, conn) + assert render(view) =~ "My notebook" + + Session.close(session.pid) + close_session_by_id(session_id) + end + test "renders an error message if relative remote notebook cannot be loaded", %{conn: conn} do bypass = Bypass.open()