mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-03-04 02:43:09 +08:00
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:
parent
1996dfada9
commit
33409e7564
16 changed files with 281 additions and 7 deletions
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 """
|
||||
|
|
|
@ -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 """
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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}")
|
||||
|
|
Loading…
Reference in a new issue