Limit standard output to last 1000 lines (#1063)

This commit is contained in:
Jonatan Kłosko 2022-03-19 12:22:36 +01:00 committed by GitHub
parent becdda61f7
commit 0145d68593
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 161 additions and 36 deletions

View file

@ -1,3 +1,5 @@
import { scrollToEnd } from "../lib/utils";
/** /**
* A hook used to scroll to the bottom of an element whenever it * A hook used to scroll to the bottom of an element whenever it
* receives LV update. * receives LV update.
@ -12,7 +14,7 @@ const ScrollOnUpdate = {
}, },
scroll() { scroll() {
this.el.scrollTop = this.el.scrollHeight; scrollToEnd(this.el);
}, },
}; };

View file

@ -1,10 +1,16 @@
import HyperList from "hyperlist"; import HyperList from "hyperlist";
import { import {
getAttributeOrDefault,
getAttributeOrThrow, getAttributeOrThrow,
parseBoolean, parseBoolean,
parseInteger, parseInteger,
} from "../lib/attribute"; } from "../lib/attribute";
import { findChildOrThrow, getLineHeight, isScrolledToEnd } from "../lib/utils"; import {
findChildOrThrow,
getLineHeight,
isScrolledToEnd,
scrollToEnd,
} from "../lib/utils";
/** /**
* A hook used to render text lines as a virtual list, so that only * A hook used to render text lines as a virtual list, so that only
@ -16,7 +22,13 @@ import { findChildOrThrow, getLineHeight, isScrolledToEnd } from "../lib/utils";
* this height enables scrolling * this height enables scrolling
* *
* * `data-follow` - whether to automatically scroll to the bottom as * * `data-follow` - whether to automatically scroll to the bottom as
* new lines appear * new lines appear. Defaults to false
*
* * `data-max-lines` - the maximum number of lines to keep in the DOM.
* By default all lines are kept
*
* * `data-ignore-trailing-empty-line` - whether to ignore the last
* line if it is empty. Defaults to false
* *
* The element should have two children: * The element should have two children:
* *
@ -34,32 +46,58 @@ const VirtualizedLines = {
this.templateEl = findChildOrThrow(this.el, "[data-template]"); this.templateEl = findChildOrThrow(this.el, "[data-template]");
this.contentEl = findChildOrThrow(this.el, "[data-content]"); this.contentEl = findChildOrThrow(this.el, "[data-content]");
this.capLines();
const config = this.hyperListConfig(); const config = this.hyperListConfig();
this.virtualizedList = new HyperList(this.contentEl, config); this.virtualizedList = new HyperList(this.contentEl, config);
if (this.props.follow) {
scrollToEnd(this.contentEl);
}
}, },
updated() { updated() {
this.props = this.getProps(); this.props = this.getProps();
const scrollToEnd = this.props.follow && isScrolledToEnd(this.contentEl); this.capLines();
const shouldScrollToEnd =
this.props.follow && isScrolledToEnd(this.contentEl);
const config = this.hyperListConfig(); const config = this.hyperListConfig();
this.virtualizedList.refresh(this.contentEl, config); this.virtualizedList.refresh(this.contentEl, config);
if (scrollToEnd) { if (shouldScrollToEnd) {
this.contentEl.scrollTop = this.contentEl.scrollHeight; scrollToEnd(this.contentEl);
} }
}, },
getProps() { getProps() {
return { return {
maxHeight: getAttributeOrThrow(this.el, "data-max-height", parseInteger), maxHeight: getAttributeOrThrow(this.el, "data-max-height", parseInteger),
follow: getAttributeOrThrow(this.el, "data-follow", parseBoolean), follow: getAttributeOrDefault(
this.el,
"data-follow",
false,
parseBoolean
),
maxLines: getAttributeOrDefault(
this.el,
"data-max-lines",
null,
parseInteger
),
ignoreTrailingEmptyLine: getAttributeOrDefault(
this.el,
"data-ignore-trailing-empty-line",
false,
parseBoolean
),
}; };
}, },
hyperListConfig() { hyperListConfig() {
const lineEls = this.templateEl.querySelectorAll("[data-line]"); const lineEls = this.getLineElements();
const numberOfLines = lineEls.length; const numberOfLines = lineEls.length;
const height = Math.min( const height = Math.min(
@ -89,6 +127,38 @@ const VirtualizedLines = {
}, },
}; };
}, },
getLineElements() {
const lineEls = Array.from(this.templateEl.querySelectorAll("[data-line]"));
if (lineEls.length === 0) {
return [];
}
const lastLineEl = lineEls[lineEls.length - 1];
if (this.props.ignoreTrailingEmptyLine && lastLineEl.innerText === "") {
return lineEls.slice(0, -1);
} else {
return lineEls;
}
},
capLines() {
if (this.props.maxLines) {
const lineEls = Array.from(
this.templateEl.querySelectorAll("[data-line]")
);
const ignoredLineEls = lineEls.slice(0, -this.props.maxLines);
const [first, ...rest] = ignoredLineEls;
rest.forEach((lineEl) => lineEl.remove());
if (first) {
first.innerHTML = "...";
}
}
},
}; };
export default VirtualizedLines; export default VirtualizedLines;

View file

@ -13,14 +13,15 @@ export function getAttributeOrThrow(element, attr, transform = null) {
export function getAttributeOrDefault( export function getAttributeOrDefault(
element, element,
attr, attr,
defaultAttrVal, defaultValue,
transform = null transform = null
) { ) {
const value = element.hasAttribute(attr) if (element.hasAttribute(attr)) {
? element.getAttribute(attr) const value = element.getAttribute(attr);
: defaultAttrVal;
return transform ? transform(value) : value; return transform ? transform(value) : value;
} else {
return defaultValue;
}
} }
export function parseBoolean(value) { export function parseBoolean(value) {

View file

@ -66,6 +66,10 @@ export function isScrolledToEnd(element) {
); );
} }
export function scrollToEnd(element) {
element.scrollTop = element.scrollHeight;
}
/** /**
* Transforms a UTF8 string into base64 encoding. * Transforms a UTF8 string into base64 encoding.
*/ */

View file

@ -612,7 +612,7 @@ defmodule Livebook.Notebook do
defp apply_frame_update(outputs, new_outputs, :append), do: new_outputs ++ outputs defp apply_frame_update(outputs, new_outputs, :append), do: new_outputs ++ outputs
defp add_output([], {idx, {:stdout, text}}), defp add_output([], {idx, {:stdout, text}}),
do: [{idx, {:stdout, Livebook.Utils.apply_rewind(text)}}] do: [{idx, {:stdout, normalize_stdout(text)}}]
defp add_output([], output), do: [output] defp add_output([], output), do: [output]
@ -626,11 +626,28 @@ defmodule Livebook.Notebook do
# Session server keeps all outputs, so we merge consecutive stdouts # Session server keeps all outputs, so we merge consecutive stdouts
defp add_output([{idx, {:stdout, text}} | tail], {_idx, {:stdout, cont}}) do defp add_output([{idx, {:stdout, text}} | tail], {_idx, {:stdout, cont}}) do
[{idx, {:stdout, Livebook.Utils.apply_rewind(text <> cont)}} | tail] [{idx, {:stdout, normalize_stdout(text <> cont)}} | tail]
end end
defp add_output(outputs, output), do: [output | outputs] 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())
end
@doc """
The maximum desired number of lines of the standard output.
"""
def max_stdout_lines(), do: 1_000
@doc """ @doc """
Recursively adds index to all outputs, including frames. Recursively adds index to all outputs, including frames.
""" """

