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
This commit is contained in:
Jonatan Kłosko 2021-02-21 16:54:44 +01:00 committed by GitHub
parent 77675ad61e
commit 0925ec77cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1247 additions and 183 deletions

3
.gitignore vendored
View file

@ -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/

View file

@ -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 {

View file

@ -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,

View file

@ -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

View file

@ -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;

View file

@ -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
]

View file

@ -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

View file

@ -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

View file

@ -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.
"""

View file

@ -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

View file

@ -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}

View file

@ -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"""
<div class="container p-4 flex justify-center">
<h1 class="text-2xl">Welcome to LiveBook</h1>
<header class="flex justify-center p-4 border-b">
<h1 class="text-2xl font-medium">LiveBook</h1>
</header>
<div class="mt-4 container max-w-4xl w-full mx-auto flex flex-col items-center space-y-4 pb-8">
<div class="w-full flex justify-end">
<button class="button-base button-sm"
phx-click="new">
New notebook
</button>
</div>
<div class="container flex flex-col space-y-4">
<%= live_component @socket, LiveBookWeb.PathSelectComponent,
id: "path_select",
path: @path,
running_paths: paths(@session_summaries),
target: nil %>
<div class="flex justify-end space-x-2">
<%= 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 %>
</div>
</div>
<div class="w-full pt-24">
<h3 class="text-xl font-medium text-gray-900">
Running sessions
</h3>
<%= if @session_summaries == [] do %>
<div class="mt-3 text-gray-500 text-medium">
No sessions currently running, you can create one above.
</div>
<% else %>
<%= live_component @socket, LiveBookWeb.SessionsComponent,
id: "sessions_list",
session_summaries: @session_summaries %>
<% end %>
</div>
</div>
"""
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

View file

@ -88,6 +88,56 @@ defmodule LiveBookWeb.Icons do
"""
end
def svg(:folder, attrs) do
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
"""
end
def svg(:document_text, attrs) do
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
"""
end
def svg(:check_circle, attrs) do
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
"""
end
def svg(:dots_circle_horizontal, attrs) do
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
"""
end
def svg(:home, attrs) do
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
"""
end
# https://heroicons.com
defp heroicon_svg_attrs(attrs) do
heroicon_svg_attrs = [

View file

@ -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"""
<form phx-change="set_path" phx-submit="set_path" <%= if @target, do: "phx-target=#{@target}" %>>
<input class="input-base shadow"
id="input-path"
phx-hook="FocusOnUpdate"
type="text"
name="path"
placeholder="File"
value="<%= @path %>"
spellcheck="false" />
</form>
<div class="h-80 -m-1 p-1 overflow-y-auto tiny-scrollbar">
<div class="grid grid-cols-4 gap-2">
<%= for file <- list_matching_files(@path, @running_paths) do %>
<%= render_file(file, @target) %>
<% end %>
</div>
</div>
"""
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"""
<button class="flex space-x-2 items-center p-2 rounded-md hover:bg-gray-100 focus:ring-1 focus:ring-blue-400 <%= if(@file.is_running, do: "text-green-400 opacity-75", else: "text-gray-700") %>"
phx-click="set_path"
phx-value-path="<%= file.path %>"
<%= if target, do: "phx-target=#{target}" %>>
<span class="block">
<%= Icons.svg(@icon, class: "h-5") %>
</span>
<span class="block overflow-hidden overflow-ellipsis whitespace-nowrap">
<%= file.name %>
</span>
</button>
"""
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

View file

@ -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
</div>
</button>
</div>
<div class="p-4 flex space-x-2 justify-between">
<%= 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 %>
<div class="text-sm text-gray-500 text-medium px-4 py-2 border-b border-gray-200 flex space-x-2 items-center hover:bg-gray-200">
<%= Icons.svg(:chip, class: "h-5 text-gray-400") %>
<span><%= runtime_description(@data.runtime) %></span>
</div>
<% end %>
<%= live_patch to: Routes.session_path(@socket, :file, @session_id) do %>
<div class="text-sm text-gray-500 text-medium px-4 py-2 border-b border-gray-200 flex space-x-2 items-center hover:bg-gray-200">
<%= 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 %>
<span>
<%= Path.basename(@data.path) %>
</span>
<% else %>
<%= Icons.svg(:document_text, class: "h-5 text-gray-400") %>
<span>
No file choosen
</span>
<% end %>
</div>
<% end %>
<div class="p-4 flex space-x-2">
<%= 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

View file

@ -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"""
<div class="p-6 pb-4 max-w-4xl w-screen flex flex-col space-y-4">
<h3 class="text-lg font-medium text-gray-900">
Configure file
</h3>
<div class="w-full flex-col space-y-3">
<p class="text-gray-500">
Specify where the notebook should be automatically persisted.
</p>
<div>
<form phx-change="set_persistence_type" phx-target="<%= @myself %>">
<div class="radio-button-group">
<label class="radio-button">
<%= tag :input, class: "radio-button__input", type: "radio", name: "type", value: "file", checked: @path != nil %>
<span class="radio-button__label">Save to file</span>
</label>
<label class="radio-button">
<%= tag :input, class: "radio-button__input", type: "radio", name: "type", value: "memory", checked: @path == nil %>
<span class="radio-button__label">Memory only</span>
</label>
</div>
</form>
</div>
<%= if @path != nil do %>
<div class="w-full container flex flex-col space-y-4">
<%= live_component @socket, LiveBookWeb.PathSelectComponent,
id: "path_select",
path: @path,
running_paths: paths(@session_summaries),
target: @myself %>
</div>
<div class="text-gray-500 text-sm">
<%= normalize_path(@path) %>
</div>
<% end %>
</div>
<div class="flex justify-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) %>
</div>
</div>
"""
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

View file

@ -1,4 +1,4 @@
defmodule LiveBookWeb.RuntimeComponent do
defmodule LiveBookWeb.SessionLive.RuntimeComponent do
use LiveBookWeb, :live_component
alias LiveBook.{Session, Runtime, Utils}

View file

@ -1,4 +1,4 @@
defmodule LiveBookWeb.ShortcutsComponent do
defmodule LiveBookWeb.SessionLive.ShortcutsComponent do
use LiveBookWeb, :live_component
@shortcuts %{

View file

@ -0,0 +1,38 @@
defmodule LiveBookWeb.SessionsComponent do
use LiveBookWeb, :live_component
alias LiveBook.SessionSupervisor
@impl true
def render(assigns) do
~L"""
<div class="mt-3 flex flex-col space-y-2">
<%= for summary <- @session_summaries do %>
<div class="shadow rounded-md p-2">
<div class="p-3 flex items-center">
<div class="flex-grow flex flex-col space-y-1 text-gray-700 text-lg hover:text-gray-900">
<%= live_redirect summary.notebook_name, to: Routes.session_path(@socket, :page, summary.session_id) %>
<div class="text-gray-500 text-sm">
<%= summary.path || "No file" %>
</div>
</div>
<button class="text-gray-500 hover:text-current"
phx-click="delete_session"
phx-value-id="<%= summary.session_id %>"
phx-target="<%= @myself %>"
aria-label="delete">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
</div>
<% end %>
</div>
"""
end
@impl true
def handle_event("delete_session", %{"id" => session_id}, socket) do
SessionSupervisor.delete_session(session_id)
{:noreply, socket}
end
end

View file

@ -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"""
<div class="container max-w-screen-md p-4 mx-auto">
<div class="flex flex-col shadow-md rounded-md px-3 py-2 mb-4">
<div class="text-gray-700 text-lg font-semibold p-2">
Sessions
</div>
<%= for session_id <- Enum.sort(@session_ids) do %>
<div class="p-3 flex">
<div class="flex-grow text-lg text-gray-500 hover:text-current">
<%= live_redirect session_id, to: Routes.session_path(@socket, :page, session_id) %>
</div>
<div>
<button phx-click="delete_session" phx-value-id="<%= session_id %>" aria-label="delete" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
</div>
</div>
<% end %>
</div>
<button phx-click="create_session" class="button-base button-primary shadow-md">
New session
</button>
</div>
"""
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

View file

@ -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

View file

@ -5,9 +5,7 @@
phx-click="lv:clear-flash"
phx-value-key="info">
<%= Icons.svg(:information_circle, class: "h-6 w-6") %>
<span>
<%= live_flash(@flash, :info) %>
</span>
<span class="whitespace-pre"><%= live_flash(@flash, :info) %></span>
</div>
<% end %>
@ -16,9 +14,7 @@
phx-click="lv:clear-flash"
phx-value-key="error">
<%= Icons.svg(:exclamation_circle, class: "h-6 w-6") %>
<span>
<%= live_flash(@flash, :error) %>
</span>
<span class="whitespace-pre"><%= live_flash(@flash, :error) %></span>
</div>
<% end %>
</div>

View file

@ -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

View file

@ -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

View file

@ -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}}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,9 @@
# Notebook
## First section
One Elixir cell below.
```elixir
length([1, 2, 3])
```

View file

@ -0,0 +1,13 @@
# Notebook
## First section
This is the first section.
```elixir
length([1, 2, 3])
```
## Second section
This is the second section.