Implement cells bin (#414)

* Implement cells bin

* Temporarily keep cells source

* Send complete bin entries from session to clients when a cell gets removed

* Polish styles

* Hydrate bin entries on section deletion as well
This commit is contained in:
Jonatan Kłosko 2021-06-30 17:48:27 +02:00 committed by GitHub
parent 35366aa18e
commit 2ff327e742
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 693 additions and 52 deletions

View file

@ -122,6 +122,6 @@ solely client-side operations.
@apply hidden;
}
[phx-hook="VirtualizedLines"]:not(:hover) [data-clipboard] {
[phx-hook="VirtualizedLines"]:not(:hover) [phx-hook="ClipCopy"] {
@apply hidden;
}

View file

@ -22,6 +22,8 @@ import UserForm from "./user_form";
import VegaLite from "./vega_lite";
import Timer from "./timer";
import MarkdownRenderer from "./markdown_renderer";
import Highlight from "./highlight";
import ClipCopy from "./clip_copy";
import morphdomCallbacks from "./morphdom_callbacks";
import { loadUserData } from "./lib/user";
@ -37,6 +39,8 @@ const hooks = {
VegaLite,
Timer,
MarkdownRenderer,
Highlight,
ClipCopy,
};
const csrfToken = document

View file

@ -41,3 +41,15 @@ monaco.languages.registerCompletionItemProvider("elixir", {
});
export default monaco;
/**
* Highlights the given code using the same rules as in the editor.
*
* Returns a promise resolving to HTML that renders as the highlighted code.
*/
export function highlight(code, language) {
return monaco.editor.colorize(code, language).then((result) => {
// `colorize` always adds additional newline, so we remove it
return result.replace(/<br\/>$/, "");
});
}

View file

@ -2,21 +2,14 @@ import marked from "marked";
import morphdom from "morphdom";
import DOMPurify from "dompurify";
import katex from "katex";
import monaco from "./live_editor/monaco";
import { highlight } from "./live_editor/monaco";
// Reuse Monaco highlighter for Markdown code blocks
marked.setOptions({
highlight: (code, lang, callback) => {
monaco.editor
.colorize(code, lang)
.then((result) => {
// `colorize` always adds additional newline, so we remove it
result = result.replace(/<br\/>$/, "");
callback(null, result);
})
.catch((error) => {
callback(error, null);
});
highlight(code, lang)
.then((html) => callback(null, html))
.catch((error) => callback(error, null));
},
});

View file

@ -0,0 +1,39 @@
import { getAttributeOrThrow } from "../lib/attribute";
/**
* A hook adding click handler that copies text from the target
* element to clipboard.
*
* Configuration:
*
* * `data-target-id` - HTML id of the element whose `innerText` is copied
*/
const ClipCopy = {
mounted() {
this.props = getProps(this);
this.el.addEventListener("click", (event) => {
const target = document.getElementById(this.props.targetId);
if (target) {
const text = target.innerText;
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text);
}
}
});
},
updated() {
this.props = getProps(this);
},
};
function getProps(hook) {
return {
targetId: getAttributeOrThrow(hook.el, "data-target-id"),
};
}
export default ClipCopy;

View file

@ -0,0 +1,39 @@
import { getAttributeOrThrow } from "../lib/attribute";
import { highlight } from "../cell/live_editor/monaco";
/**
* A hook used to highlight source code in the root element.
*
* Configuration:
*
* * `data-language` - language of the source code
*/
const Highlight = {
mounted() {
this.props = getProps(this);
highlightIn(this.el, this.props.language);
},
updated() {
this.props = getProps(this);
highlightIn(this.el, this.props.language);
},
};
function getProps(hook) {
return {
language: getAttributeOrThrow(hook.el, "data-language"),
};
}
function highlightIn(element, language) {
const code = element.innerText;
highlight(code, language).then((html) => {
element.innerHTML = html;
});
}
export default Highlight;

View file

