Improve "changed" indicator and batch evaluation shortcuts (#766)

* Make cell status italic when content changed

* Add Ctrl+Shift+Enter for evaluating all cells

* Improve the behaviour of evaluating all cells

* Fix typo
This commit is contained in:
Jonatan Kłosko 2021-12-07 19:14:32 +01:00 committed by GitHub
parent 812f753a37
commit 89ea67861f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 417 additions and 276 deletions

View file

@ -81,7 +81,13 @@ solely client-side operations.
@apply opacity-100 pointer-events-auto;
}
[data-element="cell"] [data-element="change-indicator"]:not([data-js-shown]) {
[data-element="cell"] [data-element="cell-status"][data-js-changed] {
@apply italic;
}
[data-element="cell"]
[data-element="cell-status"]:not([data-js-changed])
[data-element="change-indicator"] {
@apply invisible;
}

View file

@ -66,15 +66,18 @@ const Cell = {
this.state.evaluationDigest = evaluation_digest;
const updateChangeIndicator = () => {
const indicator = this.el.querySelector(
`[data-element="change-indicator"]`
const cellStatus = this.el.querySelector(
`[data-element="cell-status"]`
);
const indicator =
cellStatus &&
cellStatus.querySelector(`[data-element="change-indicator"]`);
if (indicator) {
const source = this.state.liveEditor.getSource();
const digest = md5Base64(source);
const changed = this.state.evaluationDigest !== digest;
indicator.toggleAttribute("data-js-shown", changed);
cellStatus.toggleAttribute("data-js-changed", changed);
}
};

View file

@ -297,6 +297,7 @@ function handleDocumentKeyDown(hook, event) {
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;
const alt = event.altKey;
const shift = event.shiftKey;
const key = event.key;
const keyBuffer = hook.state.keyBuffer;
@ -321,7 +322,10 @@ function handleDocumentKeyDown(hook, event) {
if (!monacoInputOpen && !completionBoxOpen && !signatureDetailsOpen) {
escapeInsertMode(hook);
}
} else if (cmd && key === "Enter" && !alt) {
} else if (cmd && shift && !alt && key === "Enter") {
cancelEvent(event);
queueFullCellsEvaluation(hook, true);
} else if (cmd && !alt && key === "Enter") {
cancelEvent(event);
if (hook.state.focusedCellType === "elixir") {
queueFocusedCellEvaluation(hook);
@ -350,12 +354,17 @@ function handleDocumentKeyDown(hook, event) {
saveNotebook(hook);
} else if (keyBuffer.tryMatch(["d", "d"])) {
deleteFocusedCell(hook);
} else if (keyBuffer.tryMatch(["e", "e"]) || (cmd && key === "Enter")) {
} else if (cmd && shift && !alt && key === "Enter") {
queueFullCellsEvaluation(hook, true);
} else if (keyBuffer.tryMatch(["e", "a"])) {
queueFullCellsEvaluation(hook, false);
} else if (
keyBuffer.tryMatch(["e", "e"]) ||
(cmd && !alt && key === "Enter")
) {
if (hook.state.focusedCellType === "elixir") {
queueFocusedCellEvaluation(hook);
}
} else if (keyBuffer.tryMatch(["e", "a"])) {
queueAllCellsEvaluation(hook);
} else if (keyBuffer.tryMatch(["e", "s"])) {
queueFocusedSectionEvaluation(hook);
} else if (keyBuffer.tryMatch(["s", "s"])) {
@ -667,8 +676,15 @@ function queueFocusedCellEvaluation(hook) {
}
}
function queueAllCellsEvaluation(hook) {
hook.pushEvent("queue_all_cells_evaluation", {});
function queueFullCellsEvaluation(hook, includeFocused) {
const forcedCellIds =
includeFocused && hook.state.focusedId && isCell(hook.state.focusedId)
? [hook.state.focusedId]
: [];
hook.pushEvent("queue_full_evaluation", {
forced_cell_ids: forcedCellIds,
});
}
function queueFocusedSectionEvaluation(hook) {
@ -676,7 +692,7 @@ function queueFocusedSectionEvaluation(hook) {
const sectionId = getSectionIdByFocusableId(hook.state.focusedId);
if (sectionId) {
hook.pushEvent("queue_section_cells_evaluation", {
hook.pushEvent("queue_section_evaluation", {
section_id: sectionId,
});
}

View file

@ -416,6 +416,23 @@ defmodule Livebook.Notebook do
|> Enum.filter(fn {cell, _} -> MapSet.member?(child_cell_ids, cell.id) end)
end
@doc """
Returns the list with the given parent cells and all of
their child cells.
The cells are not ordered in any secific way.
"""
@spec cell_ids_with_children(t(), list(Cell.id())) :: list(Cell.id())
def cell_ids_with_children(data, parent_cell_ids) do
graph = cell_dependency_graph(data.notebook)
for parent_id <- parent_cell_ids,
leaf_id <- Graph.leaves(graph),
cell_id <- Graph.find_path(graph, leaf_id, parent_id),
uniq: true,
do: cell_id
end
@doc """
Computes cell dependency graph.

View file

@ -148,7 +148,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends notebook attributes update to the server.
Sends notebook attributes update to the server.
"""
@spec set_notebook_attributes(pid(), map()) :: :ok
def set_notebook_attributes(pid, attrs) do
@ -156,7 +156,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends section insertion request to the server.
Sends section insertion request to the server.
"""
@spec insert_section(pid(), non_neg_integer()) :: :ok
def insert_section(pid, index) do
@ -164,7 +164,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends section insertion request to the server.
Sends section insertion request to the server.
"""
@spec insert_section_into(pid(), Section.id(), non_neg_integer()) :: :ok
def insert_section_into(pid, section_id, index) do
@ -172,7 +172,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends parent update request to the server.
Sends parent update request to the server.
"""
@spec set_section_parent(pid(), Section.id(), Section.id()) :: :ok
def set_section_parent(pid, section_id, parent_id) do
@ -180,7 +180,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends parent update request to the server.
Sends parent update request to the server.
"""
@spec unset_section_parent(pid(), Section.id()) :: :ok
def unset_section_parent(pid, section_id) do
@ -188,7 +188,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends cell insertion request to the server.
Sends cell insertion request to the server.
"""
@spec insert_cell(pid(), Section.id(), non_neg_integer(), Cell.type()) :: :ok
def insert_cell(pid, section_id, index, type) do
@ -196,7 +196,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends section deletion request to the server.
Sends section deletion request to the server.
"""
@spec delete_section(pid(), Section.id(), boolean()) :: :ok
def delete_section(pid, section_id, delete_cells) do
@ -204,7 +204,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends cell deletion request to the server.
Sends cell deletion request to the server.
"""
@spec delete_cell(pid(), Cell.id()) :: :ok
def delete_cell(pid, cell_id) do
@ -212,7 +212,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends cell restoration request to the server.
Sends cell restoration request to the server.
"""
@spec restore_cell(pid(), Cell.id()) :: :ok
def restore_cell(pid, cell_id) do
@ -220,7 +220,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends cell move request to the server.
Sends cell move request to the server.
"""
@spec move_cell(pid(), Cell.id(), integer()) :: :ok
def move_cell(pid, cell_id, offset) do
@ -228,7 +228,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends section move request to the server.
Sends section move request to the server.
"""
@spec move_section(pid(), Section.id(), integer()) :: :ok
def move_section(pid, section_id, offset) do
@ -236,7 +236,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends cell evaluation request to the server.
Sends cell evaluation request to the server.
"""
@spec queue_cell_evaluation(pid(), Cell.id()) :: :ok
def queue_cell_evaluation(pid, cell_id) do
@ -244,7 +244,34 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends cell evaluation cancellation request to the server.
Sends section evaluation request to the server.
"""
@spec queue_section_evaluation(pid(), Section.id()) :: :ok
def queue_section_evaluation(pid, section_id) do
GenServer.cast(pid, {:queue_section_evaluation, self(), section_id})
end
@doc """
Sends input bound cells evaluation request to the server.
"""
@spec queue_bound_cells_evaluation(pid(), Data.input_id()) :: :ok
def queue_bound_cells_evaluation(pid, input_id) do
GenServer.cast(pid, {:queue_bound_cells_evaluation, self(), input_id})
end
@doc """
Sends full evaluation request to the server.
All outdated (new/stale/changed) cells, as well as cells given
as `forced_cell_ids` are scheduled for evaluation.
"""
@spec queue_full_evaluation(pid(), list(Cell.id())) :: :ok
def queue_full_evaluation(pid, forced_cell_ids) do
GenServer.cast(pid, {:queue_full_evaluation, self(), forced_cell_ids})
end
@doc """
Sends cell evaluation cancellation request to the server.
"""
@spec cancel_cell_evaluation(pid(), Cell.id()) :: :ok
def cancel_cell_evaluation(pid, cell_id) do
@ -252,7 +279,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends erase outputs request to the server.
Sends erase outputs request to the server.
"""
@spec erase_outputs(pid()) :: :ok
def erase_outputs(pid) do
@ -260,7 +287,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends notebook name update request to the server.
Sends notebook name update request to the server.
"""
@spec set_notebook_name(pid(), String.t()) :: :ok
def set_notebook_name(pid, name) do
@ -268,7 +295,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends section name update request to the server.
Sends section name update request to the server.
"""
@spec set_section_name(pid(), Section.id(), String.t()) :: :ok
def set_section_name(pid, section_id, name) do
@ -276,7 +303,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends a cell delta to apply to the server.
Sends a cell delta to apply to the server.
"""
@spec apply_cell_delta(pid(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok
def apply_cell_delta(pid, cell_id, delta, revision) do
@ -284,7 +311,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously informs at what revision the given client is.
Informs at what revision the given client is.
This helps to remove old deltas that are no longer necessary.
"""
@ -294,7 +321,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends a cell attributes update to the server.
Sends a cell attributes update to the server.
"""
@spec set_cell_attributes(pid(), Cell.id(), map()) :: :ok
def set_cell_attributes(pid, cell_id, attrs) do
@ -302,15 +329,15 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends a input value update to the server.
Sends a input value update to the server.
"""
@spec set_input_value(pid(), Session.input_id(), term()) :: :ok
@spec set_input_value(pid(), Data.input_id(), term()) :: :ok
def set_input_value(pid, input_id, value) do
GenServer.cast(pid, {:set_input_value, self(), input_id, value})
end
@doc """
Asynchronously connects to the given runtime.
Connects to the given runtime.
Note that this results in initializing the corresponding remote node
with modules and processes required for evaluation.
@ -321,7 +348,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously disconnects from the current runtime.
Disconnects from the current runtime.
Note that this results in clearing the evaluation state.
"""
@ -331,7 +358,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends file location update request to the server.
Sends file location update request to the server.
"""
@spec set_file(pid(), FileSystem.File.t() | nil) :: :ok
def set_file(pid, file) do
@ -339,7 +366,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends save request to the server.
Sends save request to the server.
If there's a file set and the notebook changed since the last save,
it will be persisted to said file.
@ -360,7 +387,7 @@ defmodule Livebook.Session do
end
@doc """
Asynchronously sends a close request to the server.
Sends a close request to the server.
This results in saving the file and broadcasting
a :closed message to the session topic.
@ -533,7 +560,35 @@ defmodule Livebook.Session do
end
def handle_cast({:queue_cell_evaluation, client_pid, cell_id}, state) do
operation = {:queue_cell_evaluation, client_pid, cell_id}
operation = {:queue_cells_evaluation, client_pid, [cell_id]}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:queue_section_evaluation, client_pid, section_id}, state) do
case Notebook.fetch_section(state.data.notebook, section_id) do
{:ok, section} ->
cell_ids = for cell <- section.cells, is_struct(cell, Cell.Elixir), do: cell.id
operation = {:queue_cells_evaluation, client_pid, cell_ids}
{:noreply, handle_operation(state, operation)}
:error ->
{:noreply, state}
end
end
def handle_cast({:queue_bound_cells_evaluation, client_pid, input_id}, state) do
cell_ids =
for {bound_cell, _} <- Data.bound_cells_with_section(state.data, input_id),
do: bound_cell.id
operation = {:queue_cells_evaluation, client_pid, cell_ids}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:queue_full_evaluation, client_pid, forced_cell_ids}, state) do
cell_ids = Data.cell_ids_for_full_evaluation(state.data, forced_cell_ids)
operation = {:queue_cells_evaluation, client_pid, cell_ids}
{:noreply, handle_operation(state, operation)}
end

View file

@ -126,7 +126,7 @@ defmodule Livebook.Session.Data do
| {: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()}
| {:queue_cells_evaluation, pid(), list(Cell.id())}
| {:evaluation_started, pid(), Cell.id(), binary()}
| {:add_cell_evaluation_output, pid(), Cell.id(), term()}
| {:add_cell_evaluation_response, pid(), Cell.id(), term(), metadata :: map()}
@ -379,20 +379,28 @@ defmodule Livebook.Session.Data do
end
end
def apply_operation(data, {:queue_cell_evaluation, _client_pid, id}) do
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id),
%Cell.Elixir{} <- cell,
:ready <- data.cell_infos[cell.id].evaluation_status do
data
|> with_actions()
|> queue_prerequisite_cells_evaluation(cell)
|> queue_cell_evaluation(cell, section)
def apply_operation(data, {:queue_cells_evaluation, _client_pid, cell_ids}) do
cells_with_section =
data.notebook
|> Notebook.elixir_cells_with_section()
|> Enum.filter(fn {cell, _section} ->
info = data.cell_infos[cell.id]
cell.id in cell_ids and info.evaluation_status == :ready
end)
if cell_ids != [] and length(cell_ids) == length(cells_with_section) do
cells_with_section
|> Enum.reduce(with_actions(data), fn {cell, section}, data_actions ->
data_actions
|> queue_prerequisite_cells_evaluation(cell)
|> queue_cell_evaluation(cell, section)
end)
|> maybe_start_runtime(data)
|> maybe_evaluate_queued()
|> compute_snapshots_and_validity()
|> wrap_ok()
else
_ -> :error
:error
end
end
@ -1490,4 +1498,40 @@ defmodule Livebook.Session.Data do
|> queue_cell_evaluation(cell, section)
end)
end
@doc """
Checks if the given cell is outdated.
A cell is considered outdated if its new/fresh or its content
has changed since the last evaluation.
"""
@spec cell_outdated?(t(), Cell.t()) :: boolean()
def cell_outdated?(data, cell) do
info = data.cell_infos[cell.id]
digest = :erlang.md5(cell.source)
info.validity_status != :evaluated or info.evaluation_digest != digest
end
@doc """
Returns the list of cell ids for full evaluation.
The list includes all outdated cells, cells in `forced_cell_ids`
and all of their child cells.
"""
@spec cell_ids_for_full_evaluation(t(), list(Cell.id())) :: list(Cell.id())
def cell_ids_for_full_evaluation(data, forced_cell_ids) do
elixir_cells_with_section = Notebook.elixir_cells_with_section(data.notebook)
evaluable_cell_ids =
for {cell, _} <- elixir_cells_with_section,
cell_outdated?(data, cell) or cell.id in forced_cell_ids,
uniq: true,
do: cell.id
cell_ids = Notebook.cell_ids_with_children(data, evaluable_cell_ids)
for {cell, _} <- elixir_cells_with_section,
cell.id in cell_ids,
do: cell.id
end
end

View file

@ -665,26 +665,18 @@ defmodule LivebookWeb.SessionLive do
def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id}, socket) do
Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id)
{:noreply, socket}
end
def handle_event("queue_section_cells_evaluation", %{"section_id" => section_id}, socket) do
with {:ok, section} <- Notebook.fetch_section(socket.private.data.notebook, section_id) do
for cell <- section.cells, is_struct(cell, Cell.Elixir) do
Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id)
end
end
{:noreply, socket}
end
def handle_event("queue_all_cells_evaluation", _params, socket) do
data = socket.private.data
def handle_event("queue_section_evaluation", %{"section_id" => section_id}, socket) do
Session.queue_section_evaluation(socket.assigns.session.pid, section_id)
for {cell, _} <- Notebook.elixir_cells_with_section(data.notebook),
data.cell_infos[cell.id].validity_status != :evaluated do
Session.queue_cell_evaluation(socket.assigns.session.pid, cell.id)
end
{:noreply, socket}
end
def handle_event("queue_full_evaluation", %{"forced_cell_ids" => forced_cell_ids}, socket) do
Session.queue_full_evaluation(socket.assigns.session.pid, forced_cell_ids)
{:noreply, socket}
end
@ -917,9 +909,7 @@ defmodule LivebookWeb.SessionLive do
end
def handle_info({:queue_bound_cells_evaluation, input_id}, socket) do
for {bound_cell, _} <- Session.Data.bound_cells_with_section(socket.private.data, input_id) do
Session.queue_cell_evaluation(socket.assigns.session.pid, bound_cell.id)
end
Session.queue_bound_cells_evaluation(socket.assigns.session.pid, input_id)
{:noreply, socket}
end

View file

@ -309,7 +309,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
~H"""
<div class={"#{if(@tooltip, do: "tooltip")} bottom distant-medium"} data-tooltip={@tooltip}>
<div class="flex items-center space-x-1">
<div class="flex text-xs text-gray-400">
<div class="flex text-xs text-gray-400" data-element="cell-status">
<%= render_slot(@inner_block) %>
<%= if @change_indicator do %>
<span data-element="change-indicator">*</span>

View file

@ -101,7 +101,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
%{seq: ["d", "d"], desc: "Delete cell", basic: true},
%{seq: ["e", "e"], desc: "Evaluate cell"},
%{seq: ["e", "s"], desc: "Evaluate section"},
%{seq: ["e", "a"], desc: "Evaluate all stale/new cells", basic: true},
%{seq: ["e", "a"], desc: "Evaluate all outdated cells", basic: true},
%{seq: ["e", "x"], desc: "Cancel cell evaluation"},
%{seq: ["s", "s"], desc: "Toggle sections panel"},
%{seq: ["s", "u"], desc: "Toggle users panel"},
@ -117,6 +117,13 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
desc: "Evaluate cell in either mode",
basic: true
},
%{
seq: ["ctrl", "shift", ""],
seq_mac: ["", "", ""],
press_all: true,
desc: "Evaluate current and all outdated cells",
basic: true
},
%{
seq: ["ctrl", "s"],
seq_mac: ["", "s"],

File diff suppressed because it is too large Load diff

View file

@ -90,7 +90,7 @@ defmodule Livebook.SessionTest do
Session.queue_cell_evaluation(session.pid, cell_id)
assert_receive {:operation, {:queue_cell_evaluation, ^pid, ^cell_id}}
assert_receive {:operation, {:queue_cells_evaluation, ^pid, [^cell_id]}}
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, _,