diff --git a/lib/live_book/live_markdown/export.ex b/lib/live_book/live_markdown/export.ex index c06c70093..3ef004b08 100644 --- a/lib/live_book/live_markdown/export.ex +++ b/lib/live_book/live_markdown/export.ex @@ -37,14 +37,21 @@ defmodule LiveBook.LiveMarkdown.Export do end defp render_cell(%{type: :elixir} = cell) do + code = get_elixir_cell_code(cell) + """ ```elixir - #{cell.source} + #{code} ```\ """ |> prepend_metadata(cell.metadata) end + defp get_elixir_cell_code(%{source: source, metadata: %{"disable_formatting" => true}}), + do: source + + defp get_elixir_cell_code(%{source: source}), do: format_code(source) + defp render_metadata(metadata) do metadata_json = Jason.encode!(metadata) "" @@ -89,4 +96,12 @@ defmodule LiveBook.LiveMarkdown.Export do [ast_node] end) end + + defp format_code(code) do + try do + Code.format_string!(code) + rescue + _ -> code + end + end end diff --git a/lib/live_book/notebook.ex b/lib/live_book/notebook.ex index 4de3ef79c..ad730532f 100644 --- a/lib/live_book/notebook.ex +++ b/lib/live_book/notebook.ex @@ -15,11 +15,13 @@ defmodule LiveBook.Notebook do alias LiveBook.Notebook.{Section, Cell} + @type metadata :: %{String.t() => term()} + @type t :: %__MODULE__{ name: String.t(), version: String.t(), sections: list(Section.t()), - metadata: %{String.t() => term()} + metadata: metadata() } @version "1.0" diff --git a/lib/live_book/notebook/cell.ex b/lib/live_book/notebook/cell.ex index 50bfdbc80..0749dc69a 100644 --- a/lib/live_book/notebook/cell.ex +++ b/lib/live_book/notebook/cell.ex @@ -14,12 +14,22 @@ defmodule LiveBook.Notebook.Cell do @type id :: Utils.id() @type type :: :markdown | :elixir + @typedoc """ + Arbitrary cell information persisted as part of the notebook. + + ## Recognised entries + + * `disable_formatting` - whether this particular cell should no be automatically formatted. + Relevant for Elixir cells only. + """ + @type metadata :: %{String.t() => term()} + @type t :: %__MODULE__{ id: id(), type: type(), source: String.t(), outputs: list(), - metadata: %{String.t() => term()} + metadata: metadata() } @doc """ diff --git a/lib/live_book/notebook/section.ex b/lib/live_book/notebook/section.ex index 84afe8de7..25caa1c37 100644 --- a/lib/live_book/notebook/section.ex +++ b/lib/live_book/notebook/section.ex @@ -12,12 +12,13 @@ defmodule LiveBook.Notebook.Section do alias LiveBook.Utils @type id :: Utils.id() + @type metadata :: %{String.t() => term()} @type t :: %__MODULE__{ id: id(), name: String.t(), cells: list(Cell.t()), - metadata: %{String.t() => term()} + metadata: metadata() } @doc """ diff --git a/lib/live_book/session.ex b/lib/live_book/session.ex index a165b35a5..be178c964 100644 --- a/lib/live_book/session.ex +++ b/lib/live_book/session.ex @@ -190,6 +190,14 @@ defmodule LiveBook.Session do GenServer.cast(name(session_id), {:report_cell_revision, self(), cell_id, revision}) end + @doc """ + Asynchronously sends a cell metadata update to the server. + """ + @spec set_cell_metadata(id(), Cell.id(), Cell.metadata()) :: :ok + def set_cell_metadata(session_id, cell_id, metadata) do + GenServer.cast(name(session_id), {:set_cell_metadata, self(), cell_id, metadata}) + end + @doc """ Asynchronously connects to the given runtime. @@ -365,6 +373,11 @@ defmodule LiveBook.Session do {:noreply, handle_operation(state, operation)} end + def handle_cast({:set_cell_metadata, client_pid, cell_id, metadata}, state) do + operation = {:set_cell_metadata, client_pid, cell_id, metadata} + {:noreply, handle_operation(state, operation)} + end + def handle_cast({:connect_runtime, client_pid, runtime}, state) do if state.data.runtime do Runtime.disconnect(state.data.runtime) diff --git a/lib/live_book/session/data.ex b/lib/live_book/session/data.ex index 27fae1267..f5dae51bb 100644 --- a/lib/live_book/session/data.ex +++ b/lib/live_book/session/data.ex @@ -86,6 +86,7 @@ defmodule LiveBook.Session.Data do | {:client_leave, pid()} | {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()} | {:report_cell_revision, pid(), Cell.id(), cell_revision()} + | {:set_cell_metadata, pid(), Cell.id(), Cell.metadata()} | {:set_runtime, pid(), Runtime.t() | nil} | {:set_path, pid(), String.t() | nil} | {:mark_as_not_dirty, pid()} @@ -361,6 +362,18 @@ defmodule LiveBook.Session.Data do end end + def apply_operation(data, {:set_cell_metadata, _client_pid, cell_id, metadata}) do + with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id) do + data + |> with_actions() + |> set_cell_metadata(cell, metadata) + |> set_dirty() + |> wrap_ok() + else + _ -> :error + end + end + def apply_operation(data, {:set_runtime, _client_pid, runtime}) do data |> with_actions() @@ -654,6 +667,11 @@ defmodule LiveBook.Session.Data do end) end + defp set_cell_metadata({data, _} = data_actions, cell, metadata) do + data_actions + |> set!(notebook: Notebook.update_cell(data.notebook, cell.id, &%{&1 | metadata: metadata})) + end + defp purge_deltas(cell_info) do # Given client at revision X and upstream revision Y, # we need Y - X last deltas that the client is not aware of, diff --git a/lib/live_book_web/live/cell_component.ex b/lib/live_book_web/live/cell_component.ex index be83fdef1..c4cd55297 100644 --- a/lib/live_book_web/live/cell_component.ex +++ b/lib/live_book_web/live/cell_component.ex @@ -66,6 +66,9 @@ defmodule LiveBookWeb.CellComponent do + <%= live_patch to: Routes.session_path(@socket, :cell_settings, @session_id, @cell.id), class: "text-gray-500 hover:text-current" do %> + <%= Icons.svg(:adjustments, class: "h-6") %> + <% end %> + + + + """ + end + + @impl true + def handle_event("save", params, socket) do + metadata = update_metadata(socket.assigns.cell.metadata, params) + Session.set_cell_metadata(socket.assigns.session_id, socket.assigns.cell.id, metadata) + {:noreply, push_patch(socket, to: socket.assigns.return_to)} + end + + defp update_metadata(metadata, form_data) do + if Map.has_key?(form_data, "disable_formatting") do + Map.put(metadata, "disable_formatting", true) + else + Map.delete(metadata, "disable_formatting") + end + end +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 index c6e722d8d..34682a616 100644 --- a/lib/live_book_web/live/session_live/persistence_component.ex +++ b/lib/live_book_web/live/session_live/persistence_component.ex @@ -78,8 +78,7 @@ defmodule LiveBookWeb.SessionLive.PersistenceComponent 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))} + {:noreply, push_patch(socket, to: socket.assigns.return_to)} end defp default_path() do diff --git a/lib/live_book_web/router.ex b/lib/live_book_web/router.ex index 69c0fc0a6..931a55713 100644 --- a/lib/live_book_web/router.ex +++ b/lib/live_book_web/router.ex @@ -22,5 +22,6 @@ defmodule LiveBookWeb.Router do live "/sessions/:id/file", SessionLive, :file live "/sessions/:id/runtime", SessionLive, :runtime live "/sessions/:id/shortcuts", SessionLive, :shortcuts + live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings end end diff --git a/test/live_book/live_markdown/export_test.exs b/test/live_book/live_markdown/export_test.exs index da100a480..82057b725 100644 --- a/test/live_book/live_markdown/export_test.exs +++ b/test/live_book/live_markdown/export_test.exs @@ -295,4 +295,83 @@ defmodule LiveBook.LiveMarkdown.ExportTest do assert expected_document == document end + + test "formats code in Elixir cells" do + notebook = %{ + Notebook.new() + | name: "My Notebook", + metadata: %{}, + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + metadata: %{}, + cells: [ + %{ + Notebook.Cell.new(:elixir) + | metadata: %{}, + source: """ + [1,2,3] # Comment + """ + } + ] + } + ] + } + + expected_document = """ + # My Notebook + + ## Section 1 + + ```elixir + # Comment + [1, 2, 3] + ``` + """ + + document = Export.notebook_to_markdown(notebook) + + assert expected_document == document + end + + test "does not format code in Elixir cells which explicitly state so in metadata" do + notebook = %{ + Notebook.new() + | name: "My Notebook", + metadata: %{}, + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + metadata: %{}, + cells: [ + %{ + Notebook.Cell.new(:elixir) + | metadata: %{"disable_formatting" => true}, + source: """ + [1,2,3] # Comment\ + """ + } + ] + } + ] + } + + expected_document = """ + # My Notebook + + ## Section 1 + + + + ```elixir + [1,2,3] # Comment + ``` + """ + + document = Export.notebook_to_markdown(notebook) + + assert expected_document == document + end end diff --git a/test/live_book/session/data_test.exs b/test/live_book/session/data_test.exs index d3a722e37..84143c86b 100644 --- a/test/live_book/session/data_test.exs +++ b/test/live_book/session/data_test.exs @@ -1053,6 +1053,33 @@ defmodule LiveBook.Session.DataTest do end end + describe "apply_operation/2 given :set_cell_metadata" do + test "returns an error given invalid cell id" do + data = Data.new() + + operation = {:set_cell_metadata, self(), "nonexistent", %{}} + assert :error = Data.apply_operation(data, operation) + end + + test "updates cell metadata with the given map" do + data = + data_after_operations!([ + {:insert_section, self(), 0, "s1"}, + {:insert_cell, self(), "s1", 0, :elixir, "c1"} + ]) + + metadata = %{"disable_formatting" => true} + operation = {:set_cell_metadata, self(), "c1", metadata} + + assert {:ok, + %{ + notebook: %{ + sections: [%{cells: [%{metadata: ^metadata}]}] + } + }, _} = Data.apply_operation(data, operation) + end + end + describe "apply_operation/2 given :set_runtime" do test "updates data with the given runtime" do data = Data.new() diff --git a/test/live_book/session_test.exs b/test/live_book/session_test.exs index f4b3a2b03..d536a20c5 100644 --- a/test/live_book/session_test.exs +++ b/test/live_book/session_test.exs @@ -112,7 +112,7 @@ defmodule LiveBook.SessionTest do end end - describe "apply_cell_delta/5" do + describe "apply_cell_delta/4" do test "sends a cell delta operation to subscribers", %{session_id: session_id} do Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") pid = self() @@ -127,6 +127,32 @@ defmodule LiveBook.SessionTest do end end + describe "report_cell_revision/3" do + test "sends a revision report operation to subscribers", %{session_id: session_id} do + Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") + pid = self() + + {_section_id, cell_id} = insert_section_and_cell(session_id) + revision = 1 + + Session.report_cell_revision(session_id, cell_id, revision) + assert_receive {:operation, {:report_cell_revision, ^pid, ^cell_id, ^revision}} + end + end + + describe "set_cell_metadata/3" do + test "sends a metadata update operation to subscribers", %{session_id: session_id} do + Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}") + pid = self() + + {_section_id, cell_id} = insert_section_and_cell(session_id) + metadata = %{"disable_formatting" => true} + + Session.set_cell_metadata(session_id, cell_id, metadata) + assert_receive {:operation, {:set_cell_metadata, ^pid, ^cell_id, ^metadata}} + end + end + describe "connect_runtime/2" do test "sends a runtime update operation to subscribers", %{session_id: session_id} do Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")