View file

@ -360,13 +360,50 @@ defmodule Livebook.Utils do
"Hola\r\nHey\r" "Hola\r\nHey\r"
""" """
@spec apply_rewind(String.t()) :: String.t() @spec apply_rewind(String.t()) :: String.t()
def apply_rewind(string) when is_binary(string) do def apply_rewind(text) when is_binary(text) do
string apply_rewind(text, "", "")
|> String.split("\n") end
|> Enum.map(fn line ->
String.replace(line, ~r/^.*\r([^\r].*)$/, "\\1") defp apply_rewind(<<?\n, rest::binary>>, acc, line),
end) do: apply_rewind(rest, <<acc::binary, line::binary, ?\n>>, "")
|> Enum.join("\n")
defp apply_rewind(<<?\r, byte, rest::binary>>, acc, _line) when byte != ?\n,
do: apply_rewind(rest, acc, <<byte>>)
defp apply_rewind(<<byte, rest::binary>>, acc, line),
do: apply_rewind(rest, acc, <<line::binary, byte>>)
defp apply_rewind("", acc, line), do: acc <> line
@doc ~S"""
Limits `text` to last `max_lines`.
Replaces the removed lines with `"..."`.
## Examples
iex> Livebook.Utils.cap_lines("Line 1\nLine 2\nLine 3\nLine 4", 2)
"...\nLine 3\nLine 4"
iex> Livebook.Utils.cap_lines("Line 1\nLine 2", 2)
"Line 1\nLine 2"
iex> Livebook.Utils.cap_lines("Line 1\nLine 2", 3)
"Line 1\nLine 2"
"""
@spec cap_lines(String.t(), non_neg_integer()) :: String.t()
def cap_lines(text, max_lines) do
text
|> :binary.matches("\n")
|> Enum.at(-max_lines)
|> case do
nil ->
text
{pos, _len} ->
<<_ignore::binary-size(pos), rest::binary>> = text
"..." <> rest
end
end end
@doc """ @doc """

