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"""
<%= 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: "",
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