mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-05 20:44:30 +08:00
> Disclaimer: it's a proof-of-a-concept for an idea discussion. It's an attempt to introduce a "Render ... Markdown blocks" feature to the published apps as an option. It shall consider to render markdown block from the original notebook as an addition to the evaluable cells. Due to the limited understanding of how app rendering pipeline works, this commit should be considered only as a proof-of-a-concept, that most likely will be reworked fully. P.S. Eventually, it should also take image and section titles cells and render them.
442 lines
12 KiB
Elixir
442 lines
12 KiB
Elixir
defmodule LivebookWeb.Output do
|
|
use LivebookWeb, :html
|
|
|
|
alias LivebookWeb.Output
|
|
|
|
@doc """
|
|
Renders a single cell output.
|
|
"""
|
|
attr :id, :string, required: true
|
|
attr :output, :map, required: true
|
|
attr :session_id, :string, required: true
|
|
attr :session_pid, :any, required: true
|
|
attr :input_views, :map, required: true
|
|
attr :client_id, :string, required: true
|
|
attr :cell_id, :string, required: true
|
|
|
|
def output(assigns) do
|
|
~H"""
|
|
<div id={@id} class="max-w-full" data-el-output data-border={border?(@output)}>
|
|
{render_output(@output, %{
|
|
id: "#{@id}-output",
|
|
session_id: @session_id,
|
|
session_pid: @session_pid,
|
|
input_views: @input_views,
|
|
client_id: @client_id,
|
|
cell_id: @cell_id
|
|
})}
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp border?(%{type: :terminal_text}), do: true
|
|
defp border?(%{type: :error, context: {:interrupt, _, _}}), do: false
|
|
defp border?(%{type: :error}), do: true
|
|
defp border?(%{type: :grid, boxed: boxed}), do: boxed
|
|
defp border?(_output), do: false
|
|
|
|
defp render_output(%{type: :terminal_text} = output, %{id: id}) do
|
|
assigns = %{id: id, output: output}
|
|
|
|
~H"""
|
|
<.live_component module={Output.TerminalTextComponent} id={@id} output={@output} />
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :plain_text} = output, %{id: id}) do
|
|
assigns = %{id: id, output: output}
|
|
|
|
~H"""
|
|
<.live_component module={Output.PlainTextComponent} id={@id} output={@output} />
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :markdown} = output, %{id: id, session_id: session_id}) do
|
|
assigns = %{id: id, session_id: session_id, output: output}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={Output.MarkdownComponent}
|
|
id={@id}
|
|
session_id={@session_id}
|
|
output={@output}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :markdown_static} = output, %{id: id, session_id: session_id}) do
|
|
assigns = %{id: id, session_id: session_id, output: output}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={Output.MarkdownStaticComponent}
|
|
id={@id}
|
|
session_id={@session_id}
|
|
output={@output}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :image} = output, %{id: id}) do
|
|
assigns = %{id: id, content: output.content, mime_type: output.mime_type}
|
|
|
|
~H"""
|
|
<Output.ImageComponent.render id={@id} content={@content} mime_type={@mime_type} />
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :js} = output, %{
|
|
id: id,
|
|
session_id: session_id,
|
|
client_id: client_id
|
|
}) do
|
|
assigns = %{
|
|
id: id,
|
|
js_view: output.js_view,
|
|
session_id: session_id,
|
|
client_id: client_id
|
|
}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={LivebookWeb.JSViewComponent}
|
|
id={@id}
|
|
js_view={@js_view}
|
|
session_id={@session_id}
|
|
client_id={@client_id}
|
|
timeout_message="Output data no longer available, please reevaluate this cell"
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :frame} = output, %{
|
|
id: id,
|
|
session_id: session_id,
|
|
session_pid: session_pid,
|
|
input_views: input_views,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}) do
|
|
assigns = %{
|
|
id: id,
|
|
outputs: output.outputs,
|
|
placeholder: output.placeholder,
|
|
session_id: session_id,
|
|
session_pid: session_pid,
|
|
input_views: input_views,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={Output.FrameComponent}
|
|
id={@id}
|
|
outputs={@outputs}
|
|
placeholder={@placeholder}
|
|
session_id={@session_id}
|
|
session_pid={@session_pid}
|
|
input_views={@input_views}
|
|
client_id={@client_id}
|
|
cell_id={@cell_id}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :tabs, outputs: outputs, labels: labels}, %{
|
|
id: id,
|
|
session_id: session_id,
|
|
session_pid: session_pid,
|
|
input_views: input_views,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}) do
|
|
assigns = %{
|
|
id: id,
|
|
labels: labels,
|
|
outputs: outputs,
|
|
session_id: session_id,
|
|
session_pid: session_pid,
|
|
input_views: input_views,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={Output.TabsComponent}
|
|
id={@id}
|
|
outputs={@outputs}
|
|
labels={@labels}
|
|
session_id={@session_id}
|
|
session_pid={@session_pid}
|
|
input_views={@input_views}
|
|
client_id={@client_id}
|
|
cell_id={@cell_id}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :grid} = grid, %{
|
|
id: id,
|
|
session_id: session_id,
|
|
session_pid: session_pid,
|
|
input_views: input_views,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}) do
|
|
assigns = %{
|
|
id: id,
|
|
columns: grid.columns,
|
|
gap: grid.gap,
|
|
max_height: grid.max_height,
|
|
outputs: grid.outputs,
|
|
session_id: session_id,
|
|
session_pid: session_pid,
|
|
input_views: input_views,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={Output.GridComponent}
|
|
id={@id}
|
|
outputs={@outputs}
|
|
columns={@columns}
|
|
gap={@gap}
|
|
max_height={@max_height}
|
|
session_id={@session_id}
|
|
session_pid={@session_pid}
|
|
input_views={@input_views}
|
|
client_id={@client_id}
|
|
cell_id={@cell_id}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :input} = input, %{
|
|
id: id,
|
|
input_views: input_views,
|
|
session_pid: session_pid,
|
|
client_id: client_id
|
|
}) do
|
|
assigns = %{
|
|
id: id,
|
|
input: input,
|
|
input_views: input_views,
|
|
session_pid: session_pid,
|
|
client_id: client_id
|
|
}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={Output.InputComponent}
|
|
id={@id}
|
|
input={@input}
|
|
input_views={@input_views}
|
|
session_pid={@session_pid}
|
|
client_id={@client_id}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :control} = control, %{
|
|
id: id,
|
|
input_views: input_views,
|
|
session_pid: session_pid,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}) do
|
|
assigns = %{
|
|
id: id,
|
|
control: control,
|
|
input_views: input_views,
|
|
session_pid: session_pid,
|
|
client_id: client_id,
|
|
cell_id: cell_id
|
|
}
|
|
|
|
~H"""
|
|
<.live_component
|
|
module={Output.ControlComponent}
|
|
id={@id}
|
|
control={@control}
|
|
input_views={@input_views}
|
|
session_pid={@session_pid}
|
|
client_id={@client_id}
|
|
cell_id={@cell_id}
|
|
/>
|
|
"""
|
|
end
|
|
|
|
defp render_output(
|
|
%{type: :error, context: {:missing_secret, secret_name}} = output,
|
|
%{session_id: session_id, id: id}
|
|
) do
|
|
assigns = %{message: output.message, secret_name: secret_name, session_id: session_id, id: id}
|
|
|
|
~H"""
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex items-center justify-between gap-2" style="color: var(--ansi-color-red);">
|
|
<div class="flex space-x-2 font-editor">
|
|
<.remix_icon icon="close-circle-line" />
|
|
<span>Missing secret {inspect(@secret_name)}</span>
|
|
</div>
|
|
<.button color="gray" patch={~p"/sessions/#{@session_id}/secrets?secret_name=#{@secret_name}"}>
|
|
Add secret
|
|
</.button>
|
|
</div>
|
|
<div class="border-t border-gray-200 -mx-4" />
|
|
{render_formatted_error_message(@id, @message)}
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp render_output(
|
|
%{type: :error, context: {:file_entry_forbidden, file_entry_name}} = output,
|
|
%{session_id: session_id, id: id}
|
|
) do
|
|
assigns = %{
|
|
message: output.message,
|
|
file_entry_name: file_entry_name,
|
|
session_id: session_id,
|
|
id: id
|
|
}
|
|
|
|
~H"""
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex items-center justify-between gap-2" style="color: var(--ansi-color-red);">
|
|
<div class="flex space-x-2 font-editor">
|
|
<.remix_icon icon="close-circle-line" />
|
|
<span>Forbidden access to file {inspect(@file_entry_name)}</span>
|
|
</div>
|
|
<.button
|
|
color="gray"
|
|
phx-click={JS.push("review_file_entry_access", value: %{name: @file_entry_name})}
|
|
>
|
|
Review access
|
|
</.button>
|
|
</div>
|
|
<div class="border-t border-gray-200 -mx-4" />
|
|
{render_formatted_error_message(@id, @message)}
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp render_output(
|
|
%{type: :error, context: {:interrupt, variant, message}},
|
|
%{cell_id: cell_id}
|
|
) do
|
|
assigns = %{variant: variant, message: message, cell_id: cell_id}
|
|
|
|
~H"""
|
|
<div class={[
|
|
"flex justify-between items-center px-4 py-2 border-l-4 shadow-custom-1",
|
|
case @variant do
|
|
:error -> "text-red-400 border-red-400"
|
|
:normal -> "text-gray-500 border-gray-300"
|
|
end
|
|
]}>
|
|
<div>
|
|
{@message}
|
|
</div>
|
|
<button
|
|
class={[
|
|
"px-5 py-2 font-medium text-sm inline-flex rounded-lg border whitespace-nowrap items-center justify-center gap-1",
|
|
case @variant do
|
|
:error -> "border-red-400 text-red-400 hover:bg-red-50 focus:bg-red-50"
|
|
:normal -> "border-gray-300 text-gray-500 hover:bg-gray-100 focus:bg-gray-100"
|
|
end
|
|
]}
|
|
phx-click="queue_interrupted_cell_evaluation"
|
|
phx-value-cell_id={@cell_id}
|
|
>
|
|
<.remix_icon icon="play-circle-fill" />
|
|
<span>Continue</span>
|
|
</button>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp render_output(%{type: :error, context: :dependencies} = output, %{id: id, cell_id: cell_id}) do
|
|
assigns = %{message: output.message, id: id, cell_id: cell_id}
|
|
|
|
if cell_id == Livebook.Notebook.Cell.main_setup_cell_id() do
|
|
~H"""
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex items-center justify-between gap-2" style="color: var(--ansi-color-red);">
|
|
<div class="flex space-x-2 font-editor">
|
|
<.remix_icon icon="close-circle-line" />
|
|
<span>
|
|
Trouble installing dependencies? You may want to retry without cache.
|
|
</span>
|
|
</div>
|
|
<.button
|
|
color="gray"
|
|
phx-click={
|
|
JS.push("queue_cell_evaluation",
|
|
value: %{cell_id: @cell_id, disable_dependencies_cache: true}
|
|
)
|
|
}
|
|
>
|
|
Setup without cache
|
|
</.button>
|
|
</div>
|
|
<div class="border-t border-gray-200 -mx-4" />
|
|
{render_formatted_error_message(@id, @message)}
|
|
</div>
|
|
"""
|
|
else
|
|
render_formatted_error_message(id, output.message)
|
|
end
|
|
end
|
|
|
|
defp render_output(%{type: :error, message: message}, %{id: id}) do
|
|
render_formatted_error_message(id, message)
|
|
end
|
|
|
|
defp render_output(output, %{}) do
|
|
req = Livebook.Runtime.Definitions.kino_requirement()
|
|
|
|
render_error_message("""
|
|
Unknown output format: #{inspect(output)}. You may want to explicitly \
|
|
add {:kino, "#{req}"} as a notebook dependency or update to the latest \
|
|
Livebook.
|
|
""")
|
|
end
|
|
|
|
defp render_error_message(message) do
|
|
assigns = %{message: message}
|
|
|
|
~H"""
|
|
<div
|
|
class="whitespace-pre-wrap break-words font-editor text-red-600"
|
|
role="complementary"
|
|
aria-label="error message"
|
|
phx-no-format
|
|
>{@message}</div>
|
|
"""
|
|
end
|
|
|
|
defp render_formatted_error_message(id, message) do
|
|
assigns = %{id: id, message: message}
|
|
|
|
~H"""
|
|
<div id={@id} class="relative group/error">
|
|
<div
|
|
id={"#{@id}-message"}
|
|
class="whitespace-pre-wrap break-words font-editor text-gray-500"
|
|
role="complementary"
|
|
aria-label="error"
|
|
phx-no-format
|
|
>{LivebookWeb.ANSIHelpers.ansi_string_to_html(@message)}</div>
|
|
<div class="absolute right-2 top-0 z-10 invisible group-hover/error:visible">
|
|
<.icon_button phx-click={JS.dispatch("lb:clipcopy", to: "##{@id}-message")}>
|
|
<.remix_icon icon="clipboard-line" />
|
|
</.icon_button>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|