From 0925ec77cd1ed5715784df05c8865da24fc4e59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Sun, 21 Feb 2021 16:54:44 +0100 Subject: [PATCH] Implement notebook persistence and import (#44) * Basic filesystem navigation * Add file picker modal * Implement autosave when dirty and show the status * Add hompage link in the session view * Improve file picker and use in both places * Move session list to homepage * Some refactoring * Show import messages if any * Fix and extend tests * Show a message when there are no sessions running * Rename import to fork and make that clear in notebook name * Fix old route * Show info when no file is connected to the given session * Show runtime type next to filename * Show button for joining session when a running path is selected * Move modal components to SessionLive namespace * Add FileGuard to lock files used for notebook persistence * Use radio for specifying persistence type * Don't lock nil path * Simplify FileGuard implementation * Test notebook persistence * Fix typo * Further simplify FileGuard * Improve file listing * Don't show parent dir when there's a basename being typed * Add path component tests --- .gitignore | 3 + assets/css/components.css | 59 ++++++- assets/css/markdown.css | 14 +- assets/js/app.js | 2 + assets/js/focus_on_update/index.js | 19 ++ lib/live_book/application.ex | 2 + lib/live_book/live_markdown.ex | 2 + lib/live_book/session.ex | 162 ++++++++++++++++-- lib/live_book/session/data.ex | 54 +++++- lib/live_book/session/file_guard.ex | 76 ++++++++ lib/live_book/session_supervisor.ex | 26 ++- lib/live_book_web/live/home_live.ex | 156 ++++++++++++++++- lib/live_book_web/live/icons.ex | 50 ++++++ .../live/path_select_component.ex | 140 +++++++++++++++ lib/live_book_web/live/session_live.ex | 51 +++++- .../session_live/persistence_component.ex | 113 ++++++++++++ .../{ => session_live}/runtime_component.ex | 2 +- .../{ => session_live}/shortcuts_component.ex | 2 +- lib/live_book_web/live/sessions_component.ex | 38 ++++ lib/live_book_web/live/sessions_live.ex | 74 -------- lib/live_book_web/router.ex | 2 +- .../templates/layout/live.html.leex | 8 +- test/live_book/session/data_test.exs | 45 ++++- test/live_book/session/file_guard_test.exs | 22 +++ test/live_book/session_test.exs | 85 +++++++-- test/live_book_web/live/home_live_test.exs | 111 +++++++++++- .../live/path_select_component_test.exs | 48 ++++++ .../live_book_web/live/sessions_live_test.exs | 42 ----- test/support/notebooks/basic.livemd | 9 + .../notebooks/with_two_sections.livemd | 13 ++ 30 files changed, 1247 insertions(+), 183 deletions(-) create mode 100644 assets/js/focus_on_update/index.js create mode 100644 lib/live_book/session/file_guard.ex create mode 100644 lib/live_book_web/live/path_select_component.ex create mode 100644 lib/live_book_web/live/session_live/persistence_component.ex rename lib/live_book_web/live/{ => session_live}/runtime_component.ex (98%) rename lib/live_book_web/live/{ => session_live}/shortcuts_component.ex (98%) create mode 100644 lib/live_book_web/live/sessions_component.ex delete mode 100644 lib/live_book_web/live/sessions_live.ex create mode 100644 test/live_book/session/file_guard_test.exs create mode 100644 test/live_book_web/live/path_select_component_test.exs delete mode 100644 test/live_book_web/live/sessions_live_test.exs create mode 100644 test/support/notebooks/basic.livemd create mode 100644 test/support/notebooks/with_two_sections.livemd diff --git a/.gitignore b/.gitignore index d878faa44..b6955259f 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ npm-debug.log # we ignore priv/static. You may want to comment # this depending on your deployment strategy. /priv/static/ + +# The directory used by ExUnit :tmp_dir +/tmp/ diff --git a/assets/css/components.css b/assets/css/components.css index 73356b4ae..8159b4e88 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -1,7 +1,15 @@ /* Buttons */ .button-base { - @apply px-4 py-2 bg-white rounded-md border border-gray-300 font-medium text-gray-700 hover:bg-gray-50; + @apply px-4 py-2 bg-white rounded-md border border-gray-300 font-medium text-gray-700; +} + +.button-base:not(:disabled) { + @apply hover:bg-gray-50; +} + +.button-base:disabled { + @apply opacity-50 cursor-default; } .button-sm { @@ -9,11 +17,19 @@ } .button-danger { - @apply border border-red-300 text-red-500 hover:bg-red-50; + @apply border border-red-300 text-red-500; +} + +.button-danger:not(:disabled) { + @apply hover:bg-red-50; } .button-primary { - @apply border-0 bg-purple-400 text-white hover:bg-purple-500; + @apply border-0 bg-blue-400 text-white; +} + +.button-primary:not(:disabled) { + @apply hover:bg-blue-500; } /* Form fields */ @@ -22,6 +38,43 @@ @apply w-full px-3 py-3 bg-white rounded-md placeholder-gray-400 text-gray-700; } +.checkbox-base { + @apply h-5 w-5 appearance-none border border-gray-300 rounded text-blue-400 cursor-pointer; +} + +.checkbox-base:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +.radio-button-group { + @apply flex; +} + +.radio-button-group .radio-button:first-child .radio-button__label { + @apply rounded-l-md border-l; +} + +.radio-button-group .radio-button:last-child .radio-button__label { + @apply rounded-r-md; +} + +.radio-button .radio-button__label { + @apply block px-3 py-1 cursor-pointer border border-l-0 border-gray-700 text-gray-700 hover:bg-gray-100; +} + +.radio-button .radio-button__input { + @apply hidden; +} + +.radio-button .radio-button__input[checked] + .radio-button__label { + @apply bg-gray-700 text-white; +} + /* Custom scrollbars */ .tiny-scrollbar::-webkit-scrollbar { diff --git a/assets/css/markdown.css b/assets/css/markdown.css index b9511d418..143dd4ae3 100644 --- a/assets/css/markdown.css +++ b/assets/css/markdown.css @@ -71,11 +71,21 @@ } .markdown table th { - @apply p-2 font-bold; + @apply p-2 font-bold text-left; } .markdown table td { - @apply p-2; + @apply p-2 text-left; +} + +.markdown table th[align="center"], +.markdown table td[align="center"] { + @apply text-center; +} + +.markdown table th[align="right"], +.markdown table td[align="right"] { + @apply text-right; } .markdown table th:first-child, diff --git a/assets/js/app.js b/assets/js/app.js index 66b2e495b..74228c8a8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -7,11 +7,13 @@ import { LiveSocket } from "phoenix_live_view"; import ContentEditable from "./content_editable"; import Cell from "./cell"; import Session from "./session"; +import FocusOnUpdate from "./focus_on_update"; const Hooks = { ContentEditable, Cell, Session, + FocusOnUpdate, }; const csrfToken = document diff --git a/assets/js/focus_on_update/index.js b/assets/js/focus_on_update/index.js new file mode 100644 index 000000000..cf2c260bc --- /dev/null +++ b/assets/js/focus_on_update/index.js @@ -0,0 +1,19 @@ +/** + * A hook used to focus an element whenever it receives LV update. + */ +const FocusOnUpdate = { + mounted() { + this.__focus(); + }, + + updated() { + this.__focus(); + }, + + __focus() { + this.el.focus(); + this.el.selectionStart = this.el.selectionEnd = this.el.value.length; + }, +}; + +export default FocusOnUpdate; diff --git a/lib/live_book/application.ex b/lib/live_book/application.ex index fc6b5c113..78ae0ff27 100644 --- a/lib/live_book/application.ex +++ b/lib/live_book/application.ex @@ -15,6 +15,8 @@ defmodule LiveBook.Application do {Phoenix.PubSub, name: LiveBook.PubSub}, # Start the supervisor dynamically managing sessions LiveBook.SessionSupervisor, + # Start the server responsible for associating files with sessions + LiveBook.Session.FileGuard, # Start the Endpoint (http/https) LiveBookWeb.Endpoint ] diff --git a/lib/live_book/live_markdown.ex b/lib/live_book/live_markdown.ex index 6b703322f..f30aef6d8 100644 --- a/lib/live_book/live_markdown.ex +++ b/lib/live_book/live_markdown.ex @@ -47,4 +47,6 @@ defmodule LiveBook.LiveMarkdown do # # This file defines a notebook named *My Notebook* with two sections. # The first section includes 3 cells and the second section includes 1 Elixir cell. + + def extension(), do: ".livemd" end diff --git a/lib/live_book/session.ex b/lib/live_book/session.ex index 538625253..9e22b787b 100644 --- a/lib/live_book/session.ex +++ b/lib/live_book/session.ex @@ -14,32 +14,48 @@ defmodule LiveBook.Session do use GenServer, restart: :temporary - alias LiveBook.Session.Data - alias LiveBook.{Evaluator, Utils, Notebook, Delta, Runtime} + alias LiveBook.Session.{Data, FileGuard} + alias LiveBook.{Utils, Notebook, Delta, Runtime, LiveMarkdown} alias LiveBook.Notebook.{Cell, Section} @type state :: %{ session_id: id(), data: Data.t(), - evaluators: %{Section.t() => Evaluator.t()}, client_pids: list(pid()), runtime_monitor_ref: reference() } + @type summary :: %{ + session_id: id(), + notebook_name: String.t(), + path: String.t() | nil + } + @typedoc """ An id assigned to every running session process. """ @type id :: Utils.id() + @autosave_interval 5_000 + ## API @doc """ Starts the server process and registers it globally using the `:global` module, so that it's identifiable by the given id. + + ## Options + + * `:id` (**required**) - a unique identifier to register the session under + + * `:notebook` - the inital `Notebook` structure (e.g. imported from a file) + + * `:path` - the file to which the notebook should be saved """ - @spec start_link(id()) :: GenServer.on_start() - def start_link(session_id) do - GenServer.start_link(__MODULE__, [session_id: session_id], name: name(session_id)) + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts) do + id = Keyword.fetch!(opts, :id) + GenServer.start_link(__MODULE__, opts, name: name(id)) end defp name(session_id) do @@ -69,13 +85,21 @@ defmodule LiveBook.Session do end @doc """ - Returns the current session data. + Returns data of the given session. """ @spec get_data(id()) :: Data.t() def get_data(session_id) do GenServer.call(name(session_id), :get_data) end + @doc """ + Returns basic information about the given session. + """ + @spec get_summary(id()) :: summary() + def get_summary(session_id) do + GenServer.call(name(session_id), :get_summary) + end + @doc """ Asynchronously sends section insertion request to the server. """ @@ -170,6 +194,26 @@ defmodule LiveBook.Session do GenServer.cast(name(session_id), :disconnect_runtime) end + @doc """ + Asynchronously sends path update request to the server. + """ + @spec set_path(id(), String.t() | nil) :: :ok + def set_path(session_id, path) do + GenServer.cast(name(session_id), {:set_path, path}) + end + + @doc """ + Asynchronously sends save request to the server. + + If there's a path set and the notebook changed since the last save, + it will be persisted to said path. + Note that notebooks are automatically persisted every @autosave_interval milliseconds. + """ + @spec save(id()) :: :ok + def save(session_id) do + GenServer.cast(name(session_id), :save) + end + @doc """ Synchronously stops the server. """ @@ -181,15 +225,43 @@ defmodule LiveBook.Session do ## Callbacks @impl true - def init(session_id: session_id) do - {:ok, - %{ - session_id: session_id, - data: Data.new(), - client_pids: [], - evaluators: %{}, - runtime_monitor_ref: nil - }} + def init(opts) do + Process.send_after(self(), :autosave, @autosave_interval) + + id = Keyword.fetch!(opts, :id) + + case init_data(opts) do + {:ok, data} -> + {:ok, + %{ + session_id: id, + data: data, + client_pids: [], + runtime_monitor_ref: nil + }} + + {:error, error} -> + {:stop, error} + end + end + + defp init_data(opts) do + notebook = Keyword.get(opts, :notebook) + path = Keyword.get(opts, :path) + + data = if(notebook, do: Data.new(notebook), else: Data.new()) + + if path do + case FileGuard.lock(path, self()) do + :ok -> + {:ok, %{data | path: path}} + + {:error, :already_in_use} -> + {:error, "the given path is already in use"} + end + else + {:ok, data} + end end @impl true @@ -202,6 +274,10 @@ defmodule LiveBook.Session do {:reply, state.data, state} end + def handle_call(:get_summary, _from, state) do + {:reply, summary_from_state(state), state} + end + @impl true def handle_cast({:insert_section, index}, state) do # Include new id in the operation, so it's reproducible @@ -277,6 +353,30 @@ defmodule LiveBook.Session do |> handle_operation({:set_runtime, nil})} end + def handle_cast({:set_path, path}, state) do + if path do + FileGuard.lock(path, self()) + else + :ok + end + |> case do + :ok -> + if state.data.path do + FileGuard.unlock(state.data.path) + end + + {:noreply, handle_operation(state, {:set_path, path})} + + {:error, :already_in_use} -> + broadcast_error(state.session_id, "failed to set new path because it is already in use") + {:noreply, state} + end + end + + def handle_cast(:save, state) do + {:noreply, maybe_save_notebook(state)} + end + @impl true def handle_info({:DOWN, ref, :process, _, _}, %{runtime_monitor_ref: ref} = state) do broadcast_info(state.session_id, "runtime node terminated unexpectedly") @@ -300,10 +400,23 @@ defmodule LiveBook.Session do {:noreply, handle_operation(state, operation)} end + def handle_info(:autosave, state) do + Process.send_after(self(), :autosave, @autosave_interval) + {:noreply, maybe_save_notebook(state)} + end + def handle_info(_message, state), do: {:noreply, state} # --- + defp summary_from_state(state) do + %{ + session_id: state.session_id, + notebook_name: state.data.notebook.name, + path: state.data.path + } + end + # Given any opeation on `Data`, the process does the following: # # * broadcasts the operation to all clients immediately, @@ -392,4 +505,21 @@ defmodule LiveBook.Session do end defp ensure_runtime(state), do: {:ok, state} + + defp maybe_save_notebook(state) do + if state.data.path != nil and state.data.dirty do + content = LiveMarkdown.Export.notebook_to_markdown(state.data.notebook) + + case File.write(state.data.path, content) do + :ok -> + handle_operation(state, :mark_as_not_dirty) + + {:error, reason} -> + broadcast_error(state.session_id, "failed to save notebook - #{reason}") + state + end + else + state + end + end end diff --git a/lib/live_book/session/data.ex b/lib/live_book/session/data.ex index 23887023d..f4e08cab9 100644 --- a/lib/live_book/session/data.ex +++ b/lib/live_book/session/data.ex @@ -17,6 +17,7 @@ defmodule LiveBook.Session.Data do defstruct [ :notebook, :path, + :dirty, :section_infos, :cell_infos, :deleted_sections, @@ -30,6 +31,7 @@ defmodule LiveBook.Session.Data do @type t :: %__MODULE__{ notebook: Notebook.t(), path: nil | String.t(), + dirty: boolean(), section_infos: %{Section.id() => section_info()}, cell_infos: %{Cell.id() => cell_info()}, deleted_sections: list(Section.t()), @@ -70,6 +72,8 @@ defmodule LiveBook.Session.Data do | {:set_section_name, Section.id(), String.t()} | {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()} | {:set_runtime, Runtime.t() | nil} + | {:set_path, String.t() | nil} + | :mark_as_not_dirty @type action :: {:start_evaluation, Cell.t(), Section.t()} @@ -80,19 +84,33 @@ defmodule LiveBook.Session.Data do @doc """ Returns a fresh notebook session state. """ - @spec new() :: t() - def new() do + @spec new(Notebook.t()) :: t() + def new(notebook \\ Notebook.new()) do %__MODULE__{ - notebook: Notebook.new(), + notebook: notebook, path: nil, - section_infos: %{}, - cell_infos: %{}, + dirty: false, + section_infos: initial_section_infos(notebook), + cell_infos: initial_cell_infos(notebook), deleted_sections: [], deleted_cells: [], runtime: nil } end + defp initial_section_infos(notebook) do + for section <- notebook.sections, + into: %{}, + do: {section.id, new_section_info()} + end + + defp initial_cell_infos(notebook) do + for section <- notebook.sections, + cell <- section.cells, + into: %{}, + do: {cell.id, new_cell_info()} + end + @doc """ Applies the change specified by `operation` to the given session `data`. @@ -125,6 +143,7 @@ defmodule LiveBook.Session.Data do data |> with_actions() |> insert_section(index, section) + |> set_dirty() |> wrap_ok() end @@ -135,6 +154,7 @@ defmodule LiveBook.Session.Data do data |> with_actions() |> insert_cell(section_id, index, cell) + |> set_dirty() |> wrap_ok() end end @@ -144,6 +164,7 @@ defmodule LiveBook.Session.Data do data |> with_actions() |> delete_section(section) + |> set_dirty() |> wrap_ok() end end @@ -171,6 +192,7 @@ defmodule LiveBook.Session.Data do end |> delete_cell(cell) |> add_action({:forget_evaluation, cell, section}) + |> set_dirty() |> wrap_ok() end end @@ -245,6 +267,7 @@ defmodule LiveBook.Session.Data do data |> with_actions() |> set_notebook_name(name) + |> set_dirty() |> wrap_ok() end @@ -253,6 +276,7 @@ defmodule LiveBook.Session.Data do data |> with_actions() |> set_section_name(section, name) + |> set_dirty() |> wrap_ok() end end @@ -264,6 +288,7 @@ defmodule LiveBook.Session.Data do data |> with_actions() |> apply_delta(from, cell, delta, revision) + |> set_dirty() |> wrap_ok() else _ -> :error @@ -278,6 +303,21 @@ defmodule LiveBook.Session.Data do |> wrap_ok() end + def apply_operation(data, {:set_path, path}) do + data + |> with_actions() + |> set!(path: path) + |> set_dirty() + |> wrap_ok() + end + + def apply_operation(data, :mark_as_not_dirty) do + data + |> with_actions() + |> set_dirty(false) + |> wrap_ok() + end + # === defp with_actions(data, actions \\ []), do: {data, actions} @@ -533,6 +573,10 @@ defmodule LiveBook.Session.Data do Enum.reduce(list, data_actions, fn elem, data_actions -> reducer.(data_actions, elem) end) end + defp set_dirty(data_actions, dirty \\ true) do + set!(data_actions, dirty: dirty) + end + @doc """ Finds the cell that's currently being evaluated in the given section. """ diff --git a/lib/live_book/session/file_guard.ex b/lib/live_book/session/file_guard.ex new file mode 100644 index 000000000..d97af48a7 --- /dev/null +++ b/lib/live_book/session/file_guard.ex @@ -0,0 +1,76 @@ +defmodule LiveBook.Session.FileGuard do + @moduledoc false + + # Serves as a locking mechanism for notebook files. + # + # Every session process willing to persist notebook + # should turn to `FileGuard` to make sure the path + # is not already used by another session. + + use GenServer + + @type state :: %{ + path_with_owner_ref: %{String.t() => reference()} + } + + @name __MODULE__ + + def start_link(_opts \\ []) do + GenServer.start_link(__MODULE__, [], name: @name) + end + + def stop() do + GenServer.stop(@name) + end + + @doc """ + Locks the given file associating it with the given process. + + If the owner process dies the file is automatically unlocked. + """ + @spec lock(String.t(), pid()) :: :ok | {:error, :already_in_use} + def lock(path, owner_pid) do + GenServer.call(@name, {:lock, path, owner_pid}) + end + + @doc """ + Unlocks the given file. + """ + @spec unlock(String.t()) :: :ok + def unlock(path) do + GenServer.cast(@name, {:unlock, path}) + end + + # Callbacks + + @impl true + def init(_opts) do + {:ok, %{path_with_owner_ref: %{}}} + end + + @impl true + def handle_call({:lock, path, owner_pid}, _from, state) do + if Map.has_key?(state.path_with_owner_ref, path) do + {:reply, {:error, :already_in_use}, state} + else + monitor_ref = Process.monitor(owner_pid) + state = put_in(state.path_with_owner_ref[path], monitor_ref) + {:reply, :ok, state} + end + end + + @impl true + def handle_cast({:unlock, path}, state) do + {maybe_ref, state} = pop_in(state.path_with_owner_ref[path]) + maybe_ref && Process.demonitor(maybe_ref, [:flush]) + + {:noreply, state} + end + + @impl true + def handle_info({:DOWN, ref, :process, _, _}, state) do + {path, ^ref} = Enum.find(state.path_with_owner_ref, &(elem(&1, 1) == ref)) + {_, state} = pop_in(state.path_with_owner_ref[path]) + {:noreply, state} + end +end diff --git a/lib/live_book/session_supervisor.ex b/lib/live_book/session_supervisor.ex index accb8fcc6..31b84f52a 100644 --- a/lib/live_book/session_supervisor.ex +++ b/lib/live_book/session_supervisor.ex @@ -22,15 +22,17 @@ defmodule LiveBook.SessionSupervisor do end @doc """ - Spawns a new session process. + Spawns a new `Session` process with the given options. Broadcasts `{:session_created, id}` message under the `"sessions"` topic. """ - @spec create_session() :: {:ok, Section.id()} | {:error, any()} - def create_session() do + @spec create_session(keyword()) :: {:ok, Session.id()} | {:error, any()} + def create_session(opts \\ []) do id = Utils.random_id() - case DynamicSupervisor.start_child(@name, {Session, id}) do + opts = Keyword.put(opts, :id, id) + + case DynamicSupervisor.start_child(@name, {Session, opts}) do {:ok, _} -> broadcast_sessions_message({:session_created, id}) {:ok, id} @@ -52,7 +54,7 @@ defmodule LiveBook.SessionSupervisor do Broadcasts `{:session_delete, id}` message under the `"sessions"` topic. """ - @spec delete_session(Section.id()) :: :ok + @spec delete_session(Session.id()) :: :ok def delete_session(id) do Session.stop(id) broadcast_sessions_message({:session_deleted, id}) @@ -66,7 +68,7 @@ defmodule LiveBook.SessionSupervisor do @doc """ Returns ids of all the running session processes. """ - @spec get_session_ids() :: list(Section.id()) + @spec get_session_ids() :: list(Session.id()) def get_session_ids() do :global.registered_names() |> Enum.flat_map(fn @@ -75,10 +77,18 @@ defmodule LiveBook.SessionSupervisor do end) end + @doc """ + Returns summaries of all the running session processes. + """ + @spec get_session_summaries() :: list(Session.summary()) + def get_session_summaries() do + Enum.map(get_session_ids(), &Session.get_summary/1) + end + @doc """ Checks if a session process with the given id exists. """ - @spec session_exists?(Section.id()) :: boolean() + @spec session_exists?(Session.id()) :: boolean() def session_exists?(id) do :global.whereis_name({:session, id}) != :undefined end @@ -86,7 +96,7 @@ defmodule LiveBook.SessionSupervisor do @doc """ Retrieves pid of a session process identified by the given id. """ - @spec get_session_pid(Section.id()) :: {:ok, pid()} | {:error, :nonexistent} + @spec get_session_pid(Session.id()) :: {:ok, pid()} | {:error, :nonexistent} def get_session_pid(id) do case :global.whereis_name({:session, id}) do :undefined -> {:error, :nonexistent} diff --git a/lib/live_book_web/live/home_live.ex b/lib/live_book_web/live/home_live.ex index 5ddaf9e35..5a66c1eaa 100644 --- a/lib/live_book_web/live/home_live.ex +++ b/lib/live_book_web/live/home_live.ex @@ -1,12 +1,164 @@ defmodule LiveBookWeb.HomeLive do use LiveBookWeb, :live_view + alias LiveBook.{SessionSupervisor, Session, LiveMarkdown} + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions") + end + + session_summaries = sort_session_summaries(SessionSupervisor.get_session_summaries()) + + {:ok, assign(socket, path: default_path(), session_summaries: session_summaries)} + end + @impl true def render(assigns) do ~L""" -
-

Welcome to LiveBook

+
+

LiveBook

+
+
+
+ +
+
+ <%= live_component @socket, LiveBookWeb.PathSelectComponent, + id: "path_select", + path: @path, + running_paths: paths(@session_summaries), + target: nil %> +
+ <%= content_tag :button, "Fork", + class: "button-base button-sm", + phx_click: "fork", + disabled: not path_forkable?(@path) %> + <%= if path_running?(@path, @session_summaries) do %> + <%= live_patch "Join session", to: Routes.session_path(@socket, :page, session_id_by_path(@path, @session_summaries)), + class: "button-base button-sm button-primary" %> + <% else %> + <%= content_tag :button, "Open", + class: "button-base button-sm button-primary", + phx_click: "open", + disabled: not path_openable?(@path, @session_summaries) %> + <% end %> +
+
+
+

+ Running sessions +

+ <%= if @session_summaries == [] do %> +
+ No sessions currently running, you can create one above. +
+ <% else %> + <%= live_component @socket, LiveBookWeb.SessionsComponent, + id: "sessions_list", + session_summaries: @session_summaries %> + <% end %> +
""" end + + @impl true + def handle_event("set_path", %{"path" => path}, socket) do + {:noreply, assign(socket, path: path)} + end + + def handle_event("new", %{}, socket) do + create_session(socket) + end + + def handle_event("fork", %{}, socket) do + {notebook, messages} = import_notebook(socket.assigns.path) + socket = put_import_flash_messages(socket, messages) + notebook = %{notebook | name: notebook.name <> " - fork"} + create_session(socket, notebook: notebook) + end + + def handle_event("open", %{}, socket) do + {notebook, messages} = import_notebook(socket.assigns.path) + socket = put_import_flash_messages(socket, messages) + create_session(socket, notebook: notebook, path: socket.assigns.path) + end + + @impl true + def handle_info({:session_created, id}, socket) do + summary = Session.get_summary(id) + session_summaries = sort_session_summaries([summary | socket.assigns.session_summaries]) + {:noreply, assign(socket, session_summaries: session_summaries)} + end + + def handle_info({:session_deleted, id}, socket) do + session_summaries = Enum.reject(socket.assigns.session_summaries, &(&1.session_id == id)) + {:noreply, assign(socket, session_summaries: session_summaries)} + end + + def handle_info(_message, socket), do: {:noreply, socket} + + defp default_path(), do: File.cwd!() <> "/" + + defp sort_session_summaries(session_summaries) do + Enum.sort_by(session_summaries, & &1.notebook_name) + end + + defp paths(session_summaries) do + Enum.map(session_summaries, & &1.path) + end + + defp path_forkable?(path) do + File.regular?(path) + end + + defp path_openable?(path, session_summaries) do + File.regular?(path) and not path_running?(path, session_summaries) + end + + defp path_running?(path, session_summaries) do + running_paths = paths(session_summaries) + path in running_paths + end + + defp create_session(socket, opts \\ []) do + case SessionSupervisor.create_session(opts) do + {:ok, id} -> + {:noreply, push_redirect(socket, to: Routes.session_path(socket, :page, id))} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Failed to create a notebook: #{reason}")} + end + end + + defp import_notebook(path) do + content = File.read!(path) + LiveMarkdown.Import.notebook_from_markdown(content) + end + + defp put_import_flash_messages(socket, []), do: socket + + defp put_import_flash_messages(socket, messages) do + list = + messages + |> Enum.map(fn message -> ["- ", message] end) + |> Enum.intersperse("\n") + + flash = + IO.iodata_to_binary([ + "We found problems while importing the file and tried to autofix them:\n" | list + ]) + + put_flash(socket, :info, flash) + end + + defp session_id_by_path(path, session_summaries) do + summary = Enum.find(session_summaries, &(&1.path == path)) + summary.session_id + end end diff --git a/lib/live_book_web/live/icons.ex b/lib/live_book_web/live/icons.ex index f4dc88ebf..ededc949d 100644 --- a/lib/live_book_web/live/icons.ex +++ b/lib/live_book_web/live/icons.ex @@ -88,6 +88,56 @@ defmodule LiveBookWeb.Icons do """ end + def svg(:folder, attrs) do + assigns = %{attrs: heroicon_svg_attrs(attrs)} + + ~L""" + <%= tag(:svg, @attrs) %> + + + """ + end + + def svg(:document_text, attrs) do + assigns = %{attrs: heroicon_svg_attrs(attrs)} + + ~L""" + <%= tag(:svg, @attrs) %> + + + """ + end + + def svg(:check_circle, attrs) do + assigns = %{attrs: heroicon_svg_attrs(attrs)} + + ~L""" + <%= tag(:svg, @attrs) %> + + + """ + end + + def svg(:dots_circle_horizontal, attrs) do + assigns = %{attrs: heroicon_svg_attrs(attrs)} + + ~L""" + <%= tag(:svg, @attrs) %> + + + """ + end + + def svg(:home, attrs) do + assigns = %{attrs: heroicon_svg_attrs(attrs)} + + ~L""" + <%= tag(:svg, @attrs) %> + + + """ + end + # https://heroicons.com defp heroicon_svg_attrs(attrs) do heroicon_svg_attrs = [ diff --git a/lib/live_book_web/live/path_select_component.ex b/lib/live_book_web/live/path_select_component.ex new file mode 100644 index 000000000..19b4d96d7 --- /dev/null +++ b/lib/live_book_web/live/path_select_component.ex @@ -0,0 +1,140 @@ +defmodule LiveBookWeb.PathSelectComponent do + use LiveBookWeb, :live_component + + # The component expects: + # + # * `path` - the currently entered path + # * `running_paths` - the list of notebook paths that are already linked to running sessions + # * `target` - id of the component to send update events to or nil to send to the parent LV + # + # The target receives `set_path` events with `%{"path" => path}` payload. + + alias LiveBook.LiveMarkdown + + @impl true + def render(assigns) do + ~L""" +
> + +
+
+
+ <%= for file <- list_matching_files(@path, @running_paths) do %> + <%= render_file(file, @target) %> + <% end %> +
+
+ """ + end + + defp render_file(file, target) do + icon = + case file do + %{is_running: true} -> :play + %{is_dir: true} -> :folder + _ -> :document_text + end + + assigns = %{file: file, icon: icon} + + ~L""" + + """ + end + + defp list_matching_files(path, running_paths) do + # Note: to provide an intuitive behavior when typing the path + # we enter a new directory when it has a trailing slash, + # so given "/foo/bar" we list files in "foo" and given "/foo/bar/ + # we list files in "bar". + # + # The basename is kinda like search within the current directory, + # so we show only files starting with that string. + + {dir, basename} = split_path(path) + dir = Path.expand(dir) + + if File.exists?(dir) do + file_names = + case File.ls(dir) do + {:ok, names} -> names + {:error, _} -> [] + end + + file_infos = + file_names + |> Enum.map(fn name -> + path = Path.join(dir, name) + is_dir = File.dir?(path) + + %{ + name: name, + path: if(is_dir, do: ensure_trailing_slash(path), else: path), + is_dir: is_dir, + is_running: path in running_paths + } + end) + |> Enum.filter(fn file -> + not hidden?(file.name) and String.starts_with?(file.name, basename) and + (file.is_dir or notebook_file?(file.name)) + end) + |> Enum.sort_by(fn file -> {!file.is_dir, file.name} end) + + if dir == "/" or basename != "" do + file_infos + else + parent_dir = %{ + name: "..", + path: dir |> Path.join("..") |> Path.expand() |> ensure_trailing_slash(), + is_dir: true, + is_running: false + } + + [parent_dir | file_infos] + end + else + [] + end + end + + defp hidden?(filename) do + String.starts_with?(filename, ".") + end + + defp notebook_file?(filename) do + String.ends_with?(filename, LiveMarkdown.extension()) + end + + defp split_path(path) do + if String.ends_with?(path, "/") do + {path, ""} + else + {Path.dirname(path), Path.basename(path)} + end + end + + defp ensure_trailing_slash(path) do + if String.ends_with?(path, "/") do + path + else + path <> "/" + end + end +end diff --git a/lib/live_book_web/live/session_live.ex b/lib/live_book_web/live/session_live.ex index d688e594c..d1019f1f5 100644 --- a/lib/live_book_web/live/session_live.ex +++ b/lib/live_book_web/live/session_live.ex @@ -1,7 +1,7 @@ defmodule LiveBookWeb.SessionLive do use LiveBookWeb, :live_view - alias LiveBook.{SessionSupervisor, Session, Delta, Notebook} + alias LiveBook.{SessionSupervisor, Session, Delta, Notebook, Runtime} @impl true def mount(%{"id" => session_id}, _session, socket) do @@ -20,7 +20,7 @@ defmodule LiveBookWeb.SessionLive do {:ok, assign(socket, initial_assigns(session_id, data, platform))} else - {:ok, redirect(socket, to: Routes.sessions_path(socket, :page))} + {:ok, redirect(socket, to: Routes.home_path(socket, :page))} end end @@ -54,8 +54,16 @@ defmodule LiveBookWeb.SessionLive do @impl true def render(assigns) do ~L""" + <%= if @live_action == :file do %> + <%= live_modal @socket, LiveBookWeb.SessionLive.PersistenceComponent, + id: :file_modal, + return_to: Routes.session_path(@socket, :page, @session_id), + session_id: @session_id, + path: @data.path %> + <% end %> + <%= if @live_action == :runtime do %> - <%= live_modal @socket, LiveBookWeb.RuntimeComponent, + <%= live_modal @socket, LiveBookWeb.SessionLive.RuntimeComponent, id: :runtime_modal, return_to: Routes.session_path(@socket, :page, @session_id), session_id: @session_id, @@ -63,7 +71,7 @@ defmodule LiveBookWeb.SessionLive do <% end %> <%= if @live_action == :shortcuts do %> - <%= live_modal @socket, LiveBookWeb.ShortcutsComponent, + <%= live_modal @socket, LiveBookWeb.SessionLive.ShortcutsComponent, id: :shortcuts_modal, platform: @platform, return_to: Routes.session_path(@socket, :page, @session_id) %> @@ -101,9 +109,34 @@ defmodule LiveBookWeb.SessionLive do
-
- <%= live_patch to: Routes.session_path(@socket, :runtime, @session_id) do %> - <%= Icons.svg(:chip, class: "h-6 w-6 text-gray-600 hover:text-current") %> + <%= live_patch to: Routes.session_path(@socket, :runtime, @session_id) do %> +
+ <%= Icons.svg(:chip, class: "h-5 text-gray-400") %> + <%= runtime_description(@data.runtime) %> +
+ <% end %> + <%= live_patch to: Routes.session_path(@socket, :file, @session_id) do %> +
+ <%= if @data.path do %> + <%= if @data.dirty do %> + <%= Icons.svg(:dots_circle_horizontal, class: "h-5 text-blue-400") %> + <% else %> + <%= Icons.svg(:check_circle, class: "h-5 text-green-400") %> + <% end %> + + <%= Path.basename(@data.path) %> + + <% else %> + <%= Icons.svg(:document_text, class: "h-5 text-gray-400") %> + + No file choosen + + <% end %> +
+ <% end %> +
+ <%= live_patch to: Routes.home_path(@socket, :page) do %> + <%= Icons.svg(:home, class: "h-6 w-6 text-gray-600 hover:text-current") %> <% end %> <%= live_patch to: Routes.session_path(@socket, :shortcuts, @session_id) do %> <%= Icons.svg(:question_mark_circle, class: "h-6 w-6 text-gray-600 hover:text-current") %> @@ -465,4 +498,8 @@ defmodule LiveBookWeb.SessionLive do :error end end + + defp runtime_description(nil), do: "No runtime" + defp runtime_description(%Runtime.Standalone{}), do: "Standalone runtime" + defp runtime_description(%Runtime.Attached{}), do: "Attached runtime" end diff --git a/lib/live_book_web/live/session_live/persistence_component.ex b/lib/live_book_web/live/session_live/persistence_component.ex new file mode 100644 index 000000000..b7fdc0bd5 --- /dev/null +++ b/lib/live_book_web/live/session_live/persistence_component.ex @@ -0,0 +1,113 @@ +defmodule LiveBookWeb.SessionLive.PersistenceComponent do + use LiveBookWeb, :live_component + + alias LiveBook.{Session, SessionSupervisor, LiveMarkdown} + + @impl true + def mount(socket) do + session_summaries = SessionSupervisor.get_session_summaries() + {:ok, assign(socket, session_summaries: session_summaries)} + end + + @impl true + def render(assigns) do + ~L""" +
+

+ Configure file +

+
+

+ Specify where the notebook should be automatically persisted. +

+
+
+
+ + +
+
+
+ <%= if @path != nil do %> +
+ <%= live_component @socket, LiveBookWeb.PathSelectComponent, + id: "path_select", + path: @path, + running_paths: paths(@session_summaries), + target: @myself %> +
+
+ <%= normalize_path(@path) %> +
+ <% end %> +
+
+ <%= content_tag :button, "Done", + class: "button-base button-primary button-sm", + phx_click: "done", + phx_target: @myself, + disabled: not path_savable?(normalize_path(@path), @session_summaries) %> +
+
+ """ + end + + @impl true + def handle_event("set_persistence_type", %{"type" => type}, socket) do + path = + case type do + "file" -> default_path() + "memory" -> nil + end + + {:noreply, assign(socket, path: path)} + end + + def handle_event("set_path", %{"path" => path}, socket) do + {:noreply, assign(socket, path: path)} + end + + def handle_event("done", %{}, socket) do + path = normalize_path(socket.assigns.path) + Session.set_path(socket.assigns.session_id, path) + + {:noreply, + push_patch(socket, to: Routes.session_path(socket, :page, socket.assigns.session_id))} + end + + defp default_path() do + File.cwd!() |> Path.join("notebook") + end + + defp paths(session_summaries) do + Enum.map(session_summaries, & &1.path) + end + + defp path_savable?(nil, _session_summaries), do: true + + defp path_savable?(path, session_summaries) do + if File.exists?(path) do + running_paths = paths(session_summaries) + File.regular?(path) and path not in running_paths + else + dir = Path.dirname(path) + File.exists?(dir) + end + end + + defp normalize_path(nil), do: nil + + defp normalize_path(path) do + if String.ends_with?(path, LiveMarkdown.extension()) do + path + else + path <> LiveMarkdown.extension() + end + end +end diff --git a/lib/live_book_web/live/runtime_component.ex b/lib/live_book_web/live/session_live/runtime_component.ex similarity index 98% rename from lib/live_book_web/live/runtime_component.ex rename to lib/live_book_web/live/session_live/runtime_component.ex index c7070444a..1f8733040 100644 --- a/lib/live_book_web/live/runtime_component.ex +++ b/lib/live_book_web/live/session_live/runtime_component.ex @@ -1,4 +1,4 @@ -defmodule LiveBookWeb.RuntimeComponent do +defmodule LiveBookWeb.SessionLive.RuntimeComponent do use LiveBookWeb, :live_component alias LiveBook.{Session, Runtime, Utils} diff --git a/lib/live_book_web/live/shortcuts_component.ex b/lib/live_book_web/live/session_live/shortcuts_component.ex similarity index 98% rename from lib/live_book_web/live/shortcuts_component.ex rename to lib/live_book_web/live/session_live/shortcuts_component.ex index 74746ff02..cf3405aaf 100644 --- a/lib/live_book_web/live/shortcuts_component.ex +++ b/lib/live_book_web/live/session_live/shortcuts_component.ex @@ -1,4 +1,4 @@ -defmodule LiveBookWeb.ShortcutsComponent do +defmodule LiveBookWeb.SessionLive.ShortcutsComponent do use LiveBookWeb, :live_component @shortcuts %{ diff --git a/lib/live_book_web/live/sessions_component.ex b/lib/live_book_web/live/sessions_component.ex new file mode 100644 index 000000000..820f19c0d --- /dev/null +++ b/lib/live_book_web/live/sessions_component.ex @@ -0,0 +1,38 @@ +defmodule LiveBookWeb.SessionsComponent do + use LiveBookWeb, :live_component + + alias LiveBook.SessionSupervisor + + @impl true + def render(assigns) do + ~L""" +
+ <%= for summary <- @session_summaries do %> +
+
+
+ <%= live_redirect summary.notebook_name, to: Routes.session_path(@socket, :page, summary.session_id) %> +
+ <%= summary.path || "No file" %> +
+
+ +
+
+ <% end %> +
+ """ + end + + @impl true + def handle_event("delete_session", %{"id" => session_id}, socket) do + SessionSupervisor.delete_session(session_id) + {:noreply, socket} + end +end diff --git a/lib/live_book_web/live/sessions_live.ex b/lib/live_book_web/live/sessions_live.ex deleted file mode 100644 index b6fff2c75..000000000 --- a/lib/live_book_web/live/sessions_live.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule LiveBookWeb.SessionsLive do - use LiveBookWeb, :live_view - - @impl true - def mount(_params, _session, socket) do - if connected?(socket) do - Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions") - end - - session_ids = LiveBook.SessionSupervisor.get_session_ids() - - {:ok, assign(socket, session_ids: session_ids)} - end - - @impl true - def render(assigns) do - ~L""" -
-
-
- Sessions -
- <%= for session_id <- Enum.sort(@session_ids) do %> -
-
- <%= live_redirect session_id, to: Routes.session_path(@socket, :page, session_id) %> -
-
- -
-
- <% end %> -
- -
- """ - end - - @impl true - def handle_event("create_session", _params, socket) do - case LiveBook.SessionSupervisor.create_session() do - {:ok, id} -> - {:noreply, push_redirect(socket, to: Routes.session_path(socket, :page, id))} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to create a notebook: #{reason}")} - end - end - - def handle_event("delete_session", %{"id" => session_id}, socket) do - LiveBook.SessionSupervisor.delete_session(session_id) - - {:noreply, socket} - end - - @impl true - def handle_info({:session_created, id}, socket) do - session_ids = [id | socket.assigns.session_ids] - - {:noreply, assign(socket, :session_ids, session_ids)} - end - - def handle_info({:session_deleted, id}, socket) do - session_ids = List.delete(socket.assigns.session_ids, id) - - {:noreply, assign(socket, :session_ids, session_ids)} - end - - def handle_info(_message, socket), do: {:noreply, socket} -end diff --git a/lib/live_book_web/router.ex b/lib/live_book_web/router.ex index 23f80d6f2..69c0fc0a6 100644 --- a/lib/live_book_web/router.ex +++ b/lib/live_book_web/router.ex @@ -18,8 +18,8 @@ defmodule LiveBookWeb.Router do pipe_through :browser live "/", HomeLive, :page - live "/sessions", SessionsLive, :page live "/sessions/:id", SessionLive, :page + live "/sessions/:id/file", SessionLive, :file live "/sessions/:id/runtime", SessionLive, :runtime live "/sessions/:id/shortcuts", SessionLive, :shortcuts end diff --git a/lib/live_book_web/templates/layout/live.html.leex b/lib/live_book_web/templates/layout/live.html.leex index 4dacddca9..795990f72 100644 --- a/lib/live_book_web/templates/layout/live.html.leex +++ b/lib/live_book_web/templates/layout/live.html.leex @@ -5,9 +5,7 @@ phx-click="lv:clear-flash" phx-value-key="info"> <%= Icons.svg(:information_circle, class: "h-6 w-6") %> - - <%= live_flash(@flash, :info) %> - + <%= live_flash(@flash, :info) %>
<% end %> @@ -16,9 +14,7 @@ phx-click="lv:clear-flash" phx-value-key="error"> <%= Icons.svg(:exclamation_circle, class: "h-6 w-6") %> - - <%= live_flash(@flash, :error) %> - + <%= live_flash(@flash, :error) %>
<% end %> diff --git a/test/live_book/session/data_test.exs b/test/live_book/session/data_test.exs index 63b95bf84..46a4634d4 100644 --- a/test/live_book/session/data_test.exs +++ b/test/live_book/session/data_test.exs @@ -2,7 +2,28 @@ defmodule LiveBook.Session.DataTest do use ExUnit.Case, async: true alias LiveBook.Session.Data - alias LiveBook.Delta + alias LiveBook.{Delta, Notebook} + + describe "new/1" do + test "called with no arguments defaults to a blank notebook" do + empty_map = %{} + + assert %{notebook: %{sections: []}, cell_infos: ^empty_map, section_infos: ^empty_map} = + Data.new() + end + + test "called with a notebook, sets default cell and section infos" do + cell = Notebook.Cell.new(:elixir) + section = %{Notebook.Section.new() | cells: [cell]} + notebook = %{Notebook.new() | sections: [section]} + + cell_id = cell.id + section_id = section.id + + assert %{cell_infos: %{^cell_id => %{}}, section_infos: %{^section_id => %{}}} = + Data.new(notebook) + end + end describe "apply_operation/2 given :insert_section" do test "adds new section to notebook and session info" do @@ -672,6 +693,28 @@ defmodule LiveBook.Session.DataTest do end end + describe "apply_operation/2 given :set_path" do + test "updates data with the given path" do + data = Data.new() + operation = {:set_path, "path"} + + assert {:ok, %{path: "path"}, []} = Data.apply_operation(data, operation) + end + end + + describe "apply_operation/2 given :mark_as_not_dirty" do + test "sets dirty flag to false" do + data = + data_after_operations!([ + {:insert_section, 0, "s1"} + ]) + + operation = :mark_as_not_dirty + + assert {:ok, %{dirty: false}, []} = Data.apply_operation(data, operation) + end + end + defp data_after_operations!(operations) do Enum.reduce(operations, Data.new(), fn operation, data -> case Data.apply_operation(data, operation) do diff --git a/test/live_book/session/file_guard_test.exs b/test/live_book/session/file_guard_test.exs new file mode 100644 index 000000000..f9cd78bd5 --- /dev/null +++ b/test/live_book/session/file_guard_test.exs @@ -0,0 +1,22 @@ +defmodule LiveBook.Session.FileGuardTest do + use ExUnit.Case, async: false + + alias LiveBook.Session.FileGuard + + test "lock/2 returns an error if the given path is already locked" do + assert :ok = FileGuard.lock("/some/path", self()) + assert {:error, :already_in_use} = FileGuard.lock("/some/path", self()) + end + + test "unlock/1 unlocks the given path" do + assert :ok = FileGuard.lock("/some/path", self()) + :ok = FileGuard.unlock("/some/path") + assert :ok = FileGuard.lock("/some/path", self()) + end + + test "path is automatically unloacked when the owner process termiantes" do + owner = spawn(fn -> :ok end) + :ok = FileGuard.lock("/some/path", owner) + assert :ok = FileGuard.lock("/some/path", self()) + end +end diff --git a/test/live_book/session_test.exs b/test/live_book/session_test.exs index 86e80cfb2..29d30f5bc 100644 --- a/test/live_book/session_test.exs +++ b/test/live_book/session_test.exs @@ -4,12 +4,7 @@ defmodule LiveBook.SessionTest do alias LiveBook.{Session, Delta, Runtime, Utils} setup do - session_id = Utils.random_id() - {:ok, _} = Session.start_link(session_id) - # By default, use the current node for evaluation, - # rather than starting a standalone one. - {:ok, runtime} = LiveBookTest.Runtime.SingleEvaluator.init() - Session.connect_runtime(session_id, runtime) + session_id = start_session() %{session_id: session_id} end @@ -125,8 +120,7 @@ defmodule LiveBook.SessionTest do end describe "connect_runtime/2" do - test "sends a runtime update operation to subscribers", - %{session_id: session_id} do + test "sends a runtime update operation to subscribers", %{session_id: session_id} do Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") {:ok, runtime} = LiveBookTest.Runtime.SingleEvaluator.init() @@ -137,8 +131,7 @@ defmodule LiveBook.SessionTest do end describe "disconnect_runtime/1" do - test "sends a runtime update operation to subscribers", - %{session_id: session_id} do + test "sends a runtime update operation to subscribers", %{session_id: session_id} do Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") Session.disconnect_runtime(session_id) @@ -147,13 +140,71 @@ defmodule LiveBook.SessionTest do end end + describe "set_path/1" do + @tag :tmp_dir + test "sends a path update operation to subscribers", + %{session_id: session_id, tmp_dir: tmp_dir} do + Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") + + path = Path.join(tmp_dir, "notebook.livemd") + Session.set_path(session_id, path) + + assert_receive {:operation, {:set_path, ^path}} + end + + @tag :tmp_dir + test "broadcasts an error if the path is already in use", + %{session_id: session_id, tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "notebook.livemd") + start_session(path: path) + + Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") + + Session.set_path(session_id, path) + + assert_receive {:error, "failed to set new path because it is already in use"} + end + end + + describe "save/1" do + @tag :tmp_dir + test "persists the notebook to the associated file and notifies subscribers", + %{session_id: session_id, tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "notebook.livemd") + Session.set_path(session_id, path) + # Perform a change, so the notebook is dirty + Session.set_notebook_name(session_id, "My notebook") + + Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") + + refute File.exists?(path) + + Session.save(session_id) + + assert_receive {:operation, :mark_as_not_dirty} + assert File.exists?(path) + assert File.read!(path) =~ "My notebook" + end + end + + describe "start_link/1" do + @tag :tmp_dir + test "fails if the given path is already in use", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "notebook.livemd") + start_session(path: path) + + assert {:error, "the given path is already in use"} == + Session.start_link(id: Utils.random_id(), path: path) + end + end + # For most tests we use the lightweight runtime, so that they are cheap to run. # Here go several integration tests that actually start a separate runtime # to verify session integrates well with it. test "starts a standalone runtime upon first evaluation if there was none set explicitly" do session_id = Utils.random_id() - {:ok, _} = Session.start_link(session_id) + {:ok, _} = Session.start_link(id: session_id) Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") @@ -166,7 +217,7 @@ defmodule LiveBook.SessionTest do test "if the runtime node goes down, notifies the subscribers" do session_id = Utils.random_id() - {:ok, _} = Session.start_link(session_id) + {:ok, _} = Session.start_link(id: session_id) {:ok, runtime} = Runtime.Standalone.init(self()) Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") @@ -182,6 +233,16 @@ defmodule LiveBook.SessionTest do assert_receive {:info, "runtime node terminated unexpectedly"} end + defp start_session(opts \\ []) do + session_id = Utils.random_id() + {:ok, _} = Session.start_link(Keyword.merge(opts, id: session_id)) + # By default, use the current node for evaluation, + # rather than starting a standalone one. + {:ok, runtime} = LiveBookTest.Runtime.SingleEvaluator.init() + Session.connect_runtime(session_id, runtime) + session_id + end + defp insert_section_and_cell(session_id) do Session.insert_section(session_id, 0) assert_receive {:operation, {:insert_section, 0, section_id}} diff --git a/test/live_book_web/live/home_live_test.exs b/test/live_book_web/live/home_live_test.exs index 22af13e6b..66e2edc90 100644 --- a/test/live_book_web/live/home_live_test.exs +++ b/test/live_book_web/live/home_live_test.exs @@ -3,9 +3,116 @@ defmodule LiveBookWeb.HomeLiveTest do import Phoenix.LiveViewTest + alias LiveBook.SessionSupervisor + test "disconnected and connected render", %{conn: conn} do {:ok, view, disconnected_html} = live(conn, "/") - assert disconnected_html =~ "Welcome to LiveBook" - assert render(view) =~ "Welcome to LiveBook" + assert disconnected_html =~ "LiveBook" + assert render(view) =~ "LiveBook" + end + + test "redirects to session upon creation", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + + assert {:error, {:live_redirect, %{to: to}}} = + view + |> element("button", "New notebook") + |> render_click() + + assert to =~ "/sessions/" + end + + describe "file selection" do + test "updates the list of files as the input changes", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + + path = Path.expand("../../../lib", __DIR__) <> "/" + + assert view + |> element("form") + |> render_change(%{path: path}) =~ "live_book_web" + end + + test "allows importing when a notebook file is selected", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + + path = test_notebook_path("basic") + + view + |> element("form") + |> render_change(%{path: path}) + + assert assert {:error, {:live_redirect, %{to: to}}} = + view + |> element("button", "Fork") + |> render_click() + + assert to =~ "/sessions/" + end + + test "disables import when a directory is selected", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + + path = File.cwd!() + + view + |> element("form") + |> render_change(%{path: path}) + + assert view + |> element("button[disabled]", "Fork") + |> has_element?() + end + + test "disables import when a nonexistent file is selected", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + + path = File.cwd!() |> Path.join("nonexistent.livemd") + + view + |> element("form") + |> render_change(%{path: path}) + + assert view + |> element("button[disabled]", "Fork") + |> has_element?() + end + end + + describe "sessions list" do + test "lists running sessions", %{conn: conn} do + {:ok, id1} = SessionSupervisor.create_session() + {:ok, id2} = SessionSupervisor.create_session() + + {:ok, view, _} = live(conn, "/") + + assert render(view) =~ id1 + assert render(view) =~ id2 + end + + test "updates UI whenever a session is added or deleted", %{conn: conn} do + {:ok, view, _} = live(conn, "/") + + {:ok, id} = SessionSupervisor.create_session() + assert render(view) =~ id + + SessionSupervisor.delete_session(id) + refute render(view) =~ id + end + end + + # Helpers + + defp test_notebook_path(name) do + path = + ["../../support/notebooks", name <> ".livemd"] + |> Path.join() + |> Path.expand(__DIR__) + + unless File.exists?(path) do + raise "Cannot find test notebook with the name: #{name}" + end + + path end end diff --git a/test/live_book_web/live/path_select_component_test.exs b/test/live_book_web/live/path_select_component_test.exs new file mode 100644 index 000000000..c6abf2a4f --- /dev/null +++ b/test/live_book_web/live/path_select_component_test.exs @@ -0,0 +1,48 @@ +defmodule LiveBookWeb.PathSelectComponentTest do + use LiveBookWeb.ConnCase + + import Phoenix.LiveViewTest + + alias LiveBookWeb.PathSelectComponent + + test "when the path has a trailing slash, lists that directory" do + path = notebooks_path() <> "/" + assert render_component(PathSelectComponent, attrs(path: path)) =~ "basic.livemd" + assert render_component(PathSelectComponent, attrs(path: path)) =~ ".." + end + + test "when the path has no trailing slash, lists the parent directory" do + path = notebooks_path() + assert render_component(PathSelectComponent, attrs(path: path)) =~ "notebooks" + end + + test "lists only files with matching name" do + path = notebooks_path() |> Path.join("with_two_sectio") + assert render_component(PathSelectComponent, attrs(path: path)) =~ "with_two_sections.livemd" + refute render_component(PathSelectComponent, attrs(path: path)) =~ "basic.livemd" + end + + test "does not show parent directory when in root" do + path = "/" + refute render_component(PathSelectComponent, attrs(path: path)) =~ ".." + end + + test "does not show parent directory when there is a basename typed" do + path = notebooks_path() |> Path.join("a") + refute render_component(PathSelectComponent, attrs(path: path)) =~ ".." + end + + test "relative paths are expanded from the current working directory" do + File.cd!(notebooks_path()) + path = "" + assert render_component(PathSelectComponent, attrs(path: path)) =~ "basic.livemd" + end + + defp attrs(attrs) do + Keyword.merge([id: 1, path: "/", running_paths: [], target: nil], attrs) + end + + defp notebooks_path() do + Path.expand("../../support/notebooks", __DIR__) + end +end diff --git a/test/live_book_web/live/sessions_live_test.exs b/test/live_book_web/live/sessions_live_test.exs deleted file mode 100644 index fc830c976..000000000 --- a/test/live_book_web/live/sessions_live_test.exs +++ /dev/null @@ -1,42 +0,0 @@ -defmodule LiveBookWeb.SessionsLiveTest do - use LiveBookWeb.ConnCase - - import Phoenix.LiveViewTest - - test "disconnected and connected render", %{conn: conn} do - {:ok, view, disconnected_html} = live(conn, "/sessions") - assert disconnected_html =~ "Sessions" - assert render(view) =~ "Sessions" - end - - test "lists running sessions", %{conn: conn} do - {:ok, id1} = LiveBook.SessionSupervisor.create_session() - {:ok, id2} = LiveBook.SessionSupervisor.create_session() - - {:ok, view, _} = live(conn, "/sessions") - - assert render(view) =~ id1 - assert render(view) =~ id2 - end - - test "redirects to session upon creation", %{conn: conn} do - {:ok, view, _} = live(conn, "/sessions") - - assert {:error, {:live_redirect, %{to: to}}} = - view - |> element("button", "New session") - |> render_click() - - assert to =~ "/sessions/" - end - - test "updates UI whenever a session is added or deleted", %{conn: conn} do - {:ok, view, _} = live(conn, "/sessions") - - {:ok, id} = LiveBook.SessionSupervisor.create_session() - assert render(view) =~ id - - LiveBook.SessionSupervisor.delete_session(id) - refute render(view) =~ id - end -end diff --git a/test/support/notebooks/basic.livemd b/test/support/notebooks/basic.livemd new file mode 100644 index 000000000..29ed76c91 --- /dev/null +++ b/test/support/notebooks/basic.livemd @@ -0,0 +1,9 @@ +# Notebook + +## First section + +One Elixir cell below. + +```elixir +length([1, 2, 3]) +``` diff --git a/test/support/notebooks/with_two_sections.livemd b/test/support/notebooks/with_two_sections.livemd new file mode 100644 index 000000000..30d10aeb9 --- /dev/null +++ b/test/support/notebooks/with_two_sections.livemd @@ -0,0 +1,13 @@ +# Notebook + +## First section + +This is the first section. + +```elixir +length([1, 2, 3]) +``` + +## Second section + +This is the second section.