diff --git a/assets/css/app.css b/assets/css/app.css
index 20a301b09..50e984e73 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -4,3 +4,14 @@
@import "../node_modules/nprogress/nprogress.css";
@import "./live_view.css";
+
+/* Remove the default outline on focused elements */
+:focus,
+button:focus {
+ outline: none;
+}
+
+/* Hide Phoenix live reload frame */
+iframe[hidden] {
+ display: none;
+}
diff --git a/assets/js/app.js b/assets/js/app.js
index c44b1b544..dcbcb8e4a 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -4,6 +4,11 @@ import "phoenix_html";
import { Socket } from "phoenix";
import NProgress from "nprogress";
import { LiveSocket } from "phoenix_live_view";
+import ContentEditable from "./content_editable";
+
+const Hooks = {
+ ContentEditable,
+};
const csrfToken = document
.querySelector("meta[name='csrf-token']")
@@ -11,6 +16,7 @@ const csrfToken = document
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
+ hooks: Hooks,
});
// Show progress bar on live navigation and form submits
diff --git a/assets/js/content_editable.js b/assets/js/content_editable.js
new file mode 100644
index 000000000..ad91c39b2
--- /dev/null
+++ b/assets/js/content_editable.js
@@ -0,0 +1,39 @@
+/**
+ * A hook used on [contenteditable] elements to update the specified
+ * attribute with the element text.
+ *
+ * Configuration:
+ *
+ * * `data-update-attribute` - the name of the attribute to update when content changes
+ */
+const ContentEditable = {
+ mounted() {
+ this.attribute = this.el.dataset.updateAttribute;
+
+ this.__updateAttribute();
+
+ // Set the specified attribute on every content change
+ this.el.addEventListener("input", (event) => {
+ this.__updateAttribute();
+ });
+
+ // Make sure only plain text is pasted
+ this.el.addEventListener("paste", (event) => {
+ event.preventDefault();
+ const text = event.clipboardData.getData("text/plain");
+ document.execCommand("insertText", false, text);
+ });
+ },
+
+ updated() {
+ // The element has been re-rendered so we have to add the attribute back
+ this.__updateAttribute();
+ },
+
+ __updateAttribute() {
+ const value = this.el.innerText.trim();
+ this.el.setAttribute(this.attribute, value);
+ },
+};
+
+export default ContentEditable;
diff --git a/lib/live_book/notebook.ex b/lib/live_book/notebook.ex
index 011f5fbf1..406bed998 100644
--- a/lib/live_book/notebook.ex
+++ b/lib/live_book/notebook.ex
@@ -132,6 +132,19 @@ defmodule LiveBook.Notebook do
%{notebook | sections: sections}
end
+ @doc """
+ Updates section with the given function.
+ """
+ @spec update_section(t(), Section.id(), (Section.t() -> Section.t())) :: t()
+ def update_section(notebook, section_id, fun) do
+ sections =
+ Enum.map(notebook.sections, fn section ->
+ if section.id == section_id, do: fun.(section), else: section
+ end)
+
+ %{notebook | sections: sections}
+ end
+
@doc """
Returns a list of Elixir cells that the given cell depends on.
diff --git a/lib/live_book/session.ex b/lib/live_book/session.ex
index 1365ef33e..1e651776b 100644
--- a/lib/live_book/session.ex
+++ b/lib/live_book/session.ex
@@ -46,13 +46,25 @@ defmodule LiveBook.Session do
end
@doc """
- Registers a session client, so that it receives updates from the server.
+ Registers a session client, so that the session is aware of it.
The client process is automatically unregistered when it terminates.
+
+ Returns the current session data, which the client can than
+ keep in sync with the server by subscribing to the `sessions:id` topic
+ and reciving operations to apply.
"""
- @spec register_client(id(), pid()) :: :ok
+ @spec register_client(id(), pid()) :: Data.t()
def register_client(session_id, pid) do
- GenServer.cast(name(session_id), {:register_client, pid})
+ GenServer.call(name(session_id), {:register_client, pid})
+ end
+
+ @doc """
+ Returns the current session data.
+ """
+ @spec get_data(id()) :: Data.t()
+ def get_data(session_id) do
+ GenServer.call(name(session_id), :get_data)
end
@doc """
@@ -104,6 +116,22 @@ defmodule LiveBook.Session do
GenServer.cast(name(session_id), {:cancel_cell_evaluation, cell_id})
end
+ @doc """
+ Asynchronously sends notebook name update request to the server.
+ """
+ @spec set_notebook_name(id(), String.t()) :: :ok
+ def set_notebook_name(session_id, name) do
+ GenServer.cast(name(session_id), {:set_notebook_name, name})
+ end
+
+ @doc """
+ Asynchronously sends section name update request to the server.
+ """
+ @spec set_section_name(id(), Section.id(), String.t()) :: :ok
+ def set_section_name(session_id, section_id, name) do
+ GenServer.cast(name(session_id), {:set_section_name, section_id, name})
+ end
+
@doc """
Synchronously stops the server.
"""
@@ -126,11 +154,16 @@ defmodule LiveBook.Session do
end
@impl true
- def handle_cast({:register_client, pid}, state) do
+ def handle_call({:register_client, pid}, _from, state) do
Process.monitor(pid)
- {:noreply, %{state | client_pids: [pid | state.client_pids]}}
+ {:reply, state.data, %{state | client_pids: [pid | state.client_pids]}}
end
+ def handle_call(:get_data, _from, state) do
+ {:reply, state.data, state}
+ end
+
+ @impl true
def handle_cast({:insert_section, index}, state) do
# Include new id in the operation, so it's reproducible
operation = {:insert_section, index, Utils.random_id()}
@@ -163,6 +196,16 @@ defmodule LiveBook.Session do
handle_operation(state, operation)
end
+ def handle_cast({:set_notebook_name, name}, state) do
+ operation = {:set_notebook_name, name}
+ handle_operation(state, operation)
+ end
+
+ def handle_cast({:set_section_name, section_id, name}, state) do
+ operation = {:set_section_name, section_id, name}
+ handle_operation(state, operation)
+ end
+
@impl true
def handle_info({:DOWN, _, :process, pid, _}, state) do
{:noreply, %{state | client_pids: List.delete(state.client_pids, pid)}}
diff --git a/lib/live_book/session/data.ex b/lib/live_book/session/data.ex
index 23d04d95d..3c5b752d9 100644
--- a/lib/live_book/session/data.ex
+++ b/lib/live_book/session/data.ex
@@ -63,6 +63,8 @@ defmodule LiveBook.Session.Data do
| {:add_cell_evaluation_stdout, Cell.id(), String.t()}
| {:add_cell_evaluation_response, Cell.id(), Evaluator.evaluation_response()}
| {:cancel_cell_evaluation, Cell.id()}
+ | {:set_notebook_name, String.t()}
+ | {:set_section_name, Section.id(), String.t()}
@type action ::
{:start_evaluation, Cell.t(), Section.t()}
@@ -230,6 +232,22 @@ defmodule LiveBook.Session.Data do
end
end
+ def apply_operation(data, {:set_notebook_name, name}) do
+ data
+ |> with_actions()
+ |> set_notebook_name(name)
+ |> wrap_ok()
+ end
+
+ def apply_operation(data, {:set_section_name, section_id, name}) do
+ with {:ok, section} <- Notebook.fetch_section(data.notebook, section_id) do
+ data
+ |> with_actions()
+ |> set_section_name(section, name)
+ |> wrap_ok()
+ end
+ end
+
# ===
defp with_actions(data, actions \\ []), do: {data, actions}
@@ -384,6 +402,16 @@ defmodule LiveBook.Session.Data do
|> reduce(queued_dependent_cells, &unqueue_cell_evaluation(&1, &2, section))
end
+ defp set_notebook_name({data, _} = data_actions, name) do
+ data_actions
+ |> set!(notebook: %{data.notebook | name: name})
+ end
+
+ defp set_section_name({data, _} = data_actions, section, name) do
+ data_actions
+ |> set!(notebook: Notebook.update_section(data.notebook, section.id, &%{&1 | name: name}))
+ end
+
defp add_action({data, actions}, action) do
{data, actions ++ [action]}
end
diff --git a/lib/live_book_web.ex b/lib/live_book_web.ex
index 23abffb4a..ed4f8603f 100644
--- a/lib/live_book_web.ex
+++ b/lib/live_book_web.ex
@@ -87,6 +87,9 @@ defmodule LiveBookWeb do
import LiveBookWeb.ErrorHelpers
alias LiveBookWeb.Router.Helpers, as: Routes
+
+ # Custom helpers
+ alias LiveBookWeb.Icons
end
end
diff --git a/lib/live_book_web/live/cell.ex b/lib/live_book_web/live/cell.ex
new file mode 100644
index 000000000..6ec43926a
--- /dev/null
+++ b/lib/live_book_web/live/cell.ex
@@ -0,0 +1,31 @@
+defmodule LiveBookWeb.Cell do
+ use LiveBookWeb, :live_component
+
+ def render(assigns) do
+ ~L"""
+
">
+
+
+ <%= Icons.svg(:play, class: "h-6") %>
+
+
+ <%= Icons.svg(:trash, class: "h-6") %>
+
+
+
+
+ <%= @cell.type |> Atom.to_string() |> String.capitalize() %> cell placeholder
+
+
+
+ """
+ end
+end
diff --git a/lib/live_book_web/live/icons.ex b/lib/live_book_web/live/icons.ex
new file mode 100644
index 000000000..320b79869
--- /dev/null
+++ b/lib/live_book_web/live/icons.ex
@@ -0,0 +1,51 @@
+defmodule LiveBookWeb.Icons do
+ import Phoenix.HTML
+ import Phoenix.HTML.Tag
+
+ @doc """
+ Returns icon svg tag.
+ """
+ @spec svg(atom(), keyword()) :: Phoenix.HTML.safe()
+ def svg(name, attrs \\ [])
+
+ def svg(:chevron_right, attrs) do
+ ~e"""
+
+ """
+ |> heroicon_svg(attrs)
+ end
+
+ def svg(:play, attrs) do
+ ~e"""
+
+
+ """
+ |> heroicon_svg(attrs)
+ end
+
+ def svg(:plus, attrs) do
+ ~e"""
+
+ """
+ |> heroicon_svg(attrs)
+ end
+
+ def svg(:trash, attrs) do
+ ~e"""
+
+ """
+ |> heroicon_svg(attrs)
+ end
+
+ # https://heroicons.com
+ defp heroicon_svg(svg_content, attrs) do
+ heroicon_svg_attrs = [
+ xmlns: "http://www.w3.org/2000/svg",
+ fill: "none",
+ viewBox: "0 0 24 24",
+ stroke: "currentColor"
+ ]
+
+ content_tag(:svg, svg_content, Keyword.merge(attrs, heroicon_svg_attrs))
+ end
+end
diff --git a/lib/live_book_web/live/insert_cell_actions.ex b/lib/live_book_web/live/insert_cell_actions.ex
new file mode 100644
index 000000000..71b91d392
--- /dev/null
+++ b/lib/live_book_web/live/insert_cell_actions.ex
@@ -0,0 +1,34 @@
+defmodule LiveBookWeb.InsertCellActions do
+ use LiveBookWeb, :live_component
+
+ def render(assigns) do
+ ~L"""
+
+ <%= line() %>
+
+ + Markdown
+
+
+ + Elixir
+
+ <%= line() %>
+
+ """
+ end
+
+ defp line() do
+ ~e"""
+
+ """
+ end
+end
diff --git a/lib/live_book_web/live/section.ex b/lib/live_book_web/live/section.ex
new file mode 100644
index 000000000..56a3f3255
--- /dev/null
+++ b/lib/live_book_web/live/section.ex
@@ -0,0 +1,43 @@
+defmodule LiveBookWeb.Section do
+ use LiveBookWeb, :live_component
+
+ def render(assigns) do
+ ~L"""
+ ">
+
+
+ <%= Icons.svg(:chevron_right, class: "h-8") %>
+
<%= @section.name %>
+
+
+
+ <%= Icons.svg(:trash, class: "h-6") %>
+
+
+
+
+
+ <%= live_component @socket, LiveBookWeb.InsertCellActions,
+ section_id: @section.id,
+ index: 0 %>
+ <%= for {cell, index} <- Enum.with_index(@section.cells) do %>
+ <%= live_component @socket, LiveBookWeb.Cell,
+ cell: cell,
+ focused: cell.id == @focused_cell_id %>
+ <%= live_component @socket, LiveBookWeb.InsertCellActions,
+ section_id: @section.id,
+ index: index + 1 %>
+ <% end %>
+
+
+
+ """
+ end
+end
diff --git a/lib/live_book_web/live/session_live.ex b/lib/live_book_web/live/session_live.ex
index 2f84c68e9..d699c0149 100644
--- a/lib/live_book_web/live/session_live.ex
+++ b/lib/live_book_web/live/session_live.ex
@@ -1,17 +1,158 @@
defmodule LiveBookWeb.SessionLive do
use LiveBookWeb, :live_view
+ alias LiveBook.{SessionSupervisor, Session}
+
@impl true
def mount(%{"id" => session_id}, _session, socket) do
- {:ok, assign(socket, session_id: session_id)}
+ if SessionSupervisor.session_exists?(session_id) do
+ data =
+ if connected?(socket) do
+ data = Session.register_client(session_id, self())
+ Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
+
+ data
+ else
+ Session.get_data(session_id)
+ end
+
+ {:ok, assign(socket, initial_assigns(session_id, data))}
+ else
+ {:ok, redirect(socket, to: Routes.live_path(socket, LiveBookWeb.SessionsLive))}
+ end
+ end
+
+ defp initial_assigns(session_id, data) do
+ first_section_id =
+ case data.notebook.sections do
+ [section | _] -> section.id
+ [] -> nil
+ end
+
+ %{
+ session_id: session_id,
+ data: data,
+ selected_section_id: first_section_id,
+ focused_cell_id: nil
+ }
end
@impl true
def render(assigns) do
~L"""
-
- Session <%= @session_id %>
+
+
+
<%= @data.notebook.name %>
+
+ <%= for section <- @data.notebook.sections do %>
+
+
+ <%= section.name %>
+
+
+ <% end %>
+
+
+ <%= Icons.svg(:plus, class: "h-6") %>
+ New section
+
+
+
+
+
+
+ <%= for section <- @data.notebook.sections do %>
+ <%= live_component @socket, LiveBookWeb.Section,
+ section: section,
+ selected: section.id == @selected_section_id,
+ focused_cell_id: @focused_cell_id %>
+ <% end %>
+
+
"""
end
+
+ @impl true
+ def handle_event("add_section", _params, socket) do
+ end_index = length(socket.assigns.data.notebook.sections)
+ Session.insert_section(socket.assigns.session_id, end_index)
+
+ {:noreply, socket}
+ end
+
+ def handle_event("delete_section", %{"section_id" => section_id}, socket) do
+ Session.delete_section(socket.assigns.session_id, section_id)
+
+ {:noreply, socket}
+ end
+
+ def handle_event("select_section", %{"section_id" => section_id}, socket) do
+ {:noreply, assign(socket, selected_section_id: section_id)}
+ end
+
+ def handle_event(
+ "insert_cell",
+ %{"section_id" => section_id, "index" => index, "type" => type},
+ socket
+ ) do
+ index = String.to_integer(index) |> max(0)
+ type = String.to_atom(type)
+ Session.insert_cell(socket.assigns.session_id, section_id, index, type)
+
+ {:noreply, socket}
+ end
+
+ def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do
+ Session.delete_cell(socket.assigns.session_id, cell_id)
+
+ {:noreply, socket}
+ end
+
+ def handle_event("focus_cell", %{"cell_id" => cell_id}, socket) do
+ {:noreply, assign(socket, focused_cell_id: cell_id)}
+ end
+
+ def handle_event("set_notebook_name", %{"name" => name}, socket) do
+ name = normalize_name(name)
+ Session.set_notebook_name(socket.assigns.session_id, name)
+
+ {:noreply, socket}
+ end
+
+ def handle_event("set_section_name", %{"section_id" => section_id, "name" => name}, socket) do
+ name = normalize_name(name)
+ Session.set_section_name(socket.assigns.session_id, section_id, name)
+
+ {:noreply, socket}
+ end
+
+ defp normalize_name(name) do
+ name
+ |> String.trim()
+ |> String.replace(~r/\s+/, " ")
+ |> case do
+ "" -> "Untitled"
+ name -> name
+ end
+ end
+
+ @impl true
+ def handle_info({:operation, operation}, socket) do
+ case Session.Data.apply_operation(socket.assigns.data, operation) do
+ {:ok, data, _actions} ->
+ {:noreply, assign(socket, data: data)}
+
+ :error ->
+ {:noreply, socket}
+ end
+ end
end
diff --git a/lib/live_book_web/live/sessions_live.ex b/lib/live_book_web/live/sessions_live.ex
index d535203e0..77e15eb55 100644
--- a/lib/live_book_web/live/sessions_live.ex
+++ b/lib/live_book_web/live/sessions_live.ex
@@ -22,14 +22,12 @@ defmodule LiveBookWeb.SessionsLive do
<%= for session_id <- Enum.sort(@session_ids) do %>
-
+
<%= live_redirect session_id, to: Routes.live_path(@socket, LiveBookWeb.SessionLive, session_id) %>
-
-
-
-
+
+ <%= Icons.svg(:trash, class: "h-6") %>
diff --git a/lib/live_book_web/templates/layout/live.html.leex b/lib/live_book_web/templates/layout/live.html.leex
index 31d4dc7dc..63b573a03 100644
--- a/lib/live_book_web/templates/layout/live.html.leex
+++ b/lib/live_book_web/templates/layout/live.html.leex
@@ -1,15 +1,4 @@
-
-
- <%= live_redirect "LiveBook", to: "/", class: "font-bold inline-block mr-4 py-2 text-white" %>
-
-
-
- <%= live_redirect "Sessions", to: "/sessions", class: "px-3 text-xs uppercase font-bold text-white hover:opacity-75" %>
-
-
-
-
-
+
case Data.apply_operation(data, operation) do
diff --git a/test/live_book/session_test.exs b/test/live_book/session_test.exs
index e047c4584..8faf4307c 100644
--- a/test/live_book/session_test.exs
+++ b/test/live_book/session_test.exs
@@ -84,6 +84,26 @@ defmodule LiveBook.SessionTest do
end
end
+ describe "set_notebook_name/2" do
+ test "sends a notebook name update operation to subscribers", %{session_id: session_id} do
+ Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
+
+ Session.set_notebook_name(session_id, "Cat's guide to life")
+ assert_receive {:operation, {:set_notebook_name, "Cat's guide to life"}}
+ end
+ end
+
+ describe "set_section_name/3" do
+ test "sends a section name update operation to subscribers", %{session_id: session_id} do
+ Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
+
+ {section_id, _cell_id} = insert_section_and_cell(session_id)
+
+ Session.set_section_name(session_id, section_id, "Chapter 1")
+ assert_receive {:operation, {:set_section_name, section_id, "Chapter 1"}}
+ end
+ end
+
defp insert_section_and_cell(session_id) do
Session.insert_section(session_id, 0)
assert_receive {:operation, {:insert_section, 0, section_id}}