defmodule LivebookWeb.SessionLive do use LivebookWeb, :live_view import LivebookWeb.SessionHelpers import LivebookWeb.FileSystemComponents alias Livebook.{Session, Text, Notebook, Runtime} alias Livebook.Notebook.Cell on_mount LivebookWeb.SidebarHook @impl true def mount(%{"id" => session_id}, _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 Livebook.Sessions.fetch_session(session_id) do {:ok, %{pid: session_pid}} -> {data, client_id} = if connected?(socket) do {data, client_id} = Session.register_client(session_pid, self(), socket.assigns.current_user) Session.subscribe(session_id) Livebook.NotebookManager.subscribe_starred_notebooks() {data, client_id} else data = Session.get_data(session_pid) {data, nil} end app = if slug = data.deployed_app_slug do {:ok, app} = Livebook.Apps.fetch_app(slug) if connected?(socket) do Livebook.App.subscribe(slug) end app end socket = if connected?(socket) do payload = %{ client_id: client_id, clients: Enum.map(data.clients_map, fn {client_id, user_id} -> client_info(client_id, data.users_map[user_id]) end) } socket = push_event(socket, "session_init", payload) cells = for {cell, _} <- Notebook.cells_with_section(data.notebook), do: cell push_cell_editor_payloads(socket, data, cells) else socket end session = Session.get_by_pid(session_pid) platform = platform_from_socket(socket) socket = if data.mode == :app do put_flash( socket, :info, "This session is a deployed app. Any changes will be reflected live." ) else socket end {:ok, socket |> assign( self_path: ~p"/sessions/#{session.id}", session: session, app: app, client_id: client_id, platform: platform, data_view: data_to_view(data), autofocus_cell_id: autofocus_cell_id(data.notebook), page_title: get_page_title(data.notebook.name), action_assigns: %{}, allowed_uri_schemes: Livebook.Config.allowed_uri_schemes(), starred_files: Livebook.NotebookManager.starred_notebooks() |> starred_files() ) |> assign_private(data: data) |> prune_outputs() |> prune_cell_sources()} {:error, :not_found} -> {:ok, redirect(socket, to: ~p"/")} {:error, :different_boot_id} -> {:ok, socket |> put_flash( :error, "Could not find notebook session because Livebook has rebooted. " <> "This may happen if Livebook runs out of memory while installing dependencies or executing code." ) |> redirect(to: ~p"/")} 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 user_agent when is_binary(user_agent) <- get_connect_info(socket, :user_agent) do platform_from_user_agent(user_agent) else _ -> nil end end @impl true defdelegate render(assigns), to: LivebookWeb.SessionLive.Render @impl true def handle_params(params, url, socket) do {socket, action_assigns} = handle_params(socket.assigns.live_action, params, url, socket) socket = assign(socket, :action_assigns, action_assigns) {:noreply, socket} end defp handle_params(:cell_settings, %{"cell_id" => cell_id}, _url, socket) do {:ok, cell, _} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) {socket, %{cell: cell}} end defp handle_params(:insert_image, %{}, _url, socket) do case pop_in(socket.assigns[:insert_image_metadata]) do {nil, socket} -> {redirect_to_self(socket), %{}} {metadata, socket} -> {socket, %{insert_image_metadata: metadata}} end end defp handle_params(:insert_file, %{}, _url, socket) do case pop_in(socket.assigns[:insert_file_metadata]) do {nil, socket} -> {redirect_to_self(socket), %{}} {metadata, socket} -> {socket, %{insert_file_metadata: metadata}} end end defp handle_params(:rename_file_entry, %{"name" => name}, _url, socket) do if file_entry = find_file_entry(socket, name) do {socket, %{renaming_file_entry: file_entry}} else {redirect_to_self(socket), %{}} end end defp handle_params(:export, %{"tab" => tab}, _url, socket) do any_stale_cell? = any_stale_cell?(socket.private.data) {socket, %{tab: tab, any_stale_cell?: any_stale_cell?}} end defp handle_params(:add_file_entry, %{"tab" => tab}, _url, socket) do {file_drop_metadata, socket} = pop_in(socket.assigns[:file_drop_metadata]) {socket, %{tab: tab, file_drop_metadata: file_drop_metadata}} end defp handle_params(:secrets, params, _url, socket) do {select_secret_metadata, socket} = pop_in(socket.assigns[:select_secret_metadata]) {socket, %{select_secret_metadata: select_secret_metadata, prefill_secret_name: params["secret_name"]}} end defp handle_params(live_action, params, _url, socket) when live_action in [:app_settings, :file_settings] do {socket, %{context: params["context"]}} end defp handle_params(:catch_all, %{"path_parts" => path_parts}, requested_url, socket) do path_parts = Enum.map(path_parts, fn "__parent__" -> ".." part -> part end) path = Path.join(path_parts) socket = handle_relative_path(socket, path, requested_url) {socket, %{}} end defp handle_params(_live_action, _params, _url, socket) do {socket, %{}} end @impl true 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_below", params, socket) do with {:ok, section, index} <- section_with_next_index( socket.private.data.notebook, params["section_id"], params["cell_id"] ) do Session.insert_section_into(socket.assigns.session.pid, section.id, index) end {:noreply, socket} end def handle_event("insert_branching_section_below", params, socket) do with {:ok, section, index} <- section_with_next_index( socket.private.data.notebook, params["section_id"], params["cell_id"] ) do Session.insert_branching_section_into(socket.assigns.session.pid, section.id, index) end {: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_below", params, socket) do {:noreply, insert_cell_below(socket, params)} end def handle_event("insert_example_snippet_below", params, socket) do data = socket.private.data %{"section_id" => section_id, "cell_id" => cell_id} = params if Livebook.Runtime.connected?(socket.private.data.runtime) do case example_snippet_definition_by_name(data, params["definition_name"]) do {:ok, definition} -> variant = Enum.fetch!(definition.variants, params["variant_idx"]) socket = ensure_packages_then(socket, variant.packages, definition.name, "block", fn socket -> with {:ok, section, index} <- section_with_next_index(socket.private.data.notebook, section_id, cell_id) do attrs = %{source: variant.source} Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs) {:ok, socket} end end) {:noreply, socket} _ -> {:noreply, socket} end else reason = "To insert this block, you need a connected runtime." {:noreply, confirm_setup_default_runtime(socket, reason)} end end def handle_event("insert_smart_cell_below", params, socket) do data = socket.private.data %{"section_id" => section_id, "cell_id" => cell_id} = params case smart_cell_definition_by_kind(data, params["kind"]) do {:ok, definition} -> preset = if preset_idx = params["preset_idx"] do Enum.at(definition.requirement_presets, preset_idx) end packages = if(preset, do: preset.packages, else: []) socket = ensure_packages_then(socket, packages, definition.name, "smart cell", fn socket -> with {:ok, section, index} <- section_with_next_index(socket.private.data.notebook, section_id, cell_id) do attrs = %{kind: definition.kind} Session.insert_cell(socket.assigns.session.pid, section.id, index, :smart, attrs) {:ok, socket} end end) {:noreply, socket} _ -> {:noreply, socket} end end def handle_event("set_default_language", %{"language" => language} = params, socket) when language in ["elixir", "erlang"] do language = String.to_atom(language) Session.set_notebook_attributes(socket.assigns.session.pid, %{default_language: language}) {:noreply, insert_cell_below(socket, params)} end def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do on_confirm = fn socket -> Session.delete_cell(socket.assigns.session.pid, cell_id) socket end {:noreply, confirm(socket, on_confirm, title: "Delete cell", description: "Once you delete this cell, it will be moved to the bin.", confirm_text: "Delete", confirm_icon: "delete-bin-6-line", opt_out_id: "delete-cell" )} end def handle_event("set_notebook_name", %{"value" => 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", %{"metadata" => section_id, "value" => 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, "tag" => tag, "delta" => delta, "selection" => selection, "revision" => revision }, socket ) do tag = String.to_atom(tag) delta = Text.Delta.from_compressed(delta) selection = selection && Text.Selection.from_compressed(selection) Session.apply_cell_delta(socket.assigns.session.pid, cell_id, tag, delta, selection, revision) {:noreply, socket} end def handle_event( "report_cell_selection", %{"cell_id" => cell_id, "tag" => tag, "selection" => selection, "revision" => revision}, socket ) do selection = selection && Text.Selection.from_compressed(selection) tag = String.to_atom(tag) Phoenix.PubSub.broadcast_from( Livebook.PubSub, self(), "sessions:#{socket.assigns.session.id}", {:report_cell_selection, socket.assigns.client_id, cell_id, tag, selection, revision} ) {:noreply, socket} end def handle_event( "report_cell_revision", %{"cell_id" => cell_id, "tag" => tag, "revision" => revision}, socket ) do tag = String.to_atom(tag) Session.report_cell_revision(socket.assigns.session.pid, cell_id, tag, revision) {: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("delete_section", %{"section_id" => section_id}, socket) do socket = case Notebook.fetch_section(socket.private.data.notebook, section_id) do {:ok, %{cells: []} = section} -> Session.delete_section(socket.assigns.session.pid, section.id, true) socket {:ok, section} -> section_id = section.id first_section? = hd(socket.private.data.notebook.sections).id == section.id on_confirm = fn socket, %{"delete_cells" => delete_cells} -> Livebook.Session.delete_section(socket.assigns.session.pid, section_id, delete_cells) socket end assigns = %{section_name: section.name} description = ~H""" Are you sure you want to delete this section - “<%= @section_name %>”? """ confirm(socket, on_confirm, title: "Delete section", description: description, confirm_text: "Delete", confirm_icon: "delete-bin-6-line", options: [ %{ name: "delete_cells", label: "Delete all cells in this section", default: first_section?, disabled: first_section? } ] ) :error -> socket end {:noreply, socket} end def handle_event("recover_smart_cell", %{"cell_id" => cell_id}, socket) do Session.recover_smart_cell(socket.assigns.session.pid, cell_id) {:noreply, socket} end def handle_event("convert_smart_cell", %{"cell_id" => cell_id}, socket) do on_confirm = fn socket -> Session.convert_smart_cell(socket.assigns.session.pid, cell_id) socket end {:noreply, confirm(socket, on_confirm, title: "Convert cell", description: "Once you convert this Smart cell to a Code cell, the Smart cell will be moved to the bin.", confirm_text: "Convert", confirm_icon: "arrow-up-down-line", opt_out_id: "convert-smart-cell" )} end def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id} = params, socket) do data = socket.private.data {status, socket} = with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), true <- Cell.setup?(cell), false <- data.cell_infos[cell.id].eval.validity == :fresh do maybe_reconnect_runtime(socket) else _ -> {:ok, socket} end if params["disable_dependencies_cache"] do Session.disable_dependencies_cache(socket.assigns.session.pid) end if status == :ok do Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id) end {:noreply, socket} end @impl true def handle_event("queue_interrupted_cell_evaluation", %{"cell_id" => cell_id}, socket) do data = socket.private.data with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id), true <- data.cell_infos[cell.id].eval.interrupted do Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id) end {:noreply, socket} end def handle_event("queue_section_evaluation", %{"section_id" => section_id}, socket) do Session.queue_section_evaluation(socket.assigns.session.pid, section_id) {:noreply, socket} end def handle_event("queue_full_evaluation", %{"forced_cell_ids" => forced_cell_ids}, socket) do Session.queue_full_evaluation(socket.assigns.session.pid, forced_cell_ids) {: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( "set_reevaluate_automatically", %{"value" => value, "cell_id" => cell_id}, socket ) do Session.set_cell_attributes(socket.assigns.session.pid, cell_id, %{ reevaluate_automatically: value }) {: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: ~p"/sessions/#{socket.assigns.session.id}/settings/file")} end end def handle_event("reconnect_runtime", %{}, socket) do {_, socket} = maybe_reconnect_runtime(socket) {:noreply, socket} end def handle_event("connect_runtime", %{}, socket) do {_, socket} = connect_runtime(socket) {:noreply, socket} end def handle_event("setup_default_runtime", %{"reason" => reason}, socket) do {:noreply, confirm_setup_default_runtime(socket, reason)} end def handle_event("disconnect_runtime", %{}, socket) do Session.disconnect_runtime(socket.assigns.session.pid) {:noreply, socket} end def handle_event("deploy_app", _, socket) do data = socket.private.data app_settings = data.notebook.app_settings if Livebook.Notebook.AppSettings.valid?(app_settings) do {:noreply, LivebookWeb.SessionLive.AppSettingsComponent.preview_app( socket, app_settings, data.deployed_app_slug )} else {:noreply, push_patch(socket, to: ~p"/sessions/#{socket.assigns.session.id}/settings/app?context=preview" )} end 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 = Text.JS.js_column_to_elixir(column, line) {:details, line, column} %{"type" => "signature", "hint" => hint} -> {:signature, hint} %{"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 Runtime.connected?(data.runtime) do parent_locators = Session.parent_locators_for_cell(data, cell) node = intellisense_node(cell) ref = Runtime.handle_intellisense(data.runtime, self(), request, parent_locators, node) {:reply, %{"ref" => inspect(ref)}, socket} else info = cond do params["type"] == "completion" and not params["editor_auto_completion"] -> "You need to start a runtime (or evaluate a cell) for code completion" params["type"] == "format" -> "You need to start a runtime (or evaluate a cell) to enable code formatting" true -> 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, files_dir: files_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, files_source: {:dir, files_dir})} end def handle_event("close_session", %{}, socket) do {:noreply, confirm_close_session(socket, socket.assigns.session, redirect_to: ~p"/")} end def handle_event("star_notebook", %{}, socket) do data = socket.private.data Livebook.NotebookManager.add_starred_notebook(data.file, data.notebook.name) {:noreply, socket} end def handle_event("unstar_notebook", %{}, socket) do Livebook.NotebookManager.remove_starred_notebook(socket.private.data.file) {:noreply, socket} end def handle_event("erase_outputs", %{}, socket) do Session.erase_outputs(socket.assigns.session.pid) {:noreply, socket} end def handle_event("location_report", report, socket) do Phoenix.PubSub.broadcast_from( Livebook.PubSub, self(), "sessions:#{socket.assigns.session.id}", {:location_report, socket.assigns.client_id, report} ) {:noreply, socket} end def handle_event( "select_secret", %{ "js_view_ref" => select_secret_ref, "preselect_name" => preselect_name, "options" => select_secret_options }, socket ) do socket = assign(socket, select_secret_metadata: %{ ref: select_secret_ref, options: select_secret_options } ) {:noreply, push_patch(socket, to: ~p"/sessions/#{socket.assigns.session.id}/secrets?secret_name=#{preselect_name}" )} end def handle_event("select_hub", %{"id" => id}, socket) do Session.set_notebook_hub(socket.assigns.session.pid, id) {:noreply, socket} end def handle_event("review_file_entry_access", %{"name" => name}, socket) do if file_entry = find_file_entry(socket, name) do on_confirm = fn socket -> Session.allow_file_entry(socket.assigns.session.pid, file_entry.name) socket end file_system_label = case Livebook.FileSystem.File.fetch_file_system(file_entry.file) do {:ok, file_system} -> file_system_label(file_system) _ -> "Not available" end assigns = %{ name: file_entry.name, file: file_entry.file, file_system_label: file_system_label } description = ~H"""
File “<%= @name %>“ points to an absolute path, do you want the notebook to access it?
<.labeled_text label="Path"><%= @file.path %> <.labeled_text label="File system"><%= @file_system_label %>
""" {:noreply, confirm(socket, on_confirm, title: "Review access", description: description, confirm_text: "Allow access", confirm_icon: "shield-check-line", danger: false )} else {:noreply, socket} end end def handle_event("insert_image", params, socket) do {:noreply, socket |> assign( insert_image_metadata: %{section_id: params["section_id"], cell_id: params["cell_id"]} ) |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-image")} end def handle_event( "insert_file", %{"section_id" => section_id, "cell_id" => cell_id, "file_entry_name" => file_entry_name}, socket ) do if file_entry = find_file_entry(socket, file_entry_name) do if Livebook.Runtime.connected?(socket.private.data.runtime) do {:noreply, socket |> assign( insert_file_metadata: %{ section_id: section_id, cell_id: cell_id, file_entry: file_entry, handlers: handlers_for_file_entry(file_entry, socket.private.data.runtime) } ) |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-file")} else reason = "To see the available options, you need a connected runtime." {:noreply, confirm_setup_default_runtime(socket, reason)} end else {:noreply, socket} end end def handle_event("insert_file_action", %{"idx" => idx}, socket) do %{section_id: section_id, cell_id: cell_id, file_entry: file_entry, handlers: handlers} = socket.assigns.action_assigns.insert_file_metadata handler = Enum.fetch!(handlers, idx) source = String.replace(handler.definition.source, "{{NAME}}", file_entry.name) socket = socket |> redirect_to_self() |> ensure_packages_then( handler.definition.packages, handler.definition.description, "action", fn socket -> with {:ok, section, index} <- section_with_next_index(socket.private.data.notebook, section_id, cell_id) do attrs = %{source: source} Session.insert_cell( socket.assigns.session.pid, section.id, index, handler.cell_type, attrs ) {:ok, socket} end end ) {:noreply, socket} end def handle_event( "handle_file_drop", %{"section_id" => section_id, "cell_id" => cell_id}, socket ) do if Livebook.Runtime.connected?(socket.private.data.runtime) do {:noreply, socket |> assign(file_drop_metadata: %{section_id: section_id, cell_id: cell_id}) |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/add-file/upload") |> push_event("finish_file_drop", %{})} else reason = "To see the available options, you need a connected runtime." {:noreply, confirm_setup_default_runtime(socket, reason)} end end def handle_event("handle_file_drop", %{}, socket) do {:noreply, socket |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/add-file/upload") |> push_event("finish_file_drop", %{})} end def handle_event("open_custom_view_settings", %{}, socket) do {:noreply, push_patch(socket, to: ~p"/sessions/#{socket.assigns.session.id}/settings/custom-view")} end @impl true def handle_call({:get_input_value, input_id}, _from, socket) do reply = case socket.private.data.input_infos do %{^input_id => %{value: value}} -> {:ok, socket.assigns.session.id, value} %{} -> :error end {:reply, reply, socket} end @impl true def handle_info({:operation, operation}, socket) do {:noreply, handle_operation(socket, operation)} end def handle_info({:error, error}, socket) do message = error |> to_string() |> upcase_first() socket = put_flash(socket, :error, message) {:noreply, socket} 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({:hydrate_cell_source_digest, cell_id, tag, digest}, socket) do data = socket.private.data data = put_in(data.cell_infos[cell_id].sources[tag].digest, digest) # We don't recompute data_view, because for the cell indicator we # still compute the digest on the client side {:noreply, assign_private(socket, data: 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_navigate(to: ~p"/")} end def handle_info({:runtime_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({:location_report, client_id, report}, socket) do report = Map.put(report, :client_id, client_id) {:noreply, push_event(socket, "location_report", report)} end def handle_info({:report_cell_selection, client_id, cell_id, tag, selection, revision}, socket) do # Note that we transform the delta separately in each client's LV. # This way we avoid a race condition with session sending us a new # delta. We either acknowledge such delta and use it to transform # the incoming selection, or send the selection as is and only then # acknowledge the delta. case Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) do {:ok, _cell, _section} -> selection = selection && Session.Data.transform_selection( socket.private.data, cell_id, tag, selection, revision ) payload = %{ "client_id" => client_id, "selection" => selection && Text.Selection.to_compressed(selection) } {:noreply, push_event(socket, "cell_selection:#{cell_id}:#{tag}", payload)} _ -> {:noreply, socket} end end def handle_info({:set_input_values, values, local}, socket) do if local do socket = Enum.reduce(values, socket, fn {input_id, value}, socket -> operation = {:set_input_value, socket.assigns.client_id, input_id, value} handle_operation(socket, operation) end) {:noreply, socket} else for {input_id, value} <- values do Session.set_input_value(socket.assigns.session.pid, input_id, value) end {:noreply, socket} end end def handle_info({:queue_bound_cells_evaluation, input_id}, socket) do Session.queue_bound_cells_evaluation(socket.assigns.session.pid, input_id) {:noreply, socket} end def handle_info({:insert_image_complete, metadata, url}, socket) do params = %{ "type" => "image", "section_id" => metadata.section_id, "cell_id" => metadata.cell_id, "url" => url } {:noreply, insert_cell_below(socket, params)} end def handle_info({:file_entry_uploaded, file_entry}, socket) do case socket.assigns.action_assigns[:file_drop_metadata] do %{section_id: section_id, cell_id: cell_id} -> {:noreply, socket |> assign( insert_file_metadata: %{ section_id: section_id, cell_id: cell_id, file_entry: file_entry, handlers: handlers_for_file_entry(file_entry, socket.private.data.runtime) } ) |> push_patch(to: ~p"/sessions/#{socket.assigns.session.id}/insert-file")} nil -> {:noreply, push_patch(socket, to: ~p"/sessions/#{socket.assigns.session.id}")} end end def handle_info({:put_flash, kind, message}, socket) do {:noreply, put_flash(socket, kind, message)} end def handle_info({:starred_notebooks_updated, starred_notebooks}, socket) do {:noreply, assign(socket, starred_files: starred_files(starred_notebooks))} end def handle_info({:app_updated, app}, socket) when socket.assigns.app != nil do {:noreply, assign(socket, :app, app)} end def handle_info(_message, socket), do: {:noreply, socket} defp handle_relative_path(socket, path, requested_url) do cond do String.ends_with?(path, Livebook.LiveMarkdown.extension()) -> handle_relative_notebook_path(socket, path, requested_url) 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, requested_url) 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 = Notebook.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} -> redirect_path = session_path(session_id, url_hash: get_url_hash(requested_url)) push_navigate(socket, to: redirect_path) {:error, :none} -> open_notebook(socket, origin, fallback_locations, requested_url) {: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 get_url_hash(requested_url) do case String.split(requested_url, "#") do [_, url_hash] -> url_hash _ -> nil end end defp open_notebook(socket, origin, fallback_locations, requested_url) do case fetch_content_with_fallbacks(origin, fallback_locations) do {:ok, content} -> {notebook, %{warnings: messages}} = Livebook.LiveMarkdown.notebook_from_livemd(content) # 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) socket |> put_import_warnings(messages) |> create_session( notebook: notebook, origin: origin, file: file, url_hash: url_hash ) {:error, message} -> socket |> put_flash(:error, "Cannot navigate, " <> message) |> redirect_to_self() 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 = Notebook.ContentLoader.resolve_location(resolution_location, name) [flat_location] end defp fetch_content_with_fallbacks(location, fallbacks) do case Notebook.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, _} <- Notebook.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)} defp file_and_notebook(_fork?, _origin, notebook), do: {nil, notebook} defp session_id_by_location(location) do sessions = Livebook.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: ~p"/sessions/#{socket.assigns.session.id}") end defp handle_operation(socket, operation) do case Session.Data.apply_operation(socket.private.data, operation) do {:ok, data, actions} -> socket |> assign_private(data: data) |> assign( data_view: update_data_view(socket.assigns.data_view, socket.private.data, data, operation) ) |> after_operation(socket, operation) |> handle_actions(actions) :error -> socket end end defp after_operation(socket, _prev_socket, {:client_join, client_id, user}) do push_event(socket, "client_joined", %{client: client_info(client_id, user)}) end defp after_operation(socket, _prev_socket, {:client_leave, client_id}) do push_event(socket, "client_left", %{client_id: client_id}) end defp after_operation(socket, _prev_socket, {:update_user, _client_id, user}) do updated_clients = socket.private.data.clients_map |> Enum.filter(fn {_client_id, user_id} -> user_id == user.id end) |> Enum.map(fn {client_id, _user_id} -> client_info(client_id, user) end) push_event(socket, "clients_updated", %{clients: updated_clients}) end defp after_operation(socket, _prev_socket, {:set_notebook_name, _client_id, name}) do assign(socket, page_title: get_page_title(name)) end defp after_operation(socket, _prev_socket, {:insert_section, client_id, _index, section_id}) do if client_id == socket.assigns.client_id do push_event(socket, "section_inserted", %{section_id: section_id}) else socket end end defp after_operation( socket, _prev_socket, {:insert_section_into, client_id, _section_id, _index, section_id} ) do if client_id == socket.assigns.client_id do push_event(socket, "section_inserted", %{section_id: section_id}) else socket end end defp after_operation( socket, _prev_socket, {:delete_section, _client_id, section_id, _delete_cells} ) do push_event(socket, "section_deleted", %{section_id: section_id}) end defp after_operation(socket, _prev_socket, {:insert_cell, client_id, _, _, _, cell_id, _attrs}) do {:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) socket = push_cell_editor_payloads(socket, socket.private.data, [cell]) socket = prune_cell_sources(socket) if client_id == socket.assigns.client_id do push_event(socket, "cell_inserted", %{cell_id: cell_id}) else socket end end defp after_operation(socket, prev_socket, {:delete_cell, _client_id, 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_id, cell_id}) do {:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) socket = push_cell_editor_payloads(socket, socket.private.data, [cell]) socket = prune_cell_sources(socket) if client_id == socket.assigns.client_id do push_event(socket, "cell_restored", %{cell_id: cell_id}) else socket end end defp after_operation(socket, _prev_socket, {:move_cell, client_id, cell_id, _offset}) do if client_id == socket.assigns.client_id do push_event(socket, "cell_moved", %{cell_id: cell_id}) else socket end end defp after_operation(socket, _prev_socket, {:move_section, client_id, section_id, _offset}) do if client_id == socket.assigns.client_id do push_event(socket, "section_moved", %{section_id: section_id}) else socket end end defp after_operation( socket, _prev_socket, {:add_cell_evaluation_output, _client_id, _cell_id, _output} ) do prune_outputs(socket) end defp after_operation( socket, _prev_socket, {:add_cell_evaluation_response, _client_id, cell_id, _output, metadata} ) do socket |> prune_outputs() |> push_event("evaluation_finished:#{cell_id}", %{code_markers: metadata.code_markers}) end defp after_operation( socket, _prev_socket, {:add_cell_doctest_report, _client_id, cell_id, doctest_report} ) do push_event(socket, "doctest_report:#{cell_id}", doctest_report_payload(doctest_report)) end defp after_operation( socket, _prev_socket, {:smart_cell_started, _client_id, cell_id, _delta, _chunks, _js_view, _editor} ) do {:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) socket |> push_cell_editor_payloads(socket.private.data, [cell], [:secondary]) |> prune_cell_sources() end defp after_operation(socket, _prev_socket, {:erase_outputs, _client_id}) do push_event(socket, "erase_outputs", %{}) end defp after_operation(socket, prev_socket, {:set_deployed_app_slug, _client_id, slug}) do prev_slug = prev_socket.private.data.deployed_app_slug if slug == prev_slug do socket else if prev_slug do Livebook.App.unsubscribe(prev_slug) end app = if slug do {:ok, app} = Livebook.Apps.fetch_app(slug) Livebook.App.subscribe(slug) app end assign(socket, app: app) end 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, {:report_delta, client_id, cell, tag, delta, selection}) do if client_id == socket.assigns.client_id do push_event(socket, "cell_acknowledgement:#{cell.id}:#{tag}", %{}) else push_event(socket, "cell_delta:#{cell.id}:#{tag}", %{ client_id: client_id, delta: Text.Delta.to_compressed(delta), selection: selection && Text.Selection.to_compressed(selection) }) end end defp handle_action(socket, {:start_evaluation, cell, _section}) do push_event(socket, "start_evaluation:#{cell.id}", %{}) end defp handle_action(socket, _action), do: socket defp client_info(id, user) do %{id: id, 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_below(socket, params) do {type, attrs} = cell_type_and_attrs_from_params(params, socket) with {:ok, section, index} <- section_with_next_index( socket.private.data.notebook, params["section_id"], params["cell_id"] ) do Session.insert_cell(socket.assigns.session.pid, section.id, index, type, attrs) end socket end defp cell_type_and_attrs_from_params(%{"type" => "markdown"}, _socket), do: {:markdown, %{}} defp cell_type_and_attrs_from_params(%{"type" => "code"} = params, socket) do language = case params["language"] do language when language in ["elixir", "erlang"] -> String.to_atom(language) _ -> socket.private.data.notebook.default_language end {:code, %{language: language}} end defp cell_type_and_attrs_from_params(%{"type" => "diagram"}, _socket) do source = """ ```mermaid graph TD; A-->B; A-->C; B-->D; C-->D; ```\ """ {:markdown, %{source: source}} end defp cell_type_and_attrs_from_params(%{"type" => "image", "url" => url}, _socket) do source = "![](#{url})" {:markdown, %{source: source}} end defp section_with_next_index(notebook, section_id, cell_id) defp section_with_next_index(notebook, section_id, nil) do with {:ok, section} <- Notebook.fetch_section(notebook, section_id) do {:ok, section, 0} end end defp section_with_next_index(notebook, _section_id, cell_id) do with {:ok, cell, section} <- Notebook.fetch_cell_and_section(notebook, cell_id) do index = Enum.find_index(section.cells, &(&1 == cell)) {:ok, section, index + 1} end 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 process_intellisense_response( %{range: %{from: from, to: to}} = response, {:details, line, _column} ) do %{ response | range: %{ from: Text.JS.elixir_column_to_js(from, line), to: Text.JS.elixir_column_to_js(to, line) } } end defp process_intellisense_response(%{code: code} = response, {:format, original_code}) do delta = if code do original_code |> Text.Delta.diff(code) |> Text.Delta.to_compressed() end response |> Map.delete(:code) |> Map.put(:delta, delta) 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 defp connect_runtime(socket) do case Runtime.connect(socket.private.data.runtime) do {:ok, runtime} -> Session.set_runtime(socket.assigns.session.pid, runtime) {:ok, socket} {:error, message} -> {:error, put_flash(socket, :error, "Failed to connect runtime - #{message}")} end end defp maybe_reconnect_runtime(%{private: %{data: data}} = socket) do if Runtime.connected?(data.runtime) do data.runtime |> Runtime.duplicate() |> Runtime.connect() |> case do {:ok, new_runtime} -> Session.set_runtime(socket.assigns.session.pid, new_runtime) {:ok, clear_flash(socket, :error)} {:error, message} -> {:error, put_flash(socket, :error, "Failed to connect runtime - #{message}")} end else {:ok, socket} end end defp confirm_setup_default_runtime(socket, reason) do on_confirm = fn socket -> {status, socket} = connect_runtime(socket) if status == :ok do Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id()) end socket end confirm(socket, on_confirm, title: "Setup runtime", description: "#{reason} Do you want to connect and setup the default one?", confirm_text: "Setup runtime", confirm_icon: "play-line", danger: false ) end defp starred_files(starred_notebooks) do for info <- starred_notebooks, into: MapSet.new(), do: info.file end defp example_snippet_definition_by_name(data, name) do data.runtime |> Livebook.Runtime.snippet_definitions() |> Enum.find_value(:error, &(&1.type == :example && &1.name == name && {:ok, &1})) end defp smart_cell_definition_by_kind(data, kind) do Enum.find_value(data.smart_cell_definitions, :error, &(&1.kind == kind && {:ok, &1})) end defp add_dependencies_and_reevaluate(socket, dependencies) do Session.add_dependencies(socket.assigns.session.pid, dependencies) {status, socket} = maybe_reconnect_runtime(socket) if status == :ok do Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id()) Session.queue_cells_reevaluation(socket.assigns.session.pid) end socket end defp ensure_packages_then(socket, packages, target_name, target_type, fun) do dependencies = Enum.map(packages, & &1.dependency) has_dependencies? = dependencies == [] or Livebook.Runtime.has_dependencies?(socket.private.data.runtime, dependencies) cond do has_dependencies? -> case fun.(socket) do {:ok, socket} -> socket :error -> socket end Livebook.Runtime.fixed_dependencies?(socket.private.data.runtime) -> put_flash(socket, :error, "This runtime doesn't support adding dependencies") true -> on_confirm = fn socket -> case fun.(socket) do {:ok, socket} -> add_dependencies_and_reevaluate(socket, dependencies) :error -> socket end end confirm_add_packages(socket, on_confirm, packages, target_name, target_type) end end defp confirm_add_packages(socket, on_confirm, packages, target_name, target_type) do assigns = %{packages: packages, target_name: target_name, target_type: target_type} description = ~H""" The “<%= @target_name %>“ <%= @target_type %> requires the <.listing items={@packages}> <:item :let={package}><%= package.name %> <:singular_suffix>package. Do you want to add it as a dependency and restart? <:plural_suffix>packages. Do you want to add them as dependencies and restart? """ confirm(socket, on_confirm, title: "Add packages", description: description, confirm_text: "Add and restart", confirm_icon: "add-line", danger: false ) end defp push_cell_editor_payloads(socket, data, cells, tags \\ :all) do for cell <- cells, {tag, payload} <- cell_editor_init_payloads(cell, data.cell_infos[cell.id]), tags == :all or tag in tags, reduce: socket do socket -> push_event(socket, "cell_editor_init:#{cell.id}:#{tag}", payload) end end defp cell_editor_init_payloads(%Cell.Code{} = cell, cell_info) do [ primary: %{ source: cell.source, revision: cell_info.sources.primary.revision, code_markers: cell_info.eval.code_markers, doctest_reports: for {_, doctest_report} <- cell_info.eval.doctest_reports do doctest_report_payload(doctest_report) end } ] end defp cell_editor_init_payloads(%Cell.Markdown{} = cell, cell_info) do [ primary: %{ source: cell.source, revision: cell_info.sources.primary.revision, code_markers: [], doctest_reports: [] } ] end defp cell_editor_init_payloads(%Cell.Smart{} = cell, cell_info) do [ primary: %{ source: cell.source, revision: cell_info.sources.primary.revision, code_markers: cell_info.eval.code_markers, doctest_reports: for {_, doctest_report} <- cell_info.eval.doctest_reports do doctest_report_payload(doctest_report) end } ] ++ if cell.editor && cell_info.status == :started do [ secondary: %{ source: cell.editor.source, revision: cell_info.sources.secondary.revision, code_markers: [], doctest_reports: [] } ] else [] end end defp doctest_report_payload(doctest_report) do Map.replace_lazy(doctest_report, :details, fn details -> details |> LivebookWeb.ANSIHelpers.ansi_string_to_html() |> Phoenix.HTML.safe_to_string() end) end defp find_file_entry(socket, name) do Enum.find(socket.private.data.notebook.file_entries, &(&1.name == name)) end defp handlers_for_file_entry(file_entry, runtime) do handlers = for definition <- Livebook.Runtime.snippet_definitions(runtime), definition.type == :file_action, do: %{definition: definition, cell_type: :code} handlers = if file_entry.type == :attachment do [ %{ cell_type: :markdown, definition: %{ type: :file_action, description: "Insert as Markdown image", source: "![](files/{{NAME}})", file_types: ["image/*"], packages: [] } } | handlers ] else handlers end handlers |> Enum.filter(&matches_file_types?(file_entry.name, &1.definition.file_types)) |> Enum.sort_by(& &1.definition.description) end defp matches_file_types?(_name, :any), do: true defp matches_file_types?(name, file_types) do mime_type = MIME.from_path(name) extension = Path.extname(name) Enum.any?(file_types, fn file_type -> case String.split(file_type, "/") do [group, "*"] -> String.starts_with?(mime_type, group <> "/") _ -> file_type == mime_type or file_type == extension end end) end # 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 changed_input_ids = Session.Data.changed_input_ids(data) %{ file: data.file, persist_outputs: data.notebook.persist_outputs, autosave_interval_s: data.notebook.autosave_interval_s, default_language: data.notebook.default_language, dirty: data.dirty, persistence_warnings: data.persistence_warnings, runtime: data.runtime, smart_cell_definitions: Enum.sort_by(data.smart_cell_definitions, & &1.name), example_snippet_definitions: data.runtime |> Livebook.Runtime.snippet_definitions() |> Enum.filter(&(&1.type == :example)) |> Enum.sort_by(& &1.name), global_status: global_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), status: cells_status(section.cells, data) } end, clients: data.clients_map |> Enum.map(fn {client_id, user_id} -> {client_id, data.users_map[user_id]} end) |> Enum.sort_by(fn {_client_id, user} -> user.name || "Anonymous" end), installing?: data.cell_infos[Cell.setup_cell_id()].eval.status == :evaluating, setup_cell_view: %{ cell_to_view(hd(data.notebook.setup_section.cells), data, changed_input_ids) | type: :setup }, section_views: section_views(data.notebook.sections, data, changed_input_ids), bin_entries: data.bin_entries, secrets: data.secrets, hub: Livebook.Hubs.fetch_hub!(data.notebook.hub_id), hub_secrets: data.hub_secrets, any_session_secrets?: Session.Data.session_secrets(data.secrets, data.notebook.hub_id) != [], file_entries: Enum.sort_by(data.notebook.file_entries, & &1.name), quarantine_file_entry_names: data.notebook.quarantine_file_entry_names, app_settings: data.notebook.app_settings, deployed_app_slug: data.deployed_app_slug, deployment_group_id: data.notebook.deployment_group_id } end defp cells_status(cells, data) do eval_infos = for cell <- cells, Cell.evaluable?(cell), info = data.cell_infos[cell.id].eval, do: Map.put(info, :id, cell.id) most_recent = eval_infos |> Enum.filter(& &1.evaluation_end) |> Enum.max_by(& &1.evaluation_end, DateTime, fn -> nil end) cond do evaluating = Enum.find(eval_infos, &(&1.status == :evaluating)) -> {:evaluating, evaluating.id} most_recent != nil and most_recent.errored -> {:errored, most_recent.id} stale = Enum.find(eval_infos, &(&1.validity == :stale)) -> {:stale, stale.id} most_recent != nil -> {:evaluated, most_recent.id} true -> {:fresh, nil} end end defp global_status(data) do cells = data.notebook |> Notebook.evaluable_cells_with_section() |> Enum.map(fn {cell, _} -> cell end) cells_status(cells, data) end defp section_views(sections, data, changed_input_ids) do sections |> Enum.map(& &1.name) |> LivebookWeb.HTMLHelpers.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, changed_input_ids)) } 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.Markdown{} = cell, _data, _changed_input_ids) do %{ id: cell.id, type: :markdown, empty: cell.source == "" } end defp cell_to_view(%Cell.Code{} = cell, data, changed_input_ids) do info = data.cell_infos[cell.id] %{ id: cell.id, type: :code, language: cell.language, empty: cell.source == "", eval: eval_info_to_view(cell, info.eval, data, changed_input_ids), reevaluate_automatically: cell.reevaluate_automatically } end defp cell_to_view(%Cell.Smart{} = cell, data, changed_input_ids) do info = data.cell_infos[cell.id] %{ id: cell.id, type: :smart, empty: cell.source == "", eval: eval_info_to_view(cell, info.eval, data, changed_input_ids), status: info.status, js_view: cell.js_view, editor: cell.editor && %{ empty: cell.editor.source == "", language: cell.editor.language, placement: cell.editor.placement } } end defp eval_info_to_view(cell, eval_info, data, changed_input_ids) do %{ outputs: cell.outputs, doctest_summary: doctest_summary(eval_info.doctest_reports), validity: eval_info.validity, status: eval_info.status, errored: eval_info.errored, reevaluates_automatically: eval_info.reevaluates_automatically, evaluation_time_ms: eval_info.evaluation_time_ms, evaluation_start: eval_info.evaluation_start, evaluation_digest: encode_digest(eval_info.evaluation_digest), outputs_batch_number: eval_info.outputs_batch_number, # Pass input values relevant to the given cell input_views: input_views_for_cell(cell, data, changed_input_ids) } end defp doctest_summary(doctests) do {doctests_count, failures_count} = Enum.reduce(doctests, {0, 0}, fn {_line, %{status: status}}, {total, failed} -> {total + 1, if(status == :failed, do: failed + 1, else: failed)} end) %{doctests_count: doctests_count, failures_count: failures_count} end defp input_views_for_cell(cell, data, changed_input_ids) do input_ids = for output <- cell.outputs, input <- Cell.find_inputs_in_output(output), do: input.id data.input_infos |> Map.take(input_ids) |> Map.new(fn {input_id, %{value: value}} -> {input_id, %{value: value, changed: MapSet.member?(changed_input_ids, input_id)}} end) 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, prev_data, data, operation) do case operation do {:report_cell_revision, _client_id, _cell_id, _tag, _revision} -> data_view {:apply_cell_delta, _client_id, _cell_id, _tag, _delta, _selection, _revision} -> update_dirty_status(data_view, data) {:update_smart_cell, _client_id, _cell_id, _cell_state, _delta, _chunks} -> update_dirty_status(data_view, data) {:add_cell_evaluation_output, _client_id, cell_id, output} -> case send_output_update(prev_data, data, cell_id, output) do :ok -> data_view :continue -> data_to_view(data) end {:add_cell_doctest_report, _client_id, _cell_id, _doctest_report} -> data_view _ -> data_to_view(data) end end # For outputs that update existing outputs we send the update directly # to the corresponding component, so the DOM patch is isolated and fast. # This is important for intensive output updates def send_output_update(prev_data, data, _cell_id, %{type: :frame_update} = output) do %{ref: ref, update: {update_type, new_outputs}} = output changed_input_ids = Session.Data.changed_input_ids(data) # Lookup in previous data to see if there is a chunked output, # and if so, send the chunk update directly to its component with :append <- update_type, [{{_idx, frame}, _cell} | _] <- Notebook.find_frame_outputs(prev_data.notebook, ref), %{outputs: [{idx, %{type: type, chunk: true}} | _]} <- frame do new_outputs |> Enum.reverse() |> Enum.take_while(&match?(%{type: ^type, chunk: true}, &1)) |> Enum.each(fn output -> send_chunk_output_update(idx, output) end) end for {{idx, frame}, cell} <- Notebook.find_frame_outputs(data.notebook, ref) do # Note that we are not updating data_view to avoid re-render, # but any change that causes frame to re-render will update # data_view first input_views = input_views_for_cell(cell, data, changed_input_ids) send_update(LivebookWeb.Output.FrameComponent, id: "outputs-#{idx}-output", event: {:update, update_type, frame.outputs, input_views} ) end :ok end def send_output_update(prev_data, _data, cell_id, %{type: type, chunk: true} = output) when type in [:terminal_text, :plain_text, :markdown] do # Lookup in previous data to see if the output is already there case Notebook.fetch_cell_and_section(prev_data.notebook, cell_id) do {:ok, %{outputs: [{idx, %{type: ^type, chunk: true}} | _]}, _section} -> send_chunk_output_update(idx, output) :ok _ -> :continue end end def send_output_update(_prev_data, _data, _cell_id, _output) do :continue end defp send_chunk_output_update(idx, output) do module = case output.type do :terminal_text -> LivebookWeb.Output.TerminalTextComponent :plain_text -> LivebookWeb.Output.PlainTextComponent :markdown -> LivebookWeb.Output.MarkdownComponent end send_update(module, id: "outputs-#{idx}-output", event: {:append, output.text}) end defp prune_outputs(%{private: %{data: data}} = socket) do assign_private( socket, data: update_in(data.notebook, &Notebook.prune_cell_outputs/1) ) end defp prune_cell_sources(%{private: %{data: data}} = socket) do assign_private( socket, data: update_in( data.notebook, &Notebook.update_cells(&1, fn %Notebook.Cell.Smart{} = cell -> %{cell | source: :__pruned__, attrs: :__pruned__} |> prune_smart_cell_editor_source() %{source: _} = cell -> %{cell | source: :__pruned__} cell -> cell end) ) ) end defp prune_smart_cell_editor_source(%{editor: %{source: _}} = cell), do: put_in(cell.editor.source, :__pruned__) defp prune_smart_cell_editor_source(cell), do: cell # Changes that affect only a single cell are still likely to # have impact on dirtiness, so we need to always mirror it defp update_dirty_status(data_view, data) do put_in(data_view.dirty, data.dirty) end defp get_page_title(notebook_name) do "#{notebook_name} - Livebook" end defp intellisense_node(%Cell.Smart{editor: %{intellisense_node: node_cookie}}), do: node_cookie defp intellisense_node(_), do: nil defp any_stale_cell?(data) do data.notebook |> Notebook.evaluable_cells_with_section() |> Enum.any?(fn {cell, _section} -> data.cell_infos[cell.id].eval.validity == :stale end) end end