defmodule LivebookWeb.SessionLive do use LivebookWeb, :live_view import LivebookWeb.UserHelpers import LivebookWeb.SessionHelpers import Livebook.Utils, only: [access_by_id: 1] alias LivebookWeb.SidebarHelpers alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown, FileSystem} alias Livebook.Notebook.Cell alias Livebook.JSInterop @impl true def mount(%{"id" => session_id}, %{"current_user_id" => current_user_id} = web_session, socket) do # We use the tracked sessions to locate the session pid, but then # we talk to the session process exclusively for getting all the information case Sessions.fetch_session(session_id) do {:ok, %{pid: session_pid}} -> current_user = build_current_user(web_session, socket) data = if connected?(socket) do data = Session.register_client(session_pid, self(), current_user) Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}") Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}") data else Session.get_data(session_pid) end session = Session.get_by_pid(session_pid) platform = platform_from_socket(socket) {:ok, socket |> assign( session: session, platform: platform, current_user: current_user, self: self(), data_view: data_to_view(data), autofocus_cell_id: autofocus_cell_id(data.notebook) ) |> assign_private(data: data) |> allow_upload(:cell_image, accept: ~w(.jpg .jpeg .png .gif), max_entries: 1, max_file_size: 5_000_000 )} :error -> {:ok, redirect(socket, to: Routes.home_path(socket, :page))} end end # Puts the given assigns in `socket.private`, # to ensure they are not used for rendering. defp assign_private(socket, assigns) do Enum.reduce(assigns, socket, fn {key, value}, socket -> put_in(socket.private[key], value) end) end defp platform_from_socket(socket) do with connect_info when connect_info != nil <- get_connect_info(socket), {:ok, user_agent} <- Map.fetch(connect_info, :user_agent) do platform_from_user_agent(user_agent) else _ -> nil end end @impl true def render(assigns) do ~H"""

Sections

<%= for section_item <- @data_view.sections_items do %> <% end %>

Users

<%= length(@data_view.clients) %> connected
<%= for {client_pid, user} <- @data_view.clients do %>
<%= if client_pid != @self do %> <% end %>
<% end %>

<%= @data_view.notebook_name %>

