defmodule LivebookWeb.HomeLive do use LivebookWeb, :live_view import LivebookWeb.SessionHelpers alias LivebookWeb.{LearnHelpers, LayoutHelpers} alias Livebook.{Sessions, Session, LiveMarkdown, Notebook, FileSystem} on_mount LivebookWeb.SidebarHook @impl true def mount(params, _session, socket) do if connected?(socket) do Livebook.Sessions.subscribe() Livebook.SystemResources.subscribe() end sessions = Sessions.list_sessions() |> Enum.filter(&(&1.mode == :default)) notebook_infos = Notebook.Learn.visible_notebook_infos() |> Enum.take(3) {:ok, assign(socket, self_path: ~p"/", file: determine_file(params), file_info: %{exists: true, access: :read_write}, sessions: sessions, notebook_infos: notebook_infos, page_title: "Livebook", new_version: Livebook.UpdateCheck.new_version(), update_instructions_url: Livebook.Config.update_instructions_url(), app_service_url: Livebook.Config.app_service_url(), memory: Livebook.SystemResources.memory() )} end @impl true def render(assigns) do ~H""" <LayoutHelpers.layout current_page={@self_path} current_user={@current_user} saved_hubs={@saved_hubs} > <:topbar_action> <div class="flex space-x-2"> <.link patch={~p"/home/import/url"} class="button-base button-outlined-gray whitespace-nowrap" > Import </.link> <button class="button-base button-blue" phx-click="new"> New notebook </button> </div> </:topbar_action> <.update_notification version={@new_version} instructions_url={@update_instructions_url} /> <.memory_notification memory={@memory} app_service_url={@app_service_url} /> <div class="p-4 md:px-12 md:py-6 max-w-screen-lg mx-auto space-y-4"> <div class="flex flex-row space-y-0 items-center pb-4 justify-between"> <LayoutHelpers.title text="Home" /> <div class="hidden md:flex space-x-2" role="navigation" aria-label="new notebook"> <.link patch={~p"/home/import/url"} class="button-base button-outlined-gray whitespace-nowrap" > Import </.link> <button class="button-base button-blue" phx-click="new"> New notebook </button> </div> </div> <div class="h-80" role="region" aria-label="file system"> <.live_component module={LivebookWeb.FileSelectComponent} id="home-file-select" file={@file} extnames={[LiveMarkdown.extension()]} running_files={files(@sessions)} > <div class="flex justify-end space-x-2"> <button class="button-base button-outlined-gray whitespace-nowrap" phx-click="fork" disabled={not path_forkable?(@file, @file_info)} > <.remix_icon icon="git-branch-line" class="align-middle mr-1" /> <span>Fork</span> </button> <%= if file_running?(@file, @sessions) do %> <.link navigate={~p"/sessions/#{session_id_by_file(@file, @sessions)}"} class="button-base button-blue" > Join session </.link> <% else %> <span {open_button_tooltip_attrs(@file, @file_info)}> <button class="button-base button-blue" phx-click="open" disabled={not path_openable?(@file, @file_info, @sessions)} > Open </button> </span> <% end %> </div> </.live_component> </div> <div class="py-12" data-el-learn-section role="region" aria-label="learn section"> <div class="mb-4 flex justify-between items-center"> <h2 class="uppercase font-semibold text-gray-500"> Learn </h2> <.link navigate={~p"/learn"} class="flex items-center text-blue-600"> <span class="font-semibold">See all</span> <.remix_icon icon="arrow-right-line" class="align-middle ml-1" /> </.link> </div> <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <% # Note: it's fine to use stateless components in this comprehension, # because @notebook_infos never change %> <LearnHelpers.notebook_card :for={info <- @notebook_infos} notebook_info={info} /> </div> </div> <div id="running-sessions" class="py-12" role="region" aria-label="running sessions"> <.live_component module={LivebookWeb.HomeLive.SessionListComponent} id="session-list" sessions={@sessions} memory={@memory} /> </div> </div> </LayoutHelpers.layout> <.modal :if={@live_action == :close_session} id="close-session-modal" show class="w-full max-w-xl" patch={@self_path} > <.live_component module={LivebookWeb.HomeLive.CloseSessionComponent} id="close-session" return_to={@self_path} session={@session} /> </.modal> <.modal :if={@live_action == :import} id="import-modal" show class="w-full max-w-4xl" patch={@self_path} > <.live_component module={LivebookWeb.HomeLive.ImportComponent} id="import" tab={@tab} import_opts={@import_opts} /> </.modal> <.modal :if={@live_action == :edit_sessions} id="edit-sessions-modal" show class="w-full max-w-xl" patch={@self_path} > <.live_component module={LivebookWeb.HomeLive.EditSessionsComponent} id="edit-sessions" action={@bulk_action} return_to={@self_path} sessions={@sessions} selected_sessions={selected_sessions(@sessions, @selected_session_ids)} /> </.modal> """ end defp open_button_tooltip_attrs(file, file_info) do if regular?(file, file_info) and not writable?(file_info) do [class: "tooltip top", data_tooltip: "This file is write-protected, please fork instead"] else [] end end defp update_notification(%{version: nil} = assigns), do: ~H"" defp update_notification(assigns) do ~H""" <div class="px-2 py-2 bg-blue-200 text-gray-900 text-sm text-center"> <span> Livebook v<%= @version %> available! <%= if @instructions_url do %> Check out the news on <a class="font-medium border-b border-gray-900 hover:border-transparent" href="https://livebook.dev/" target="_blank" > livebook.dev </a> and follow the <a class="font-medium border-b border-gray-900 hover:border-transparent" href={@instructions_url} target="_blank" > update instructions </a> <% else %> Check out the news and installation steps on <a class="font-medium border-b border-gray-900 hover:border-transparent" href="https://livebook.dev/" target="_blank" > livebook.dev </a> <% end %> 🚀 </span> </div> """ end defp memory_notification(assigns) do ~H""" <div :if={@app_service_url && @memory.free < 30_000_000} class="px-2 py-2 bg-red-200 text-gray-900 text-sm text-center" > <.remix_icon icon="alarm-warning-line" class="align-text-bottom mr-0.5" /> Less than 30 MB of memory left, consider <a class="font-medium border-b border-gray-900 hover:border-transparent" href={@app_service_url} target="_blank" > adding more resources to the instance </a> or closing <a class="font-medium border-b border-gray-900 hover:border-transparent" href="#running-sessions" > running sessions </a> </div> """ end @impl true def handle_params(%{"session_id" => session_id}, _url, socket) do session = Enum.find(socket.assigns.sessions, &(&1.id == session_id)) {:noreply, assign(socket, session: session)} end def handle_params(%{"action" => action}, _url, socket) when socket.assigns.live_action == :edit_sessions do {:noreply, assign(socket, bulk_action: action)} end def handle_params(%{"tab" => tab} = params, _url, socket) when socket.assigns.live_action == :import do import_opts = [url: params["url"]] {:noreply, assign(socket, tab: tab, import_opts: import_opts)} end def handle_params(%{"url" => url}, _url, socket) when socket.assigns.live_action == :public_import do origin = Notebook.ContentLoader.url_to_location(url) origin |> Notebook.ContentLoader.fetch_content_from_location() |> case do {:ok, content} -> socket = import_content(socket, content, origin: origin) {:noreply, socket} {:error, _message} -> {:noreply, push_patch(socket, to: ~p"/home/import/url?url=#{url}")} end end def handle_params(%{"path" => path} = _params, _uri, socket) when socket.assigns.live_action == :public_open do path = Path.expand(path) file = FileSystem.File.local(path) if file_running?(file, socket.assigns.sessions) do session_id = session_id_by_file(file, socket.assigns.sessions) {:noreply, push_navigate(socket, to: ~p"/sessions/#{session_id}")} else {:noreply, open_notebook(socket, FileSystem.File.local(path))} end end def handle_params(_params, _url, socket), do: {:noreply, socket} @impl true def handle_event("new", %{}, socket) do {:noreply, create_session(socket)} end def handle_event("fork", %{}, socket) do file = socket.assigns.file socket = case import_notebook(file) do {:ok, {notebook, messages}} -> notebook = Notebook.forked(notebook) images_dir = Session.images_dir_for_notebook(file) socket |> put_import_warnings(messages) |> create_session( notebook: notebook, copy_images_from: images_dir, origin: {:file, file} ) {:error, error} -> put_flash(socket, :error, Livebook.Utils.upcase_first(error)) end {:noreply, socket} end def handle_event("open", %{}, socket) do file = socket.assigns.file {:noreply, open_notebook(socket, file)} end def handle_event("bulk_action", %{"action" => "disconnect"} = params, socket) do socket = assign(socket, selected_session_ids: params["session_ids"]) {:noreply, push_patch(socket, to: ~p"/home/sessions/edit_sessions/disconnect")} end def handle_event("bulk_action", %{"action" => "close_all"} = params, socket) do socket = assign(socket, selected_session_ids: params["session_ids"]) {:noreply, push_patch(socket, to: ~p"/home/sessions/edit_sessions/close_all")} end def handle_event("open_autosave_directory", %{}, socket) do file = Livebook.Settings.autosave_path() |> FileSystem.Utils.ensure_dir_path() |> FileSystem.File.local() file_info = %{exists: true, access: file_access(file)} {:noreply, assign(socket, file: file, file_info: file_info)} end @impl true def handle_info({:set_file, file, info}, socket) do file_info = %{exists: info.exists, access: file_access(file)} {:noreply, assign(socket, file: file, file_info: file_info)} end def handle_info({:session_created, session}, socket) do if session in socket.assigns.sessions do {:noreply, socket} else {:noreply, assign(socket, sessions: [session | socket.assigns.sessions])} end end def handle_info({:session_updated, session}, socket) do sessions = Enum.map(socket.assigns.sessions, fn other -> if other.id == session.id, do: session, else: other end) {:noreply, assign(socket, sessions: sessions)} end def handle_info({:session_closed, session}, socket) do sessions = Enum.reject(socket.assigns.sessions, &(&1.id == session.id)) {:noreply, assign(socket, sessions: sessions)} end def handle_info({:import_content, content, session_opts}, socket) do socket = import_content(socket, content, session_opts) {:noreply, socket} end def handle_info({:memory_update, memory}, socket) do {:noreply, assign(socket, memory: memory)} end def handle_info(_message, socket), do: {:noreply, socket} defp files(sessions) do Enum.map(sessions, & &1.file) end defp path_forkable?(file, file_info) do regular?(file, file_info) end defp path_openable?(file, file_info, sessions) do regular?(file, file_info) and not file_running?(file, sessions) and writable?(file_info) end defp regular?(file, file_info) do file_info.exists and not FileSystem.File.dir?(file) end defp writable?(file_info) do file_info.access in [:read_write, :write] end defp file_running?(file, sessions) do running_files = files(sessions) file in running_files end defp import_notebook(file) do with {:ok, content} <- FileSystem.File.read(file) do {:ok, LiveMarkdown.notebook_from_livemd(content)} end end defp session_id_by_file(file, sessions) do session = Enum.find(sessions, &(&1.file == file)) session.id end defp import_content(socket, content, session_opts) do {notebook, messages} = Livebook.LiveMarkdown.notebook_from_livemd(content) socket = socket |> put_import_warnings(messages) |> put_flash( :info, "You have imported a notebook, no code has been executed so far. You should read and evaluate code as needed." ) session_opts = Keyword.merge(session_opts, notebook: notebook) create_session(socket, session_opts) end defp file_access(file) do case FileSystem.File.access(file) do {:ok, access} -> access {:error, _} -> :none end end defp selected_sessions(sessions, selected_session_ids) do Enum.filter(sessions, &(&1.id in selected_session_ids)) end defp determine_file(%{"path" => path} = _params) do path = Path.expand(path) cond do File.dir?(path) -> path |> FileSystem.Utils.ensure_dir_path() |> FileSystem.File.local() File.regular?(path) -> FileSystem.File.local(path) true -> Livebook.Config.local_filesystem_home() end end defp determine_file(_params), do: Livebook.Settings.default_file_system_home() defp open_notebook(socket, file) do case import_notebook(file) do {:ok, {notebook, messages}} -> socket |> put_import_warnings(messages) |> create_session(notebook: notebook, file: file, origin: {:file, file}) {:error, error} -> put_flash(socket, :error, Livebook.Utils.upcase_first(error)) end end end