View file

@ -38,14 +38,14 @@ defmodule LivebookWeb.Output do
defp render_output({:stdout, text}, %{id: id}) do defp render_output({:stdout, text}, %{id: id}) do
text = if(text == :__pruned__, do: nil, else: text) text = if(text == :__pruned__, do: nil, else: text)
live_component(Output.StdoutComponent, id: id, text: text, follow: true) live_component(Output.StdoutComponent, id: id, text: text)
end end
defp render_output({:text, text}, %{id: id}) do defp render_output({:text, text}, %{id: id}) do
assigns = %{id: id, text: text} assigns = %{id: id, text: text}
~H""" ~H"""
<Output.TextComponent.render id={@id} content={@text} follow={false} /> <Output.TextComponent.render id={@id} content={@text} />
""" """
end end

View file

@ -15,12 +15,7 @@ defmodule LivebookWeb.Output.StdoutComponent do
if text do if text do
text = (socket.assigns.last_line || "") <> text text = (socket.assigns.last_line || "") <> text
# Captured output usually has a trailing newline that we ignore text = Livebook.Notebook.normalize_stdout(text)
# for HTML conversion, since each line is an HTML block anyway
trailing_newline? = String.ends_with?(text, "\n")
text = String.replace_suffix(text, "\n", "")
text = Livebook.Utils.apply_rewind(text)
last_line = last_line =
case Livebook.Utils.split_at_last_occurrence(text, "\n") do case Livebook.Utils.split_at_last_occurrence(text, "\n") do
@ -28,8 +23,6 @@ defmodule LivebookWeb.Output.StdoutComponent do
{:ok, _, last_line} -> last_line {:ok, _, last_line} -> last_line
end end
last_line = last_line <> if(trailing_newline?, do: "\n", else: "")
{html_lines, modifiers} = {html_lines, modifiers} =
LivebookWeb.Helpers.ANSI.ansi_string_to_html_lines_step(text, socket.assigns.modifiers) LivebookWeb.Helpers.ANSI.ansi_string_to_html_lines_step(text, socket.assigns.modifiers)
@ -54,7 +47,9 @@ defmodule LivebookWeb.Output.StdoutComponent do
class="relative" class="relative"
phx-hook="VirtualizedLines" phx-hook="VirtualizedLines"
data-max-height="300" data-max-height="300"
data-follow={to_string(@follow)}> data-follow="true"
data-max-lines={Livebook.Notebook.max_stdout_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 %> <%# Note 1: We add a newline to each element, so that multiple lines can be copied properly as element.textContent %>
<%# Note 2: We use comments to avoid inserting unintended whitespace %> <%# Note 2: We use comments to avoid inserting unintended whitespace %>
<div data-template class="hidden" id={"virtualized-text-#{@id}-template"}><%# <div data-template class="hidden" id={"virtualized-text-#{@id}-template"}><%#

View file

@ -7,8 +7,7 @@ defmodule LivebookWeb.Output.TextComponent do
<div id={"virtualized-text-#{@id}"} <div id={"virtualized-text-#{@id}"}
class="relative" class="relative"
phx-hook="VirtualizedLines" phx-hook="VirtualizedLines"
data-max-height="300" data-max-height="300">
data-follow={to_string(@follow)}>
<%# Add a newline to each element, so that multiple lines can be copied properly %> <%# Add a newline to each element, so that multiple lines can be copied properly %>
<div data-template class="hidden" <div data-template class="hidden"
id={"virtualized-text-#{@id}-template"} id={"virtualized-text-#{@id}-template"}