Format Elixir code in exported LiveMarkdown (#40)

* Format Elixir code in exported LiveMarkdown

* Make it possible to disable code formatting for Elixir cells

* Add tests
This commit is contained in:
Jonatan Kłosko 2021-03-03 22:23:48 +01:00 committed by GitHub
parent 1996dfada9
commit 33409e7564
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 281 additions and 7 deletions

View file

@ -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)
"<!-- livebook:#{metadata_json} -->"
@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -66,6 +66,9 @@ defmodule LiveBookWeb.CellComponent do
<button phx-click="delete_focused_cell" class="text-gray-500 hover:text-current">
<%= Icons.svg(:trash, class: "h-6") %>
</button>
<%= 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 %>
<button class="text-gray-500 hover:text-current"
phx-click="move_focused_cell"
phx-value-offset="-1">

View file

@ -169,6 +169,16 @@ defmodule LiveBookWeb.Icons do
"""
end
def svg(:adjustments, attrs) do
assigns = %{attrs: heroicon_svg_attrs(attrs)}
~L"""
<%= tag(:svg, @attrs) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
"""
end
# https://heroicons.com
defp heroicon_svg_attrs(attrs) do
heroicon_svg_attrs = [

View file

@ -33,6 +33,7 @@ defmodule LiveBookWeb.SectionComponent do
<%= for {cell, index} <- Enum.with_index(@section.cells) do %>
<%= live_component @socket, LiveBookWeb.CellComponent,
id: cell.id,
session_id: @session_id,
cell: cell,
cell_info: @cell_infos[cell.id],
focused: @selected and cell.id == @focused_cell_id,

View file

@ -77,6 +77,14 @@ defmodule LiveBookWeb.SessionLive do
return_to: Routes.session_path(@socket, :page, @session_id) %>
<% end %>
<%= if @live_action == :cell_settings do %>
<%= live_modal @socket, LiveBookWeb.SessionLive.CellSettingsComponent,
id: :cell_settings_modal,
session_id: @session_id,
cell: @cell,
return_to: Routes.session_path(@socket, :page, @session_id) %>
<% end %>
<div class="flex flex-grow h-full"
id="session"
phx-hook="Session"
@ -148,6 +156,7 @@ defmodule LiveBookWeb.SessionLive do
<%= for section <- @data.notebook.sections do %>
<%= live_component @socket, LiveBookWeb.SectionComponent,
id: section.id,
session_id: @session_id,
section: section,
selected: section.id == @selected_section_id,
cell_infos: @data.cell_infos,
@ -168,6 +177,11 @@ defmodule LiveBookWeb.SessionLive do
end
@impl true
def handle_params(%{"cell_id" => cell_id}, _url, socket) do
{:ok, cell, _} = Notebook.fetch_cell_and_section(socket.assigns.data.notebook, cell_id)
{:noreply, assign(socket, cell: cell)}
end
def handle_params(_params, _url, socket) do
{:noreply, socket}
end

View file

@ -0,0 +1,55 @@
defmodule LiveBookWeb.SessionLive.CellSettingsComponent do
use LiveBookWeb, :live_component
alias LiveBook.Session
@impl true
def update(assigns, socket) do
metadata = assigns.cell.metadata
assigns =
Map.merge(assigns, %{disable_formatting: Map.get(metadata, "disable_formatting", false)})
{:ok, assign(socket, assigns)}
end
@impl true
def render(assigns) do
~L"""
<div class="p-6 pb-4 max-w-4xl flex flex-col space-y-3">
<h3 class="text-lg font-medium text-gray-900">
Cell settings
</h3>
<form phx-submit="save" phx-target="<%= @myself %>">
<div class="w-full flex-col space-y-3">
<label class="flex space-x-3 items-center cursor-pointer">
<%= tag :input, class: "checkbox-base", type: "checkbox", name: "disable_formatting", checked: @disable_formatting %>
<span>Disable code formatting (when saving to file)</span>
</label>
</div>
<div class="mt-6 flex justify-end space-x-2">
<%= live_patch "Cancel", to: @return_to, class: "button-base button-sm" %>
<button class="button-base button-primary button-sm" type="submit">
Save
</button>
</div>
</form>
</div>
"""
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

View file

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

View file

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

View file

@ -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
<!-- livebook:{"disable_formatting":true} -->
```elixir
[1,2,3] # Comment
```
"""
document = Export.notebook_to_markdown(notebook)
assert expected_document == document
end
end

View file

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

View file

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