diff --git a/assets/js/hooks/markdown_renderer.js b/assets/js/hooks/markdown_renderer.js
index a62a6d295..bfc008bf1 100644
--- a/assets/js/hooks/markdown_renderer.js
+++ b/assets/js/hooks/markdown_renderer.js
@@ -1,35 +1,47 @@
import { getAttributeOrThrow } from "../lib/attribute";
import Markdown from "../lib/markdown";
+import { findChildOrThrow } from "../lib/utils";
/**
* A hook used to render Markdown content on the client.
*
* ## Configuration
*
- * * `data-id` - id of the renderer, under which the content event
- * is pushed
+ * * `data-base-path` - the path to resolve relative URLs against
+ *
+ * * `data-allowed-uri-schemes` - a comma separated list of additional
+ * URI schemes that should be kept during sanitization
+ *
+ * The element should have two children:
+ *
+ * * `[data-template]` - a hidden container containing the markdown
+ * content. The DOM structure is ignored, only text content matters
+ *
+ * * `[data-content]` - the target element to render results into
+ *
*/
const MarkdownRenderer = {
mounted() {
this.props = this.getProps();
- const markdown = new Markdown(this.el, "", {
- baseUrl: this.props.sessionPath,
+ this.templateEl = findChildOrThrow(this.el, "[data-template]");
+ this.contentEl = findChildOrThrow(this.el, "[data-content]");
+
+ this.markdown = new Markdown(this.contentEl, this.templateEl.textContent, {
+ baseUrl: this.props.basePath,
allowedUriSchemes: this.props.allowedUriSchemes.split(","),
});
+ },
- this.handleEvent(
- `markdown_renderer:${this.props.id}:content`,
- ({ content }) => {
- markdown.setContent(content);
- }
- );
+ updated() {
+ this.props = this.getProps();
+
+ this.markdown.setContent(this.templateEl.textContent);
},
getProps() {
return {
- id: getAttributeOrThrow(this.el, "data-id"),
- sessionPath: getAttributeOrThrow(this.el, "data-session-path"),
+ basePath: getAttributeOrThrow(this.el, "data-base-path"),
allowedUriSchemes: getAttributeOrThrow(
this.el,
"data-allowed-uri-schemes"
diff --git a/lib/livebook/live_markdown/export.ex b/lib/livebook/live_markdown/export.ex
index 676b1f69c..94ace9690 100644
--- a/lib/livebook/live_markdown/export.ex
+++ b/lib/livebook/live_markdown/export.ex
@@ -212,7 +212,7 @@ defmodule Livebook.LiveMarkdown.Export do
|> Enum.intersperse("\n\n")
end
- defp render_output({:stdout, text}, _ctx) do
+ defp render_output({:terminal_text, text, %{}}, _ctx) do
text = String.replace_suffix(text, "\n", "")
delimiter = MarkdownHelpers.code_block_delimiter(text)
text = strip_ansi(text)
@@ -221,14 +221,6 @@ defmodule Livebook.LiveMarkdown.Export do
|> prepend_metadata(%{output: true})
end
- defp render_output({:text, text}, _ctx) do
- delimiter = MarkdownHelpers.code_block_delimiter(text)
- text = strip_ansi(text)
-
- [delimiter, "\n", text, "\n", delimiter]
- |> prepend_metadata(%{output: true})
- end
-
defp render_output(
{:js, %{export: %{info_string: info_string, key: key}, js_view: %{ref: ref}}},
ctx
diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex
index 18f9e44a3..a5b89cb2f 100644
--- a/lib/livebook/live_markdown/import.ex
+++ b/lib/livebook/live_markdown/import.ex
@@ -195,7 +195,7 @@ defmodule Livebook.LiveMarkdown.Import do
[{"pre", _, [{"code", [{"class", "output"}], [output], %{}}], %{}} | ast],
outputs
) do
- take_outputs(ast, [{:text, output} | outputs])
+ take_outputs(ast, [{:terminal_text, output, %{chunk: false}} | outputs])
end
defp take_outputs(
@@ -206,7 +206,7 @@ defmodule Livebook.LiveMarkdown.Import do
],
outputs
) do
- take_outputs(ast, [{:text, output} | outputs])
+ take_outputs(ast, [{:terminal_text, output, %{chunk: false}} | outputs])
end
# Ignore other exported outputs
diff --git a/lib/livebook/notebook.ex b/lib/livebook/notebook.ex
index 634dd3ffb..992c932f0 100644
--- a/lib/livebook/notebook.ex
+++ b/lib/livebook/notebook.ex
@@ -666,9 +666,21 @@ defmodule Livebook.Notebook do
@doc """
Adds new output to the given cell.
- Automatically merges stdout outputs and updates frames.
+ Automatically merges terminal outputs and updates frames.
"""
@spec add_cell_output(t(), Cell.id(), Livebook.Runtime.output()) :: t()
+ def add_cell_output(notebook, cell_id, output)
+
+ # Map legacy outputs
+ def add_cell_output(notebook, cell_id, {:text, text}),
+ do: add_cell_output(notebook, cell_id, {:terminal_text, text, %{chunk: false}})
+
+ def add_cell_output(notebook, cell_id, {:plain_text, text}),
+ do: add_cell_output(notebook, cell_id, {:plain_text, text, %{chunk: false}})
+
+ def add_cell_output(notebook, cell_id, {:markdown, text}),
+ do: add_cell_output(notebook, cell_id, {:markdown, text, %{chunk: false}})
+
def add_cell_output(notebook, cell_id, output) do
{notebook, counter} = do_add_cell_output(notebook, cell_id, notebook.output_counter, output)
%{notebook | output_counter: counter}
@@ -714,45 +726,80 @@ defmodule Livebook.Notebook do
end)
end
- defp apply_frame_update(_outputs, new_outputs, :replace), do: new_outputs
- defp apply_frame_update(outputs, new_outputs, :append), do: new_outputs ++ outputs
+ defp apply_frame_update(_outputs, new_outputs, :replace) do
+ merge_chunk_outputs(new_outputs)
+ end
+
+ defp apply_frame_update(outputs, new_outputs, :append) do
+ Enum.reduce(Enum.reverse(new_outputs), outputs, &add_output(&2, &1))
+ end
defp add_output(outputs, {_idx, :ignored}), do: outputs
- defp add_output([], {idx, {:stdout, text}}),
- do: [{idx, {:stdout, normalize_stdout(text)}}]
-
- defp add_output([], output), do: [output]
-
- # Session clients prune stdout content and handle subsequent
- # ones by directly appending page content to the previous one
- defp add_output([{_idx1, {:stdout, :__pruned__}} | _] = outputs, {_idx2, {:stdout, _text}}) do
- outputs
+ # Session clients prune rendered chunks, we only keep add the new one
+ defp add_output(
+ [{idx, {type, :__pruned__, %{chunk: true} = info}} | tail],
+ {_idx, {type, text, %{chunk: true}}}
+ )
+ when type in [:terminal_text, :plain_text, :markdown] do
+ [{idx, {type, text, info}} | tail]
end
- # Session server keeps all outputs, so we merge consecutive stdouts
- defp add_output([{idx, {:stdout, text}} | tail], {_idx, {:stdout, cont}}) do
- [{idx, {:stdout, normalize_stdout(text <> cont)}} | tail]
+ # Session server keeps all outputs, so we merge consecutive chunks
+ defp add_output(
+ [{idx, {:terminal_text, text, %{chunk: true} = info}} | tail],
+ {_idx, {:terminal_text, cont, %{chunk: true}}}
+ ) do
+ [{idx, {:terminal_text, normalize_terminal_text(text <> cont), info}} | tail]
+ end
+
+ defp add_output(outputs, {idx, {:terminal_text, text, info}}) do
+ [{idx, {:terminal_text, normalize_terminal_text(text), info}} | outputs]
+ end
+
+ defp add_output(
+ [{idx, {type, text, %{chunk: true} = info}} | tail],
+ {_idx, {type, cont, %{chunk: true}}}
+ )
+ when type in [:plain_text, :markdown] do
+ [{idx, {type, normalize_terminal_text(text <> cont), info}} | tail]
+ end
+
+ defp add_output(outputs, {idx, {type, text, info}}) when type in [:plain_text, :markdown] do
+ [{idx, {type, normalize_terminal_text(text), info}} | outputs]
+ end
+
+ defp add_output(outputs, {idx, {type, container_outputs, info}}) when type in [:frame, :grid] do
+ [{idx, {type, merge_chunk_outputs(container_outputs), info}} | outputs]
end
defp add_output(outputs, output), do: [output | outputs]
- @doc """
- Normalizes a text chunk coming form the standard output.
-
- Handles CR rawinds and caps output lines.
- """
- @spec normalize_stdout(String.t()) :: String.t()
- def normalize_stdout(text) do
- text
- |> Livebook.Utils.apply_rewind()
- |> Livebook.Utils.cap_lines(max_stdout_lines())
+ defp merge_chunk_outputs(outputs) do
+ outputs
+ |> Enum.reverse()
+ |> Enum.reduce([], &add_output(&2, &1))
end
@doc """
- The maximum desired number of lines of the standard output.
+ Normalizes terminal text chunk.
+
+ Handles CR rewinds and caps output lines.
"""
- def max_stdout_lines(), do: 1_000
+ @spec normalize_terminal_text(String.t()) :: String.t()
+ def normalize_terminal_text(text) do
+ text
+ |> Livebook.Utils.apply_rewind()
+ |> Livebook.Utils.cap_lines(max_terminal_lines())
+ end
+
+ @doc """
+ The maximum desired number of lines of terminal text.
+
+ This is particularly relevant for standard output, which may receive
+ a lot of lines.
+ """
+ def max_terminal_lines(), do: 1_000
@doc """
Recursively adds index to all outputs, including frames.
@@ -803,60 +850,65 @@ defmodule Livebook.Notebook do
@spec prune_cell_outputs(t()) :: t()
def prune_cell_outputs(notebook) do
update_cells(notebook, fn
- %{outputs: _outputs} = cell -> %{cell | outputs: prune_outputs(cell.outputs)}
+ %{outputs: _outputs} = cell -> %{cell | outputs: prune_outputs(cell.outputs, true)}
cell -> cell
end)
end
- defp prune_outputs(outputs) do
+ defp prune_outputs(outputs, appendable?) do
outputs
|> Enum.reverse()
- |> do_prune_outputs([])
+ |> do_prune_outputs(appendable?, [])
end
- defp do_prune_outputs([], acc), do: acc
+ defp do_prune_outputs([], _appendable?, acc), do: acc
- # Keep the last stdout, so that we know to message it directly, but remove its contents
- defp do_prune_outputs([{idx, {:stdout, _}}], acc) do
- [{idx, {:stdout, :__pruned__}} | acc]
+ # Keep trailing outputs that can be merged with subsequent outputs
+ defp do_prune_outputs([{idx, {type, _, %{chunk: true} = info}}], true = _appendable?, acc)
+ when type in [:terminal_text, :plain_text, :markdown] do
+ [{idx, {type, :__pruned__, info}} | acc]
end
# Keep frame and its relevant contents
- defp do_prune_outputs([{idx, {:frame, frame_outputs, info}} | outputs], acc) do
- do_prune_outputs(outputs, [{idx, {:frame, prune_outputs(frame_outputs), info}} | acc])
+ defp do_prune_outputs([{idx, {:frame, frame_outputs, info}} | outputs], appendable?, acc) do
+ do_prune_outputs(
+ outputs,
+ appendable?,
+ [{idx, {:frame, prune_outputs(frame_outputs, true), info}} | acc]
+ )
end
# Keep layout output and its relevant contents
- defp do_prune_outputs([{idx, {:tabs, tabs_outputs, info}} | outputs], acc) do
- case prune_outputs(tabs_outputs) do
+ defp do_prune_outputs([{idx, {:tabs, tabs_outputs, info}} | outputs], appendable?, acc) do
+ case prune_outputs(tabs_outputs, false) do
[] ->
- do_prune_outputs(outputs, acc)
+ do_prune_outputs(outputs, appendable?, acc)
pruned_tabs_outputs ->
info = Map.replace(info, :labels, :__pruned__)
- do_prune_outputs(outputs, [{idx, {:tabs, pruned_tabs_outputs, info}} | acc])
+ do_prune_outputs(outputs, appendable?, [{idx, {:tabs, pruned_tabs_outputs, info}} | acc])
end
end
- defp do_prune_outputs([{idx, {:grid, grid_outputs, info}} | outputs], acc) do
- case prune_outputs(grid_outputs) do
+ defp do_prune_outputs([{idx, {:grid, grid_outputs, info}} | outputs], appendable?, acc) do
+ case prune_outputs(grid_outputs, false) do
[] ->
- do_prune_outputs(outputs, acc)
+ do_prune_outputs(outputs, appendable?, acc)
pruned_grid_outputs ->
- do_prune_outputs(outputs, [{idx, {:grid, pruned_grid_outputs, info}} | acc])
+ do_prune_outputs(outputs, appendable?, [{idx, {:grid, pruned_grid_outputs, info}} | acc])
end
end
# Keep outputs that get re-rendered
- defp do_prune_outputs([{idx, output} | outputs], acc)
+ defp do_prune_outputs([{idx, output} | outputs], appendable?, acc)
when elem(output, 0) in [:input, :control, :error] do
- do_prune_outputs(outputs, [{idx, output} | acc])
+ do_prune_outputs(outputs, appendable?, [{idx, output} | acc])
end
# Remove everything else
- defp do_prune_outputs([_output | outputs], acc) do
- do_prune_outputs(outputs, acc)
+ defp do_prune_outputs([_output | outputs], appendable?, acc) do
+ do_prune_outputs(outputs, appendable?, acc)
end
@doc """
diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex
index a3e18d8b3..00bc0945f 100644
--- a/lib/livebook/runtime.ex
+++ b/lib/livebook/runtime.ex
@@ -72,14 +72,12 @@ defprotocol Livebook.Runtime do
"""
@type output ::
:ignored
- # IO output, adjacent such outputs are treated as a whole
- | {:stdout, binary()}
- # Standalone text block otherwise matching :stdout
- | {:text, binary()}
+ # Text with terminal style and ANSI support
+ | {:terminal_text, text :: String.t(), info :: map()}
# Plain text content
- | {:plain_text, binary()}
+ | {:plain_text, text :: String.t(), info :: map()}
# Markdown content
- | {:markdown, binary()}
+ | {:markdown, text :: String.t(), info :: map()}
# A raw image in the given format
| {:image, content :: binary(), mime_type :: binary()}
# JavaScript powered output
diff --git a/lib/livebook/runtime/evaluator/formatter.ex b/lib/livebook/runtime/evaluator/formatter.ex
index 251b79952..8e8bad00a 100644
--- a/lib/livebook/runtime/evaluator/formatter.ex
+++ b/lib/livebook/runtime/evaluator/formatter.ex
@@ -76,7 +76,7 @@ defmodule Livebook.Runtime.Evaluator.Formatter do
defp to_inspect_output(value, opts \\ []) do
try do
inspected = inspect(value, inspect_opts(opts))
- {:text, inspected}
+ {:terminal_text, inspected, %{chunk: false}}
catch
kind, error ->
formatted = format_error(kind, error, __STACKTRACE__)
@@ -172,6 +172,6 @@ defmodule Livebook.Runtime.Evaluator.Formatter do
defp erlang_to_output(value) do
text = :io_lib.format("~p", [value]) |> IO.iodata_to_binary()
- {:text, text}
+ {:terminal_text, text, %{chunk: false}}
end
end
diff --git a/lib/livebook/runtime/evaluator/io_proxy.ex b/lib/livebook/runtime/evaluator/io_proxy.ex
index bde2f6288..37febdd34 100644
--- a/lib/livebook/runtime/evaluator/io_proxy.ex
+++ b/lib/livebook/runtime/evaluator/io_proxy.ex
@@ -438,7 +438,10 @@ defmodule Livebook.Runtime.Evaluator.IOProxy do
string = state.buffer |> Enum.reverse() |> Enum.join()
if state.send_to != nil and string != "" do
- send(state.send_to, {:runtime_evaluation_output, state.ref, {:stdout, string}})
+ send(
+ state.send_to,
+ {:runtime_evaluation_output, state.ref, {:terminal_text, string, %{chunk: true}}}
+ )
end
%{state | buffer: []}
diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex
index 315932c35..3163feec4 100644
--- a/lib/livebook_web/live/output.ex
+++ b/lib/livebook_web/live/output.ex
@@ -37,40 +37,26 @@ defmodule LivebookWeb.Output do
"""
end
- defp border?({:stdout, _text}), do: true
- defp border?({:text, _text}), do: true
+ defp border?({:terminal_text, _text, _info}), do: true
+ defp border?({:plain_text, _text, _info}), do: true
defp border?({:error, _message, {:interrupt, _, _}}), do: false
defp border?({:error, _message, _type}), do: true
defp border?({:grid, _, info}), do: Map.get(info, :boxed, false)
defp border?(_output), do: false
- defp render_output({:stdout, text}, %{id: id}) do
+ defp render_output({:terminal_text, text, _info}, %{id: id}) do
text = if(text == :__pruned__, do: nil, else: text)
- live_component(Output.StdoutComponent, id: id, text: text)
+ live_component(Output.TerminalTextComponent, id: id, text: text)
end
- defp render_output({:text, text}, %{id: id}) do
- assigns = %{id: id, text: text}
-
- ~H"""
-
- """
+ defp render_output({:plain_text, text, _info}, %{id: id}) do
+ text = if(text == :__pruned__, do: nil, else: text)
+ live_component(Output.PlainTextComponent, id: id, text: text)
end
- defp render_output({:plain_text, text}, %{id: id}) do
- assigns = %{id: id, text: text}
-
- ~H"""
-
<%= @text %>
- """
- end
-
- defp render_output({:markdown, markdown}, %{id: id, session_id: session_id}) do
- live_component(Output.MarkdownComponent,
- id: id,
- session_id: session_id,
- content: markdown
- )
+ defp render_output({:markdown, text, _info}, %{id: id, session_id: session_id}) do
+ text = if(text == :__pruned__, do: nil, else: text)
+ live_component(Output.MarkdownComponent, id: id, session_id: session_id, text: text)
end
defp render_output({:image, content, mime_type}, %{id: id}) do
diff --git a/lib/livebook_web/live/output/markdown_component.ex b/lib/livebook_web/live/output/markdown_component.ex
index a464bcf55..84b7ce68e 100644
--- a/lib/livebook_web/live/output/markdown_component.ex
+++ b/lib/livebook_web/live/output/markdown_component.ex
@@ -3,30 +3,41 @@ defmodule LivebookWeb.Output.MarkdownComponent do
@impl true
def mount(socket) do
- {:ok, assign(socket, allowed_uri_schemes: Livebook.Config.allowed_uri_schemes())}
+ {:ok, assign(socket, allowed_uri_schemes: Livebook.Config.allowed_uri_schemes(), chunks: 0),
+ temporary_assigns: [text: nil]}
end
@impl true
def update(assigns, socket) do
+ {text, assigns} = Map.pop(assigns, :text)
socket = assign(socket, assigns)
- {:ok,
- push_event(socket, "markdown_renderer:#{socket.assigns.id}:content", %{
- content: socket.assigns.content
- })}
+ if text do
+ {:ok, socket |> assign(text: text) |> update(:chunks, &(&1 + 1))}
+ else
+ {:ok, socket}
+ end
end
@impl true
def render(assigns) do
~H"""
+
<%=
+ @text %>
+
+
"""
end
diff --git a/lib/livebook_web/live/output/plain_text_component.ex b/lib/livebook_web/live/output/plain_text_component.ex
new file mode 100644
index 000000000..820b2f3bc
--- /dev/null
+++ b/lib/livebook_web/live/output/plain_text_component.ex
@@ -0,0 +1,33 @@
+defmodule LivebookWeb.Output.PlainTextComponent do
+ use LivebookWeb, :live_component
+
+ @impl true
+ def mount(socket) do
+ {:ok, assign(socket, chunks: 0), temporary_assigns: [text: nil]}
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ {text, assigns} = Map.pop(assigns, :text)
+ socket = assign(socket, assigns)
+
+ if text do
+ {:ok, socket |> assign(text: text) |> update(:chunks, &(&1 + 1))}
+ else
+ {:ok, socket}
+ end
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
<%=
+ @text %>
+ """
+ end
+end
diff --git a/lib/livebook_web/live/output/stdout_component.ex b/lib/livebook_web/live/output/terminal_text_component.ex
similarity index 93%
rename from lib/livebook_web/live/output/stdout_component.ex
rename to lib/livebook_web/live/output/terminal_text_component.ex
index 4774b8428..0df544212 100644
--- a/lib/livebook_web/live/output/stdout_component.ex
+++ b/lib/livebook_web/live/output/terminal_text_component.ex
@@ -1,4 +1,4 @@
-defmodule LivebookWeb.Output.StdoutComponent do
+defmodule LivebookWeb.Output.TerminalTextComponent do
use LivebookWeb, :live_component
@impl true
@@ -15,7 +15,7 @@ defmodule LivebookWeb.Output.StdoutComponent do
if text do
text = (socket.assigns.last_line || "") <> text
- text = Livebook.Notebook.normalize_stdout(text)
+ text = Livebook.Notebook.normalize_terminal_text(text)
last_line =
case Livebook.Utils.split_at_last_occurrence(text, "\n") do
@@ -49,7 +49,7 @@ defmodule LivebookWeb.Output.StdoutComponent do
phx-hook="VirtualizedLines"
data-max-height="300"
data-follow="true"
- data-max-lines={Livebook.Notebook.max_stdout_lines()}
+ data-max-lines={Livebook.Notebook.max_terminal_lines()}
data-ignore-trailing-empty-line="true"
>
<% # Note 1: We add a newline to each element, so that multiple lines can be copied properly as element.textContent %>
diff --git a/lib/livebook_web/live/output/text_component.ex b/lib/livebook_web/live/output/text_component.ex
deleted file mode 100644
index 012775f08..000000000
--- a/lib/livebook_web/live/output/text_component.ex
+++ /dev/null
@@ -1,38 +0,0 @@
-defmodule LivebookWeb.Output.TextComponent do
- use LivebookWeb, :live_component
-
- @impl true
- def render(assigns) do
- ~H"""
-
- <% # Add a newline to each element, so that multiple lines can be copied properly %>
-
<%= for line <- ansi_string_to_html_lines(@content) do %>