<%= if @data_view.section_views == [] do %>
<% end %> <%= for {section_view, index} <- Enum.with_index(@data_view.section_views) do %> <%= live_component LivebookWeb.SessionLive.SectionComponent, id: section_view.id, index: index, session_id: @session.id, section_view: section_view %> <% end %>
<%= live_component LivebookWeb.SessionLive.IndicatorsComponent, session_id: @session.id, file: @data_view.file, dirty: @data_view.dirty, autosave_interval_s: @data_view.autosave_interval_s, runtime: @data_view.runtime, global_evaluation_status: @data_view.global_evaluation_status %>
<%= if @live_action == :user do %> <%= live_modal LivebookWeb.UserComponent, id: "user", modal_class: "w-full max-w-sm", user: @current_user, return_to: Routes.session_path(@socket, :page, @session.id) %> <% end %> <%= if @live_action == :runtime_settings do %> <%= live_modal LivebookWeb.SessionLive.RuntimeComponent, id: "runtime-settings", modal_class: "w-full max-w-4xl", return_to: Routes.session_path(@socket, :page, @session.id), session: @session, runtime: @data_view.runtime %> <% end %> <%= if @live_action == :file_settings do %> <%= live_modal @socket, LivebookWeb.SessionLive.PersistenceLive, id: "persistence", modal_class: "w-full max-w-4xl", return_to: Routes.session_path(@socket, :page, @session.id), session: %{ "session" => @session, "file" => @data_view.file, "persist_outputs" => @data_view.persist_outputs, "autosave_interval_s" => @data_view.autosave_interval_s } %> <% end %> <%= if @live_action == :shortcuts do %> <%= live_modal LivebookWeb.SessionLive.ShortcutsComponent, id: "shortcuts", modal_class: "w-full max-w-6xl", platform: @platform, return_to: Routes.session_path(@socket, :page, @session.id) %> <% end %> <%= if @live_action == :cell_settings do %> <%= live_modal settings_component_for(@cell), id: "cell-settings", modal_class: "w-full max-w-xl", session: @session, cell: @cell, return_to: Routes.session_path(@socket, :page, @session.id) %> <% end %> <%= if @live_action == :cell_upload do %> <%= live_modal LivebookWeb.SessionLive.CellUploadComponent, id: "cell-upload", modal_class: "w-full max-w-xl", session: @session, cell: @cell, uploads: @uploads, return_to: Routes.session_path(@socket, :page, @session.id) %> <% end %> <%= if @live_action == :delete_section do %> <%= live_modal LivebookWeb.SessionLive.DeleteSectionComponent, id: "delete-section", modal_class: "w-full max-w-xl", session: @session, section: @section, is_first: @section.id == @first_section_id, return_to: Routes.session_path(@socket, :page, @session.id) %> <% end %> <%= if @live_action == :bin do %> <%= live_modal LivebookWeb.SessionLive.BinComponent, id: "bin", modal_class: "w-full max-w-4xl", session: @session, bin_entries: @data_view.bin_entries, return_to: Routes.session_path(@socket, :page, @session.id) %> <% end %> <%= if @live_action == :export do %> <%= live_modal LivebookWeb.SessionLive.ExportComponent, id: "export", modal_class: "w-full max-w-4xl", session: @session, tab: @tab, return_to: Routes.session_path(@socket, :page, @session.id) %> <% end %> """ end defp settings_component_for(%Cell.Elixir{}), do: LivebookWeb.SessionLive.ElixirCellSettingsComponent defp settings_component_for(%Cell.Input{}), do: LivebookWeb.SessionLive.InputCellSettingsComponent defp branching_tooltip_attrs(name, parent_name) do direction = if String.length(name) >= 16, do: "left", else: "right" wrapped_name = Livebook.Utils.wrap_line("”" <> parent_name <> "”", 16) label = "Branches from\n#{wrapped_name}" [class: "tooltip #{direction}", "aria-label": label] end @impl true def handle_params(%{"cell_id" => cell_id}, _url, socket) do {:ok, cell, _} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) {:noreply, assign(socket, cell: cell)} end def handle_params(%{"section_id" => section_id}, _url, socket) do {:ok, section} = Notebook.fetch_section(socket.private.data.notebook, section_id) first_section_id = hd(socket.private.data.notebook.sections).id {:noreply, assign(socket, section: section, first_section_id: first_section_id)} end def handle_params( %{"path_parts" => path_parts}, _url, %{assigns: %{live_action: :catch_all}} = socket ) do path_parts = Enum.map(path_parts, fn "__parent__" -> ".." part -> part end) path = Path.join(path_parts) {:noreply, handle_relative_path(socket, path)} end def handle_params(%{"tab" => tab}, _url, socket) do {:noreply, assign(socket, tab: tab)} end def handle_params(_params, _url, socket) do {:noreply, socket} end @impl true def handle_event("session_init", _params, socket) do data = socket.private.data payload = %{ clients: Enum.map(data.clients_map, fn {client_pid, user_id} -> client_info(client_pid, data.users_map[user_id]) end) } {:reply, payload, socket} end def handle_event("cell_init", %{"cell_id" => cell_id}, socket) do data = socket.private.data case Notebook.fetch_cell_and_section(data.notebook, cell_id) do {:ok, cell, _section} -> info = data.cell_infos[cell.id] payload = %{ source: cell.source, revision: info.revision, evaluation_digest: encode_digest(info.evaluation_digest) } # From this point on we don't need cell source in the LV, # so we are going to drop it altogether socket = remove_cell_source(socket, cell_id) {:reply, payload, socket} :error -> {:noreply, socket} end end def handle_event("append_section", %{}, socket) do idx = length(socket.private.data.notebook.sections) Session.insert_section(socket.assigns.session.pid, idx) {:noreply, socket} end def handle_event("insert_section_into", %{"section_id" => section_id, "index" => index}, socket) do index = ensure_integer(index) |> max(0) Session.insert_section_into(socket.assigns.session.pid, section_id, index) {:noreply, socket} end def handle_event( "set_section_parent", %{"section_id" => section_id, "parent_id" => parent_id}, socket ) do Session.set_section_parent(socket.assigns.session.pid, section_id, parent_id) {:noreply, socket} end def handle_event("unset_section_parent", %{"section_id" => section_id}, socket) do Session.unset_section_parent(socket.assigns.session.pid, section_id) {:noreply, socket} end def handle_event( "insert_cell", %{"section_id" => section_id, "index" => index, "type" => type}, socket ) do index = ensure_integer(index) |> max(0) type = String.to_atom(type) Session.insert_cell(socket.assigns.session.pid, section_id, index, type) {:noreply, socket} end def handle_event("insert_cell_below", %{"cell_id" => cell_id, "type" => type}, socket) do type = String.to_atom(type) insert_cell_next_to(socket, cell_id, type, idx_offset: 1) {:noreply, socket} end def handle_event("insert_cell_above", %{"cell_id" => cell_id, "type" => type}, socket) do type = String.to_atom(type) insert_cell_next_to(socket, cell_id, type, idx_offset: 0) {:noreply, socket} end def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do Session.delete_cell(socket.assigns.session.pid, cell_id) {:noreply, socket} end def handle_event("set_notebook_name", %{"name" => name}, socket) do name = normalize_name(name) Session.set_notebook_name(socket.assigns.session.pid, name) {:noreply, socket} end def handle_event("set_section_name", %{"section_id" => section_id, "name" => name}, socket) do name = normalize_name(name) Session.set_section_name(socket.assigns.session.pid, section_id, name) {:noreply, socket} end def handle_event( "apply_cell_delta", %{"cell_id" => cell_id, "delta" => delta, "revision" => revision}, socket ) do delta = Delta.from_compressed(delta) Session.apply_cell_delta(socket.assigns.session.pid, cell_id, delta, revision) {:noreply, socket} end def handle_event( "report_cell_revision", %{"cell_id" => cell_id, "revision" => revision}, socket ) do Session.report_cell_revision(socket.assigns.session.pid, cell_id, revision) {:noreply, socket} end def handle_event("set_cell_value", %{"cell_id" => cell_id, "value" => value}, socket) do # The browser may normalize newlines to \r\n, but we want \n # to more closely imitate an actual shell value = String.replace(value, "\r\n", "\n") Session.set_cell_attributes(socket.assigns.session.pid, cell_id, %{value: value}) {:noreply, socket} end def handle_event("move_cell", %{"cell_id" => cell_id, "offset" => offset}, socket) do offset = ensure_integer(offset) Session.move_cell(socket.assigns.session.pid, cell_id, offset) {:noreply, socket} end def handle_event("move_section", %{"section_id" => section_id, "offset" => offset}, socket) do offset = ensure_integer(offset) Session.move_section(socket.assigns.session.pid, section_id, offset) {:noreply, socket} end def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id}, socket) do Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id) {:noreply, socket} end def handle_event("queue_section_cells_evaluation", %{"section_id" => section_id}, socket) do with {:ok, section} <- Notebook.fetch_section(socket.private.data.notebook, section_id) do for cell <- section.cells, is_struct(cell, Cell.Elixir) do Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id) end end {:noreply, socket} end def handle_event("queue_all_cells_evaluation", _params, socket) do data = socket.private.data for {cell, _} <- Notebook.elixir_cells_with_section(data.notebook), data.cell_infos[cell.id].validity_status != :evaluated do Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id) end {:noreply, socket} end def handle_event("queue_child_cells_evaluation", %{"cell_id" => cell_id}, socket) do with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) do for {cell, _} <- Notebook.child_cells_with_section(socket.private.data.notebook, cell.id), is_struct(cell, Cell.Elixir) do Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id) end end {:noreply, socket} end def handle_event("queue_bound_cells_evaluation", %{"cell_id" => cell_id}, socket) do data = socket.private.data with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id) do for {bound_cell, _} <- Session.Data.bound_cells_with_section(data, cell.id) do Session.queue_cell_evaluation(socket.assigns.session.pid, bound_cell.id) end end {:noreply, socket} end def handle_event("cancel_cell_evaluation", %{"cell_id" => cell_id}, socket) do Session.cancel_cell_evaluation(socket.assigns.session.pid, cell_id) {:noreply, socket} end def handle_event("save", %{}, socket) do if socket.private.data.file do Session.save(socket.assigns.session.pid) {:noreply, socket} else {:noreply, push_patch(socket, to: Routes.session_path(socket, :file_settings, socket.assigns.session.id) )} end end def handle_event("show_shortcuts", %{}, socket) do {:noreply, push_patch(socket, to: Routes.session_path(socket, :shortcuts, socket.assigns.session.id))} end def handle_event("show_runtime_settings", %{}, socket) do {:noreply, push_patch(socket, to: Routes.session_path(socket, :runtime_settings, socket.assigns.session.id) )} end def handle_event("show_bin", %{}, socket) do {:noreply, push_patch(socket, to: Routes.session_path(socket, :bin, socket.assigns.session.id))} end def handle_event("restart_runtime", %{}, socket) do socket = if runtime = socket.private.data.runtime do case Runtime.duplicate(runtime) do {:ok, new_runtime} -> Session.connect_runtime(socket.assigns.session.pid, new_runtime) socket {:error, message} -> put_flash(socket, :error, "Failed to setup runtime - #{message}") end else socket end {:noreply, socket} end def handle_event("intellisense_request", %{"cell_id" => cell_id} = params, socket) do request = case params do %{"type" => "completion", "hint" => hint} -> {:completion, hint} %{"type" => "details", "line" => line, "column" => column} -> column = JSInterop.js_column_to_elixir(column, line) {:details, line, column} %{"type" => "format", "code" => code} -> {:format, code} end data = socket.private.data with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id) do if data.runtime do ref = make_ref() prev_locator = Session.find_prev_locator(data.notebook, cell, section) Runtime.handle_intellisense(data.runtime, self(), ref, request, prev_locator) {:reply, %{"ref" => inspect(ref)}, socket} else info = case params["type"] do "completion" -> "You need to start a runtime (or evaluate a cell) for code completion" "format" -> "You need to start a runtime (or evaluate a cell) to enable code formatting" _ -> nil end socket = if info, do: put_flash(socket, :info, info), else: socket {:reply, %{"ref" => nil}, socket} end else _ -> {:noreply, socket} end end def handle_event("fork_session", %{}, socket) do %{pid: pid, images_dir: images_dir} = socket.assigns.session # Fetch the data, as we don't keep cells' source in the state data = Session.get_data(pid) notebook = Notebook.forked(data.notebook) {:noreply, create_session(socket, notebook: notebook, copy_images_from: images_dir)} end def handle_event("location_report", report, socket) do Phoenix.PubSub.broadcast_from( Livebook.PubSub, self(), "sessions:#{socket.assigns.session.id}", {:location_report, self(), report} ) {:noreply, socket} end def handle_event("format_code", %{"code" => code}, socket) do formatted = try do code |> Code.format_string!() |> IO.iodata_to_binary() rescue _ -> code end {:reply, %{code: formatted}, socket} end @impl true def handle_info({:operation, operation}, socket) do case Session.Data.apply_operation(socket.private.data, operation) do {:ok, data, actions} -> new_socket = socket |> assign_private(data: data) |> assign(data_view: update_data_view(socket.assigns.data_view, data, operation)) |> after_operation(socket, operation) |> handle_actions(actions) {:noreply, new_socket} :error -> {:noreply, socket} end end def handle_info({:error, error}, socket) do message = error |> to_string() |> upcase_first() {:noreply, put_flash(socket, :error, message)} end def handle_info({:info, info}, socket) do message = info |> to_string() |> upcase_first() {:noreply, put_flash(socket, :info, message)} end def handle_info({:hydrate_bin_entries, hydrated_entries}, socket) do hydrated_entries_map = Map.new(hydrated_entries, fn entry -> {entry.cell.id, entry} end) data = Map.update!(socket.private.data, :bin_entries, fn bin_entries -> Enum.map(bin_entries, fn entry -> case Map.fetch(hydrated_entries_map, entry.cell.id) do {:ok, hydrated_entry} -> hydrated_entry :error -> entry end end) end) {:noreply, socket |> assign_private(data: data) |> assign(data_view: data_to_view(data))} end def handle_info({:session_updated, session}, socket) do {:noreply, assign(socket, :session, session)} end def handle_info(:session_closed, socket) do {:noreply, socket |> put_flash(:info, "Session has been closed") |> push_redirect(to: Routes.home_path(socket, :page))} end def handle_info({:intellisense_response, ref, request, response}, socket) do response = process_intellisense_response(response, request) payload = %{"ref" => inspect(ref), "response" => response} {:noreply, push_event(socket, "intellisense_response", payload)} end def handle_info( {:user_change, %{id: id} = user}, %{assigns: %{current_user: %{id: id}}} = socket ) do {:noreply, assign(socket, :current_user, user)} end def handle_info({:location_report, client_pid, report}, socket) do report = Map.put(report, :client_pid, inspect(client_pid)) {:noreply, push_event(socket, "location_report", report)} end def handle_info(_message, socket), do: {:noreply, socket} defp handle_relative_path(socket, path) do cond do String.ends_with?(path, LiveMarkdown.extension()) -> handle_relative_notebook_path(socket, path) true -> socket |> put_flash( :error, "Got unrecognised session path: #{path}\nIf you want to link another notebook, make sure to include the .livemd extension" ) |> redirect_to_self() end end defp handle_relative_notebook_path(socket, relative_path) do resolution_location = location(socket.private.data) case resolution_location do nil -> socket |> put_flash( :info, "Cannot resolve notebook path #{relative_path}, because the current notebook has no location" ) |> redirect_to_self() resolution_location -> origin = case resolution_location do {:url, url} -> {:url, Livebook.Utils.expand_url(url, relative_path)} {:file, file} -> {:file, FileSystem.File.resolve(file, relative_path)} end case session_id_by_location(origin) do {:ok, session_id} -> push_redirect(socket, to: Routes.session_path(socket, :page, session_id)) {:error, :none} -> open_notebook(socket, origin) {:error, :many} -> origin_str = case origin do {:url, url} -> url {:file, file} -> file.path end socket |> put_flash( :error, "Cannot navigate, because multiple sessions were found for #{origin_str}" ) |> redirect_to_self() end end end defp location(data) defp location(%{file: file}) when is_map(file), do: {:file, file} defp location(%{origin: origin}), do: origin defp open_notebook(socket, origin) do case load_content(origin) do {:ok, content} -> {notebook, messages} = Livebook.LiveMarkdown.Import.notebook_from_markdown(content) # If the current session has no path, fork the notebook fork? = socket.private.data.file == nil {file, notebook} = file_and_notebook(fork?, origin, notebook) socket |> put_import_flash_messages(messages) |> create_session(notebook: notebook, origin: origin, file: file) {:error, message} -> socket |> put_flash(:error, "Cannot navigate, " <> message) |> redirect_to_self() end end defp load_content({:file, file}) do case FileSystem.File.read(file) do {:ok, content} -> {:ok, content} {:error, message} -> {:error, "failed to read #{file.path}, reason: #{message}"} end end defp load_content({:url, url}) do url |> Livebook.ContentLoader.rewrite_url() |> Livebook.ContentLoader.fetch_content() 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)} defp file_and_notebook(_fork?, _origin, notebook), do: {nil, notebook} defp session_id_by_location(location) do sessions = Sessions.list_sessions() session_with_file = Enum.find(sessions, fn session -> session.file && {:file, session.file} == location end) # A session associated with the given file takes # precedence over sessions originating from this file if session_with_file do {:ok, session_with_file.id} else sessions |> Enum.filter(fn session -> session.origin == location end) |> case do [session] -> {:ok, session.id} [] -> {:error, :none} _ -> {:error, :many} end end end defp redirect_to_self(socket) do push_patch(socket, to: Routes.session_path(socket, :page, socket.assigns.session.id)) end defp after_operation(socket, _prev_socket, {:client_join, client_pid, user}) do push_event(socket, "client_joined", %{client: client_info(client_pid, user)}) end defp after_operation(socket, _prev_socket, {:client_leave, client_pid}) do push_event(socket, "client_left", %{client_pid: inspect(client_pid)}) end defp after_operation(socket, _prev_socket, {:update_user, _client_pid, user}) do updated_clients = socket.private.data.clients_map |> Enum.filter(fn {_client_pid, user_id} -> user_id == user.id end) |> Enum.map(fn {client_pid, _user_id} -> client_info(client_pid, user) end) push_event(socket, "clients_updated", %{clients: updated_clients}) end defp after_operation(socket, _prev_socket, {:insert_section, client_pid, _index, section_id}) do if client_pid == self() do push_event(socket, "section_inserted", %{section_id: section_id}) else socket end end defp after_operation( socket, _prev_socket, {:insert_section_into, client_pid, _section_id, _index, section_id} ) do if client_pid == self() do push_event(socket, "section_inserted", %{section_id: section_id}) else socket end end defp after_operation( socket, _prev_socket, {:delete_section, _client_pid, section_id, _delete_cells} ) do push_event(socket, "section_deleted", %{section_id: section_id}) end defp after_operation(socket, _prev_socket, {:insert_cell, client_pid, _, _, type, cell_id}) do if client_pid == self() do case type do :input -> push_patch(socket, to: Routes.session_path(socket, :cell_settings, socket.assigns.session.id, cell_id) ) _ -> socket end |> push_event("cell_inserted", %{cell_id: cell_id}) else socket end end defp after_operation(socket, prev_socket, {:delete_cell, _client_pid, cell_id}) do # Find a sibling cell that the client would focus if the deleted cell has focus. sibling_cell_id = case Notebook.fetch_cell_sibling(prev_socket.private.data.notebook, cell_id, 1) do {:ok, next_cell} -> next_cell.id :error -> case Notebook.fetch_cell_sibling(prev_socket.private.data.notebook, cell_id, -1) do {:ok, previous_cell} -> previous_cell.id :error -> nil end end push_event(socket, "cell_deleted", %{cell_id: cell_id, sibling_cell_id: sibling_cell_id}) end defp after_operation(socket, _prev_socket, {:restore_cell, client_pid, cell_id}) do if client_pid == self() do push_event(socket, "cell_restored", %{cell_id: cell_id}) else socket end end defp after_operation(socket, _prev_socket, {:move_cell, client_pid, cell_id, _offset}) do if client_pid == self() do push_event(socket, "cell_moved", %{cell_id: cell_id}) else socket end end defp after_operation(socket, _prev_socket, {:move_section, client_pid, section_id, _offset}) do if client_pid == self() do push_event(socket, "section_moved", %{section_id: section_id}) else socket end end defp after_operation( socket, _prev_socket, {:evaluation_started, _client_pid, cell_id, evaluation_digest} ) do push_event(socket, "evaluation_started:#{cell_id}", %{ evaluation_digest: encode_digest(evaluation_digest) }) end defp after_operation(socket, _prev_socket, _operation), do: socket defp handle_actions(socket, actions) do Enum.reduce(actions, socket, &handle_action(&2, &1)) end defp handle_action(socket, {:broadcast_delta, client_pid, cell, delta}) do if client_pid == self() do push_event(socket, "cell_acknowledgement:#{cell.id}", %{}) else push_event(socket, "cell_delta:#{cell.id}", %{delta: Delta.to_compressed(delta)}) end end defp handle_action(socket, _action), do: socket defp client_info(pid, user) do %{pid: inspect(pid), hex_color: user.hex_color, name: user.name || "Anonymous"} end defp normalize_name(name) do name |> String.trim() |> String.replace(~r/\s+/, " ") |> case do "" -> "Untitled" name -> name end end def upcase_first(string) do {head, tail} = String.split_at(string, 1) String.upcase(head) <> tail end defp insert_cell_next_to(socket, cell_id, type, idx_offset: idx_offset) do {:ok, cell, section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) index = Enum.find_index(section.cells, &(&1 == cell)) Session.insert_cell(socket.assigns.session.pid, section.id, index + idx_offset, type) end defp ensure_integer(n) when is_integer(n), do: n defp ensure_integer(n) when is_binary(n), do: String.to_integer(n) defp encode_digest(nil), do: nil defp encode_digest(digest), do: Base.encode64(digest) defp remove_cell_source(socket, cell_id) do update_in(socket.private.data.notebook, fn notebook -> Notebook.update_cell(notebook, cell_id, &%{&1 | source: nil}) end) end defp process_intellisense_response( %{range: %{from: from, to: to}} = response, {:details, line, _column} ) do %{ response | range: %{ from: JSInterop.elixir_column_to_js(from, line), to: JSInterop.elixir_column_to_js(to, line) } } end defp process_intellisense_response(response, _request), do: response defp autofocus_cell_id(%Notebook{sections: [%{cells: [%{id: id, source: ""}]}]}), do: id defp autofocus_cell_id(_notebook), do: nil # Builds view-specific structure of data by cherry-picking # only the relevant attributes. # We then use `@data_view` in the templates and consequently # irrelevant changes to data don't change `@data_view`, so LV doesn't # have to traverse the whole template tree and no diff is sent to the client. defp data_to_view(data) do %{ file: data.file, persist_outputs: data.notebook.persist_outputs, autosave_interval_s: data.notebook.autosave_interval_s, dirty: data.dirty, runtime: data.runtime, global_evaluation_status: global_evaluation_status(data), notebook_name: data.notebook.name, sections_items: for section <- data.notebook.sections do %{ id: section.id, name: section.name, parent: parent_section_view(section.parent_id, data) } end, clients: data.clients_map |> Enum.map(fn {client_pid, user_id} -> {client_pid, data.users_map[user_id]} end) |> Enum.sort_by(fn {_client_pid, user} -> user.name end), section_views: section_views(data.notebook.sections, data), bin_entries: data.bin_entries } end defp global_evaluation_status(data) do cells = data.notebook |> Notebook.elixir_cells_with_section() |> Enum.map(fn {cell, _} -> cell end) cond do evaluating = Enum.find(cells, &evaluating?(&1, data)) -> {:evaluating, evaluating.id} stale = Enum.find(cells, &stale?(&1, data)) -> {:stale, stale.id} evaluated = Enum.find(Enum.reverse(cells), &evaluated?(&1, data)) -> {:evaluated, evaluated.id} true -> {:fresh, nil} end end defp evaluating?(cell, data), do: data.cell_infos[cell.id].evaluation_status == :evaluating defp stale?(cell, data), do: data.cell_infos[cell.id].validity_status == :stale defp evaluated?(cell, data), do: data.cell_infos[cell.id].validity_status == :evaluated defp section_views(sections, data) do sections |> Enum.map(& &1.name) |> names_to_html_ids() |> Enum.zip(sections) |> Enum.map(fn {html_id, section} -> %{ id: section.id, html_id: html_id, name: section.name, parent: parent_section_view(section.parent_id, data), has_children?: Notebook.child_sections(data.notebook, section.id) != [], valid_parents: for parent <- Notebook.valid_parents_for(data.notebook, section.id) do %{id: parent.id, name: parent.name} end, cell_views: Enum.map(section.cells, &cell_to_view(&1, data)) } end) end defp parent_section_view(nil, _data), do: nil defp parent_section_view(parent_id, data) do {:ok, section} = Notebook.fetch_section(data.notebook, parent_id) %{id: section.id, name: section.name} end defp cell_to_view(%Cell.Elixir{} = cell, data) do info = data.cell_infos[cell.id] %{ id: cell.id, type: :elixir, # Note: we need this during initial loading, # at which point we still have the source empty?: cell.source == "", outputs: cell.outputs, validity_status: info.validity_status, evaluation_status: info.evaluation_status, evaluation_time_ms: info.evaluation_time_ms, number_of_evaluations: info.number_of_evaluations } end defp cell_to_view(%Cell.Markdown{} = cell, _data) do %{ id: cell.id, type: :markdown, # Note: we need this during initial loading, # at which point we still have the source empty?: cell.source == "" } end defp cell_to_view(%Cell.Input{} = cell, _data) do %{ id: cell.id, type: :input, input_type: cell.type, name: cell.name, value: cell.value, error: case Cell.Input.validate(cell) do :ok -> nil {:error, error} -> error end, props: cell.props } end # Updates current data_view in response to an operation. # In most cases we simply recompute data_view, but for the # most common ones we only update the relevant parts. defp update_data_view(data_view, data, operation) do case operation do {:report_cell_revision, _pid, _cell_id, _revision} -> data_view {:apply_cell_delta, _pid, cell_id, _delta, _revision} -> data_view |> update_cell_view(data, cell_id) |> update_dirty_status(data) _ -> data_to_view(data) end end defp update_cell_view(data_view, data, cell_id) do {:ok, cell, section} = Notebook.fetch_cell_and_section(data.notebook, cell_id) cell_view = cell_to_view(cell, data) put_in( data_view, [:section_views, access_by_id(section.id), :cell_views, access_by_id(cell.id)], cell_view ) end # Changes that affect only a single cell are still likely to # have impact on dirtyness, so we need to always mirror it defp update_dirty_status(data_view, data) do put_in(data_view.dirty, data.dirty) end end