@ -140,6 +140,10 @@ const Session = {
}
);
this.handleEvent("cell_restored", ({ cell_id: cellId }) => {
handleCellRestored(this, cellId);
});
this.handleEvent("cell_moved", ({ cell_id }) => {
handleCellMoved(this, cell_id);
});
@ -293,6 +297,8 @@ function handleDocumentKeyDown(hook, event) {
toggleClientsList(hook);
} else if (keyBuffer.tryMatch(["s", "r"])) {
showNotebookRuntimeSettings(hook);
} else if (keyBuffer.tryMatch(["s", "b"])) {
showBin(hook);
} else if (keyBuffer.tryMatch(["e", "x"])) {
cancelFocusedCellEvaluation(hook);
} else if (keyBuffer.tryMatch(["?"])) {
@ -327,6 +333,11 @@ function handleDocumentKeyDown(hook, event) {
* (e.g. if the user starts selecting some text within the editor)
*/
function handleDocumentMouseDown(hook, event) {
// If the click is outside the notebook element, keep the focus as is
if (!event.target.closest(`[data-element="notebook"]`)) {
return;
}
// If click targets a clickable element that awaits mouse up, keep the focus as is
if (event.target.closest(`a, button`)) {
// If the pencil icon is clicked, enter insert mode
@ -533,6 +544,10 @@ function showNotebookRuntimeSettings(hook) {
hook.pushEvent("show_runtime_settings", {});
}
function showBin(hook) {
hook.pushEvent("show_bin", {});
}
function saveNotebook(hook) {
hook.pushEvent("save", {});
}
@ -706,6 +721,10 @@ function handleCellDeleted(hook, cellId, siblingCellId) {
}
}
function handleCellRestored(hook, cellId) {
setFocusedCell(hook, cellId);
}
function handleCellMoved(hook, cellId) {
if (hook.state.focusedCellId === cellId) {
globalPubSub.broadcast("cells", { type: "cell_moved", cellId });

View file

@ -24,7 +24,6 @@ import { getLineHeight } from "../lib/utils";
* * one annotated with `data-content` where the visible elements are rendered,
* it should contain any styling relevant for the container
*
* Also a `data-clipboard` child button is used for triggering copy-to-clipboard.
*/
const VirtualizedLines = {
mounted() {
@ -64,18 +63,6 @@ const VirtualizedLines = {
this.state.contentElement,
config
);
this.el
.querySelector("[data-clipboard]")
.addEventListener("click", (event) => {
const content = Array.from(this.state.templateElement.children)
.map((child) => child.innerText)
.join("");
if ("clipboard" in navigator) {
navigator.clipboard.writeText(content);
}
});
},
updated() {

View file

@ -17,20 +17,32 @@ defmodule Livebook.Notebook.Cell do
## Recognised entries
* `disable_formatting` - whether this particular cell should no be automatically formatted.
Relevant for Elixir cells only.
* `disable_formatting` - whether this particular cell should
not be automatically formatted. Relevant for Elixir cells only.
"""
@type metadata :: %{String.t() => term()}
@type t :: Cell.Elixir.t() | Cell.Markdown.t() | Cell.Input.t()
@type type :: :markdown | :elixir | :input
@doc """
Returns an empty cell of the given type.
"""
@spec new(:markdown | :elixir | :input) :: t()
@spec new(type()) :: t()
def new(type)
def new(:markdown), do: Cell.Markdown.new()
def new(:elixir), do: Cell.Elixir.new()
def new(:input), do: Cell.Input.new()
@doc """
Returns an atom representing the type of the given cell.
"""
@spec type(t()) :: type()
def type(cell)
def type(%Cell.Elixir{}), do: :elixir
def type(%Cell.Markdown{}), do: :markdown
def type(%Cell.Input{}), do: :input
end

View file

@ -148,6 +148,14 @@ defmodule Livebook.Session do
GenServer.cast(name(session_id), {:delete_cell, self(), cell_id})
end
@doc """
Asynchronously sends cell restoration request to the server.
"""
@spec restore_cell(id(), Cell.id()) :: :ok
def restore_cell(session_id, cell_id) do
GenServer.cast(name(session_id), {:restore_cell, self(), cell_id})
end
@doc """
Asynchronously sends cell move request to the server.
"""
@ -382,6 +390,11 @@ defmodule Livebook.Session do
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:restore_cell, client_pid, cell_id}, state) do
operation = {:restore_cell, client_pid, cell_id}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:move_cell, client_pid, cell_id, offset}, state) do
operation = {:move_cell, client_pid, cell_id, offset}
{:noreply, handle_operation(state, operation)}
@ -679,6 +692,24 @@ defmodule Livebook.Session do
state
end
defp after_operation(state, _prev_state, {:delete_cell, _client_pid, cell_id}) do
entry = Enum.find(state.data.bin_entries, fn entry -> entry.cell.id == cell_id end)
# The session LV drops cell's source, so we send them
# the complete bin entry to override
broadcast_message(state.session_id, {:hydrate_bin_entries, [entry]})
state
end
defp after_operation(state, prev_state, {:delete_section, _client_pid, section_id, true}) do
{:ok, section} = Notebook.fetch_section(prev_state.data.notebook, section_id)
cell_ids = Enum.map(section.cells, & &1.id)
entries = Enum.filter(state.data.bin_entries, fn entry -> entry.cell.id in cell_ids end)
broadcast_message(state.session_id, {:hydrate_bin_entries, entries})
state
end
defp after_operation(state, _prev_state, _operation), do: state
defp handle_actions(state, actions) do

View file

@ -20,8 +20,7 @@ defmodule Livebook.Session.Data do
:dirty,
:section_infos,
:cell_infos,
:deleted_sections,
:deleted_cells,
:bin_entries,
:runtime,
:clients_map,
:users_map
@ -37,8 +36,7 @@ defmodule Livebook.Session.Data do
dirty: boolean(),
section_infos: %{Section.id() => section_info()},
cell_infos: %{Cell.id() => cell_info()},
deleted_sections: list(Section.t()),
deleted_cells: list(Cell.t()),
bin_entries: list(cell_bin_entry()),
runtime: Runtime.t() | nil,
clients_map: %{pid() => User.id()},
users_map: %{User.id() => User.t()}
@ -61,6 +59,14 @@ defmodule Livebook.Session.Data do
bound_to_input_ids: MapSet.t(Cell.id())
}
@type cell_bin_entry :: %{
cell: Cell.t(),
section_id: Section.id(),
section_name: String.t(),
index: non_neg_integer(),
deleted_at: DateTime.t()
}
@type cell_revision :: non_neg_integer()
@type cell_validity_status :: :fresh | :evaluated | :stale | :aborted
@ -84,6 +90,7 @@ defmodule Livebook.Session.Data do
| {:insert_cell, pid(), Section.id(), index(), Cell.type(), Cell.id()}
| {:delete_section, pid(), Section.id(), delete_cells :: boolean()}
| {:delete_cell, pid(), Cell.id()}
| {:restore_cell, pid(), Cell.id()}
| {:move_cell, pid(), Cell.id(), offset :: integer()}
| {:move_section, pid(), Section.id(), offset :: integer()}
| {:queue_cell_evaluation, pid(), Cell.id()}
@ -123,8 +130,7 @@ defmodule Livebook.Session.Data do
dirty: false,
section_infos: initial_section_infos(notebook),
cell_infos: initial_cell_infos(notebook),
deleted_sections: [],
deleted_cells: [],
bin_entries: [],
runtime: nil,
clients_map: %{},
users_map: %{}
@ -227,6 +233,19 @@ defmodule Livebook.Session.Data do
end
end
def apply_operation(data, {:restore_cell, _client_pid, id}) do
with {:ok, cell_bin_entry} <- fetch_cell_bin_entry(data, id),
true <- data.notebook.sections != [] do
data
|> with_actions()
|> restore_cell(cell_bin_entry)
|> set_dirty()
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:move_cell, _client_pid, id, offset}) do
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, id),
true <- offset != 0 do
@ -506,8 +525,7 @@ defmodule Livebook.Session.Data do
data_actions
|> set!(
notebook: Notebook.delete_section(data.notebook, section.id),
section_infos: Map.delete(data.section_infos, section.id),
deleted_sections: [%{section | cells: []} | data.deleted_sections]
section_infos: Map.delete(data.section_infos, section.id)
)
end
@ -518,7 +536,16 @@ defmodule Livebook.Session.Data do
|> add_action({:forget_evaluation, cell, section})
|> set!(
notebook: Notebook.delete_cell(data.notebook, cell.id),
deleted_cells: [cell | data.deleted_cells]
bin_entries: [
%{
cell: cell,
section_id: section.id,
section_name: section.name,
index: Enum.find_index(section.cells, &(&1 == cell)),
deleted_at: DateTime.utc_now()
}
| data.bin_entries
]
)
|> delete_cell_info(cell)
end
@ -528,6 +555,21 @@ defmodule Livebook.Session.Data do
|> set!(cell_infos: Map.delete(data.cell_infos, cell.id))
end
defp restore_cell({data, _} = data_actions, cell_bin_entry) do
{section, index} =
case Notebook.fetch_section(data.notebook, cell_bin_entry.section_id) do
# Insert at the index of deletion, it may be no longer accurate,
# but even then makes for a good approximation and the cell can be easily moved
{:ok, section} -> {section, cell_bin_entry.index}
# Insert at the end of the notebook if the section no longer exists
:error -> {List.last(data.notebook.sections), -1}
end
data_actions
|> insert_cell(section.id, index, cell_bin_entry.cell)
|> set!(bin_entries: List.delete(data.bin_entries, cell_bin_entry))
end
defp move_cell({data, _} = data_actions, cell, offset) do
updated_notebook = Notebook.move_cell(data.notebook, cell.id, offset)
@ -949,6 +991,12 @@ defmodule Livebook.Session.Data do
%{cell_info | deltas: deltas}
end
defp fetch_cell_bin_entry(data, cell_id) do
Enum.find_value(data.bin_entries, :error, fn entry ->
entry.cell.id == cell_id && {:ok, entry}
end)
end
defp add_action({data, actions}, action) do
{data, actions ++ [action]}
end

156
lib/livebook/utils/time.ex Normal file
View file

@ -0,0 +1,156 @@
defmodule Livebook.Utils.Time do
@moduledoc false
# A simplified version of https://gist.github.com/tlemens/88e9b08f62150ba6082f478a4a03ac52
@doc """
Formats the given point in time relatively to present.
"""
@spec time_ago_in_words(NaiveDateTime.t()) :: String.t()
def time_ago_in_words(naive_date_time) when is_struct(naive_date_time, NaiveDateTime) do
now = NaiveDateTime.utc_now()
if NaiveDateTime.compare(naive_date_time, now) == :gt do
raise ArgumentError, "expected a datetime in the past, got: #{inspect(naive_date_time)}"
end
distance_of_time_in_words(naive_date_time, now)
end
@doc """
Formats time distance between `from_ndt` and `to_ndt`
as a human-readable string.
## Examples
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:15:04])
"less than 5 seconds"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:15:09])
"less than 10 seconds"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:15:19])
"less than 20 seconds"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:15:20])
"half a minute"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:15:39])
"half a minute"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:15:40])
"less than a minute"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:15:59])
"less than a minute"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:16:00])
"1 minute"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:16:29])
"1 minute"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:16:30])
"2 minutes"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:58:30])
"44 minutes"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 18:59:30])
"about 1 hour"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-20 19:59:30])
"about 2 hours"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-21 18:14:00])
"about 24 hours"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-21 18:15:00])
"1 day"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-06-22 18:15:00])
"2 days"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2020-07-22 18:15:00])
"about 1 month"
iex> Livebook.Utils.Time.distance_of_time_in_words(~N[2020-06-20 18:15:00], ~N[2021-08-22 18:15:00])
"about 14 months"
"""
@spec distance_of_time_in_words(NaiveDateTime.t(), NaiveDateTime.t()) :: String.t()
def distance_of_time_in_words(from_ndt, to_ndt)
when is_struct(from_ndt, NaiveDateTime) and is_struct(to_ndt, NaiveDateTime) do
duration_seconds = NaiveDateTime.diff(to_ndt, from_ndt)
{:seconds, duration_seconds}
|> maybe_convert_to_minutes()
|> duration_in_words()
end
defp maybe_convert_to_minutes({:seconds, seconds}) when seconds > 59 do
{:minutes, round(seconds / 60)}
end
defp maybe_convert_to_minutes(duration), do: duration
defp duration_in_words({:seconds, seconds}) when seconds in 0..4 do
"less than 5 seconds"
end
defp duration_in_words({:seconds, seconds}) when seconds in 5..9 do
"less than 10 seconds"
end
defp duration_in_words({:seconds, seconds}) when seconds in 10..19 do
"less than 20 seconds"
end
defp duration_in_words({:seconds, seconds}) when seconds in 20..39 do
"half a minute"
end
defp duration_in_words({:seconds, seconds}) when seconds in 40..59 do
"less than a minute"
end
defp duration_in_words({:minutes, minutes}) when minutes == 1 do
"1 minute"
end
defp duration_in_words({:minutes, minutes}) when minutes in 2..44 do
"#{minutes} minutes"
end
defp duration_in_words({:minutes, minutes}) when minutes in 45..89 do
"about 1 hour"
end
# 90 mins up to 24 hours
defp duration_in_words({:minutes, minutes}) when minutes in 90..1439 do
"about #{round(minutes / 60)} hours"
end
# 24 hours up to 42 hours
defp duration_in_words({:minutes, minutes}) when minutes in 1440..2519 do
"1 day"
end
# 42 hours up to 30 days
defp duration_in_words({:minutes, minutes}) when minutes in 2520..43_199 do
"#{round(minutes / 1440)} days"
end
# 30 days up to 45 days
defp duration_in_words({:minutes, minutes}) when minutes in 43_200..64_799 do
"about 1 month"
end
# 45 days up to 60 days
defp duration_in_words({:minutes, minutes}) when minutes in 64_800..86_399 do
"about 2 months"
end
defp duration_in_words({:minutes, minutes}) do
"about #{round(minutes / 43_200)} months"
end
end

View file

@ -9,17 +9,18 @@ defmodule LivebookWeb.Output.TextComponent do
phx-hook="VirtualizedLines"
data-max-height="300"
data-follow="<%= @follow %>">
<div data-template class="hidden">
<%= for line <- ansi_to_html_lines(@content) do %>
<%# Add a newline, so that multiple lines can be copied properly %>
<div><%= [line, "\n"] %></div>
<% end %>
</div>
<%# Add a newline to each element, so that multiple lines can be copied properly %>
<div data-template class="hidden"
id="virtualized-text-<%= @id %>-template"
><%= for line <- ansi_to_html_lines(@content) do %><div><%= [line, "\n"] %></div><% end %></div>
<div data-content class="overflow-auto whitespace-pre font-editor text-gray-500 tiny-scrollbar"
id="virtualized-text-<%= @id %>-content"
phx-update="ignore"></div>
<div class="absolute right-4 top-0 z-10">
<button class="icon-button bg-gray-100" data-clipboard>
<button class="icon-button bg-gray-100"
id="virtualized-text-<%= @id %>-clipcopy"
phx-hook="ClipCopy"
data-target-id="virtualized-text-<%= @id %>-template">
<%= remix_icon("clipboard-line", class: "text-lg") %>
</button>
</div>

View file

@ -97,6 +97,13 @@ defmodule LivebookWeb.SessionLive do
label: "Runtime settings (sr)",
active: @live_action == :runtime_settings
},
%{
type: :link,
icon: "delete-bin-6-fill",
path: Routes.session_path(@socket, :bin, @session_id),
label: "Bin (sb)",
active: @live_action == :bin
},
%{type: :break},
%{
type: :link,
@ -297,6 +304,15 @@ defmodule LivebookWeb.SessionLive do
is_first: @section.id == @first_section_id,
return_to: Routes.session_path(@socket, :page, @session_id) %>
<% end %>
<%= if @live_action == :bin do %>
<%= live_modal LivebookWeb.SessionLive.BinComponent,
id: :bin_modal,
modal_class: "w-full max-w-4xl",
session_id: @session_id,
bin_entries: @data_view.bin_entries,
return_to: Routes.session_path(@socket, :page, @session_id) %>
<% end %>
"""
end
@ -545,6 +561,11 @@ defmodule LivebookWeb.SessionLive do
)}
end
def handle_event("show_bin", %{}, socket) do
{:noreply,
push_patch(socket, to: Routes.session_path(socket, :bin, socket.assigns.session_id))}
end
def handle_event("completion_request", %{"hint" => hint, "cell_id" => cell_id}, socket) do
data = socket.private.data
@ -631,6 +652,25 @@ defmodule LivebookWeb.SessionLive do
{:noreply, put_flash(socket, :info, message)}
end
def handle_info({:hydrate_bin_entries, hydrated_entries}, socket) do
hydrated_entries_map = Map.new(hydrated_entries, fn entry -> {entry.cell.id, entry} end)
data =
Map.update!(socket.private.data, :bin_entries, fn bin_entries ->
Enum.map(bin_entries, fn entry ->
case Map.fetch(hydrated_entries_map, entry.cell.id) do
{:ok, hydrated_entry} -> hydrated_entry
:error -> entry
end
end)
end)
{:noreply,
socket
|> assign_private(data: data)
|> assign(data_view: data_to_view(data))}
end
def handle_info(:session_closed, socket) do
{:noreply,
socket
@ -732,6 +772,14 @@ defmodule LivebookWeb.SessionLive do
push_event(socket, "cell_deleted", %{cell_id: cell_id, sibling_cell_id: sibling_cell_id})
end
defp after_operation(socket, _prev_socket, {:restore_cell, client_pid, cell_id}) do
if client_pid == self() do
push_event(socket, "cell_restored", %{cell_id: cell_id})
else
socket
end
end
defp after_operation(socket, _prev_socket, {:move_cell, client_pid, cell_id, _offset}) do
if client_pid == self() do
push_event(socket, "cell_moved", %{cell_id: cell_id})
@ -831,7 +879,8 @@ defmodule LivebookWeb.SessionLive do
data.clients_map
|> Enum.map(fn {client_pid, user_id} -> {client_pid, data.users_map[user_id]} end)
|> Enum.sort_by(fn {_client_pid, user} -> user.name end),
section_views: section_views(data.notebook.sections, data)
section_views: section_views(data.notebook.sections, data),
bin_entries: data.bin_entries
}
end

