defmodule LivebookWeb.SessionLive.PersistenceLive do # TODO: rewrite this live view as a component, once live_view # has a unified way of sending events programmatically from a child # component to parent live view or component. Currently we send an # event to self() from FileSelectComponent and use handle_info in # the parent live view. use LivebookWeb, :live_view import LivebookWeb.FileSystemHelpers alias Livebook.{Sessions, Session, LiveMarkdown, FileSystem} @impl true def mount( _params, %{ "session" => session, "file" => file, "persist_outputs" => persist_outputs, "autosave_interval_s" => autosave_interval_s }, socket ) do sessions = Sessions.list_sessions() running_files = Enum.map(sessions, & &1.file) attrs = %{ file: file, persist_outputs: persist_outputs, autosave_interval_s: autosave_interval_s } {:ok, assign(socket, session: session, running_files: running_files, attrs: attrs, new_attrs: attrs, draft_file: nil, saved_file: nil )} end @impl true def render(assigns) do ~H"""

Save to file

<.switch_checkbox name="persist_outputs" label="Persist outputs" checked={@new_attrs.persist_outputs} />
Autosave <.select name="autosave_interval_s" selected={@new_attrs.autosave_interval_s} options={[ {5, "every 5 seconds"}, {30, "every 30 seconds"}, {60, "every minute"}, {600, "every 10 minutes"}, {nil, "never"} ]} />
File: <%= if @new_attrs.file do %> [<.file_system_icon file_system={@new_attrs.file.file_system} />] <%= @new_attrs.file.path %> <% else %> no file selected <%= unless @draft_file do %> <% end %> <% end %>
<%= if @draft_file do %>
<.live_component module={LivebookWeb.FileSelectComponent} id="persistence_file_select" file={@draft_file} extnames={[LiveMarkdown.extension()]} running_files={@running_files} submit_event={:confirm_file}>
File: <%= normalize_file(@draft_file).path %>
<% end %>
<%= if @new_attrs.file do %> <% else %> <% end %>
""" end @impl true def handle_event("open_file_select", %{}, socket) do file = socket.assigns.new_attrs.file || Livebook.Config.local_filesystem_home() {:noreply, assign(socket, draft_file: file)} end def handle_event("close_file_select", %{}, socket) do {:noreply, assign(socket, draft_file: nil)} end def handle_event("confirm_file", %{}, socket) do handle_confirm_file(socket) end def handle_event("clear_file", %{}, socket) do {:noreply, socket |> put_new_file(nil) |> assign(draft_file: nil)} end def handle_event( "set_options", %{"persist_outputs" => persist_outputs, "autosave_interval_s" => autosave_interval_s}, socket ) do persist_outputs = persist_outputs == "true" autosave_interval_s = parse_optional_integer(autosave_interval_s) {:noreply, socket |> put_new_attr(:persist_outputs, persist_outputs) |> put_new_attr(:autosave_interval_s, autosave_interval_s)} end def handle_event("save", %{}, %{assigns: assigns} = socket) do %{new_attrs: new_attrs, attrs: attrs} = assigns new_attrs = Map.update!(new_attrs, :file, &normalize_file/1) diff = map_diff(new_attrs, attrs) if Map.has_key?(diff, :file) do Session.set_file(assigns.session.pid, diff.file) end notebook_attrs_diff = Map.take(diff, [:autosave_interval_s, :persist_outputs]) if notebook_attrs_diff != %{} do Session.set_notebook_attributes(assigns.session.pid, notebook_attrs_diff) end if new_attrs.file do Session.save_sync(assigns.session.pid) end running_files = [new_attrs.file | assigns.running_files] |> List.delete(attrs.file) |> Enum.reject(&is_nil/1) {:noreply, assign(socket, running_files: running_files, attrs: assigns.new_attrs, saved_file: new_attrs.file )} end @impl true def handle_info({:set_file, file, _file_info}, socket) do {:noreply, assign(socket, draft_file: file)} end def handle_info(:confirm_file, socket) do handle_confirm_file(socket) end defp handle_confirm_file(socket) do file = normalize_file(socket.assigns.draft_file) {:noreply, socket |> put_new_file(file) |> assign(draft_file: nil)} end defp parse_optional_integer(string) do case Integer.parse(string) do {number, _} -> number :error -> nil end end defp put_new_file(socket, file) do new_attrs = socket.assigns.new_attrs current_file_system = new_attrs.file && new_attrs.file.file_system new_file_system = file && file.file_system autosave_interval_s = case new_file_system do ^current_file_system -> new_attrs.autosave_interval_s nil -> Livebook.Notebook.default_autosave_interval_s() %FileSystem.Local{} -> Livebook.Notebook.default_autosave_interval_s() _other -> nil end socket |> put_new_attr(:file, file) |> put_new_attr(:autosave_interval_s, autosave_interval_s) end defp put_new_attr(socket, key, value) do new_attrs = socket.assigns.new_attrs if new_attrs[key] == value do socket else new_attrs = put_in(new_attrs[key], value) assign(socket, :new_attrs, new_attrs) end end defp normalize_file(nil), do: nil defp normalize_file(file) do Map.update!(file, :path, fn path -> if String.ends_with?(path, LiveMarkdown.extension()) do path else path <> LiveMarkdown.extension() end end) end defp savable?(new_attrs, attrs, running_files, draft_file) do new_attrs = Map.update!(new_attrs, :file, &normalize_file/1) valid_file? = new_attrs.file == attrs.file or file_savable?(new_attrs.file, running_files) valid_file? and draft_file == nil end defp same_attrs?(new_attrs, attrs) do new_attrs = Map.update!(new_attrs, :file, &normalize_file/1) new_attrs == attrs end defp file_savable?(nil, _running_files), do: true defp file_savable?(file, running_files) do not FileSystem.File.dir?(file) and file not in running_files end defp map_diff(left, right) do Map.new(Map.to_list(left) -- Map.to_list(right)) end end