View file

@ -0,0 +1,145 @@
defmodule LivebookWeb.SessionLive.BinComponent do
use LivebookWeb, :live_component
alias Livebook.Notebook.Cell
@initial_limit 10
@limit_step 10
@impl true
def mount(socket) do
{:ok, assign(socket, search: "", limit: @initial_limit)}
end
@impl true
def update(assigns, socket) do
{bin_entries, assigns} = Map.pop(assigns, :bin_entries)
# Only show text cells, as they have an actual content
bin_entries =
Enum.filter(bin_entries, fn entry ->
Cell.type(entry.cell) in [:markdown, :elixir]
end)
{:ok,
socket
|> assign(:bin_entries, bin_entries)
|> assign(assigns)
|> assign_matching_entries()}
end
@impl true
def render(assigns) do
~L"""
<div class="p-6 max-w-4xl flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800">
Bin
</h3>
<div class="w-full flex-col space-y-5">
<p class="text-gray-700">
Here you can find all the cells deleted within this session.
</p>
<%= if @bin_entries == [] do %>
<div class="p-5 flex space-x-4 items-center border border-gray-200 rounded-lg">
<div>
<%= remix_icon("windy-line", class: "text-gray-400 text-xl") %>
</div>
<div class="text-gray-600">
There are currently no cells in the bin.
</div>
</div>
<% else %>
<form phx-change="search" onsubmit="return false" phx-target="<%= @myself %>">
<input class="input"
type="text"
name="search"
value="<%= @search %>"
placeholder="Search"
autocomplete="off"
spellcheck="false"
autofocus />
</form>
<div class="flex flex-col space-y-8 overflow-y-auto tiny-scrollbar h-[30rem] pr-3 pb-1">
<%= for %{cell: cell} = entry <- Enum.take(@matching_entries, @limit) do %>
<div class="flex flex-col space-y-1">
<div class="flex justify-between items-center">
<p class="text-sm text-gray-700">
<span class="font-semibold"><%= Cell.type(cell) |> Atom.to_string() |> String.capitalize() %></span> cell
deleted from <span class="font-semibold"><%= entry.section_name %></span> section
<span class="font-semibold">
<%= entry.deleted_at |> DateTime.to_naive() |> Livebook.Utils.Time.time_ago_in_words() %> ago
</span>
</p>
<div class="flex justify-end space-x-2">
<span class="tooltip left" aria-label="Copy source">
<button class="icon-button"
id="bin-cell-<%= cell.id %>-clipcopy"
phx-hook="ClipCopy"
data-target-id="bin-cell-<%= cell.id %>-source">
<%= remix_icon("clipboard-line", class: "text-lg") %>
</button>
</span>
<span class="tooltip left" aria-label="Restore">
<button class="icon-button"
phx-click="restore"
phx-value-cell_id="<%= entry.cell.id %>"
phx-target="<%= @myself %>">
<%= remix_icon("arrow-go-back-line", class: "text-lg") %>
</button>
</span>
</div>
</div>
<div class="markdown">
<pre><code
id="bin-cell-<%= cell.id %>-source"
phx-hook="Highlight"
data-language="<%= Cell.type(cell) %>"><%= cell.source %></code></pre>
</div>
</div>
<% end %>
<%= if length(@matching_entries) > @limit do %>
<div class="flex justify-center">
<button class="button button-outlined-gray" phx-click="more" phx-target="<%= @myself %>">
Older
</button>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
"""
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply, assign(socket, search: search, limit: @initial_limit) |> assign_matching_entries()}
end
def handle_event("more", %{}, socket) do
{:noreply, assign(socket, limit: socket.assigns.limit + @limit_step)}
end
def handle_event("restore", %{"cell_id" => cell_id}, socket) do
Livebook.Session.restore_cell(socket.assigns.session_id, cell_id)
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
defp assign_matching_entries(socket) do
matching_entries = filter_matching(socket.assigns.bin_entries, socket.assigns.search)
assign(socket, matching_entries: matching_entries)
end
defp filter_matching(entries, search) do
parts =
search
|> String.split()
|> Enum.map(fn part -> ~r/#{Regex.escape(part)}/i end)
Enum.filter(entries, fn entry ->
Enum.all?(parts, fn part ->
entry.cell.source =~ part
end)
end)
end
end

View file

@ -32,7 +32,8 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
%{seq: ["e", "x"], desc: "Cancel cell evaluation"},
%{seq: ["s", "s"], desc: "Toggle sections panel"},
%{seq: ["s", "u"], desc: "Toggle users panel"},
%{seq: ["s", "r"], desc: "Show runtime settings"}
%{seq: ["s", "r"], desc: "Show runtime settings"},
%{seq: ["s", "b"], desc: "Show bin"}
],
universal: [
%{seq: ["ctrl", "s"], press_all: true, desc: "Save notebook"}

View file

@ -31,6 +31,7 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
live "/sessions/:id/settings/file", SessionLive, :file_settings
live "/sessions/:id/bin", SessionLive, :bin
live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings
live "/sessions/:id/cell-upload/:cell_id", SessionLive, :cell_upload
live "/sessions/:id/delete-section/:section_id", SessionLive, :delete_section

View file

@ -142,7 +142,7 @@ defmodule Livebook.Session.DataTest do
assert :error = Data.apply_operation(data, operation)
end
test "removes the section from notebook and section info, adds to deleted sections" do
test "removes the section from notebook and section info" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"}
@ -156,8 +156,7 @@ defmodule Livebook.Session.DataTest do
notebook: %{
sections: []
},
section_infos: ^empty_map,
deleted_sections: [%{id: "s1"}]
section_infos: ^empty_map
}, []} = Data.apply_operation(data, operation)
end
@ -188,7 +187,7 @@ defmodule Livebook.Session.DataTest do
notebook: %{
sections: [%{id: "s1", cells: [%{id: "c1"}, %{id: "c2"}]}]
},
deleted_cells: []
bin_entries: []
}, []} = Data.apply_operation(data, operation)
end
@ -208,7 +207,15 @@ defmodule Livebook.Session.DataTest do
notebook: %{
sections: [%{id: "s1", cells: [%{id: "c1"}]}]
},
deleted_cells: [%{id: "c2"}]
bin_entries: [
%{
cell: %{id: "c2"},
section_id: "s2",
section_name: "Section",
index: 0,
deleted_at: _
}
]
},
[{:forget_evaluation, %{id: "c2"}, %{id: "s2"}}]} =
Data.apply_operation(data, operation)
@ -281,7 +288,15 @@ defmodule Livebook.Session.DataTest do
sections: [%{cells: []}]
},
cell_infos: ^empty_map,
deleted_cells: [%{id: "c1"}]
bin_entries: [
%{
cell: %{id: "c1"},
section_id: "s1",
section_name: "Section",
index: 0,
deleted_at: _
}
]
}, _actions} = Data.apply_operation(data, operation)
end
@ -360,6 +375,75 @@ defmodule Livebook.Session.DataTest do
end
end
describe "apply_operation/2 given :restore_cell" do
test "returns an error if cell with the given id is not in the bin" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
])
operation = {:restore_cell, self(), "c1"}
assert :error = Data.apply_operation(data, operation)
end
test "returns an error if there are no sections to restore to" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:delete_section, self(), "s1", true}
])
operation = {:restore_cell, self(), "c1"}
assert :error = Data.apply_operation(data, operation)
end
test "if the original section exists, restores cell at the index of deletion" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:insert_cell, self(), "s1", 1, :elixir, "c2"},
{:insert_cell, self(), "s1", 2, :elixir, "c3"},
{:delete_cell, self(), "c2"}
])
operation = {:restore_cell, self(), "c2"}
assert {:ok,
%{
notebook: %{
sections: [%{id: "s1", cells: [%{id: "c1"}, %{id: "c2"}, %{id: "c3"}]}]
},
cell_infos: %{"c2" => %{}},
bin_entries: []
}, _actions} = Data.apply_operation(data, operation)
end
test "if the original section does not exist, restores cell at the end of the notebook" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
{:insert_section, self(), 1, "s2"},
{:insert_cell, self(), "s2", 0, :elixir, "c2"},
{:delete_section, self(), "s1", true}
])
operation = {:restore_cell, self(), "c1"}
assert {:ok,
%{
notebook: %{
sections: [%{id: "s2", cells: [%{id: "c2"}, %{id: "c1"}]}]
},
cell_infos: %{"c2" => %{}},
bin_entries: []
}, _actions} = Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :move_cell" do
test "returns an error given invalid cell id" do
data = Data.new()

View file

@ -61,6 +61,19 @@ defmodule Livebook.SessionTest do
end
end
describe "restore_cell/2" do
test "sends a restore opreation 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)
Session.delete_cell(session_id, cell_id)
Session.restore_cell(session_id, cell_id)
assert_receive {:operation, {:restore_cell, ^pid, ^cell_id}}
end
end
describe "queue_cell_evaluation/2" do
test "sends a queue evaluation operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")

View file

@ -0,0 +1,7 @@
defmodule Livebook.Utils.TimeTest do
use ExUnit.Case, async: true
alias Livebook.Utils
doctest Utils.Time
end