Notebook status indicators (#127)

* Add notebook indicators

* Make evaluation status button point to the corresponding cell

* Rename introductory notebook

* Update path highlight when chosen for saving

* Allow specifying nonexistent directories when saving and create those

* Update lib/livebook_web/live/session_live/indicators_component.ex

Co-authored-by: José Valim <jose.valim@dashbit.co>

* Update lib/livebook_web/live/session_live/indicators_component.ex

Co-authored-by: José Valim <jose.valim@dashbit.co>

* Update lib/livebook_web/live/session_live/indicators_component.ex

Co-authored-by: José Valim <jose.valim@dashbit.co>

* Update lib/livebook_web/live/session_live/indicators_component.ex

Co-authored-by: José Valim <jose.valim@dashbit.co>

* Update lib/livebook_web/live/session_live/indicators_component.ex

Co-authored-by: José Valim <jose.valim@dashbit.co>

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2021-04-01 12:56:19 +02:00 committed by GitHub
parent 4e90666350
commit 5efd8eb851
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 237 additions and 46 deletions

View file

@ -64,6 +64,10 @@
line-height: 1;
}
.icon-outlined-button {
@apply rounded-full border-2;
}
/* Form fields */
.input {

View file

@ -16,8 +16,8 @@ solely client-side operations.
/* === Session === */
[data-element="session"]:not([data-js-insert-mode])
[data-element="insert-indicator"] {
@apply hidden;
[data-element="insert-mode-indicator"] {
@apply invisible;
}
[data-element="session"]

View file

@ -11,6 +11,7 @@ Example usage:
/* Tooltip element wrapping the actual hoverable element */
.tooltip {
position: relative;
display: flex;
--distance: 4px;
--arrow-size: 5px;
--show-delay: 0.5s;

View file

@ -47,15 +47,17 @@ const Session = {
handleSectionListClick(this, event);
});
getSectionsPanelToggle().addEventListener("click", (event) => {
toggleSectionsPanel(this);
});
getNotebook().addEventListener("scroll", (event) => {
updateSectionListHighlight();
});
document
.querySelector(`[data-element="sections-panel-toggle"]`)
.addEventListener("click", (event) => {
toggleSectionsPanel(this);
});
getCellIndicators().addEventListener("click", (event) => {
handleCellIndicatorsClick(this, event);
});
// DOM setup
@ -246,6 +248,17 @@ function handleSectionListClick(hook, event) {
}
}
/**
* Handles button clicks within cell indicators section.
*/
function handleCellIndicatorsClick(hook, event) {
const button = event.target.closest(`[data-element="focus-cell-button"]`);
if (button) {
const cellId = button.getAttribute("data-target");
setFocusedCell(hook, cellId);
}
}
/**
* Handles the main notebook area being scrolled.
*/
@ -552,10 +565,18 @@ function getSectionList() {
return document.querySelector(`[data-element="section-list"]`);
}
function getCellIndicators() {
return document.querySelector(`[data-element="notebook-indicators"]`);
}
function getNotebook() {
return document.querySelector(`[data-element="notebook"]`);
}
function getSectionsPanelToggle() {
return document.querySelector(`[data-element="sections-panel-toggle"]`);
}
function cancelEvent(event) {
// Cancel any default browser behavior.
event.preventDefault();

View file

@ -1,6 +1,6 @@
defmodule Livebook.Notebook.HelloLivebook do
defmodule Livebook.Notebook.Welcome do
livemd = ~s'''
# Hello Livebook
# Welcome to Livebook
## Introduction
@ -22,7 +22,7 @@ defmodule Livebook.Notebook.HelloLivebook do
There are **Markdown** cells (such as this one) that allow you to describe your work
and **Elixir** cells where the magic takes place!
To insert a new cell move your between cells and click one of the revealed buttons. 👇
To insert a new cell move your cursor between cells and click one of the revealed buttons. 👇
```elixir
# This is an Elixir cell - as the name suggests that's where the code goes.

View file

@ -579,10 +579,12 @@ defmodule Livebook.Session do
if state.data.path != nil and state.data.dirty do
content = LiveMarkdown.Export.notebook_to_markdown(state.data.notebook)
case File.write(state.data.path, content) do
:ok ->
handle_operation(state, {:mark_as_not_dirty, self()})
dir = Path.dirname(state.data.path)
with :ok <- File.mkdir_p(dir),
:ok <- File.write(state.data.path, content) do
handle_operation(state, {:mark_as_not_dirty, self()})
else
{:error, reason} ->
broadcast_error(state.session_id, "failed to save notebook - #{reason}")
state

View file

@ -39,7 +39,7 @@ defmodule LivebookWeb.HomeLive do
<div class="flex space-x-2">
<span class="tooltip top" aria-label="Introduction">
<button class="button button-outlined-gray button-square-icon"
phx-click="hello_livebook">
phx-click="open_welcome">
<%= remix_icon("compass-line") %>
</button>
</span>
@ -116,8 +116,8 @@ defmodule LivebookWeb.HomeLive do
{:noreply, assign(socket, path: path)}
end
def handle_event("hello_livebook", %{}, socket) do
create_session(socket, notebook: Livebook.Notebook.HelloLivebook.new())
def handle_event("open_welcome", %{}, socket) do
create_session(socket, notebook: Livebook.Notebook.Welcome.new())
end
def handle_event("new", %{}, socket) do

View file

@ -80,20 +80,20 @@ defmodule LivebookWeb.SessionLive do
<img src="/logo.png" height="40" width="40" alt="livebook" />
<% end %>
<span class="tooltip right distant" aria-label="Sections (ss)">
<button class="text-2xl text-gray-600 hover:text-gray-50 focus:text-gray-50" data-element="sections-panel-toggle">
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50" data-element="sections-panel-toggle">
<%= remix_icon("booklet-fill") %>
</button>
</span>
<span class="tooltip right distant" aria-label="Notebook settings (sn)">
<%= live_patch to: Routes.session_path(@socket, :settings, @session_id, "file"),
class: "text-gray-600 hover:text-gray-50 focus:text-gray-50 #{if(@live_action == :settings, do: "text-gray-50")}" do %>
class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 #{if(@live_action == :settings, do: "text-gray-50")}" do %>
<%= remix_icon("settings-4-fill", class: "text-2xl") %>
<% end %>
</span>
<div class="flex-grow"></div>
<span class="tooltip right distant" aria-label="Keyboard shortcuts (?)">
<%= live_patch to: Routes.session_path(@socket, :shortcuts, @session_id),
class: "text-gray-600 hover:text-gray-50 focus:text-gray-50 #{if(@live_action == :shortcuts, do: "text-gray-50")}" do %>
class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 #{if(@live_action == :shortcuts, do: "text-gray-50")}" do %>
<%= remix_icon("keyboard-box-fill", class: "text-2xl") %>
<% end %>
</span>
@ -151,9 +151,10 @@ defmodule LivebookWeb.SessionLive do
</div>
</div>
</div>
<%# Show a tiny insert indicator for clarity %>
<div class="fixed right-5 bottom-1 text-gray-500 text-semibold text-sm" data-element="insert-indicator">
insert
<div class="fixed bottom-[0.5rem] right-[1.5rem]">
<%= live_component @socket, LivebookWeb.SessionLive.IndicatorsComponent,
session_id: @session_id,
data_view: @data_view %>
</div>
</div>
"""
@ -328,6 +329,12 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("save", %{}, socket) do
Session.save(socket.assigns.session_id)
{:noreply, socket}
end
def handle_event("show_shortcuts", %{}, socket) do
{:noreply,
push_patch(socket, to: Routes.session_path(socket, :shortcuts, socket.assigns.session_id))}
@ -472,7 +479,9 @@ defmodule LivebookWeb.SessionLive do
defp data_to_view(data) do
%{
path: data.path,
dirty: data.dirty,
runtime: data.runtime,
global_evaluation_status: global_evaluation_status(data),
notebook_name: data.notebook.name,
sections_items:
for section <- data.notebook.sections do
@ -482,6 +491,33 @@ defmodule LivebookWeb.SessionLive do
}
end
defp global_evaluation_status(data) do
cells =
data.notebook
|> Notebook.elixir_cells_with_section()
|> Enum.map(fn {cell, _} -> cell end)
cond do
evaluating = Enum.find(cells, &evaluating?(&1, data)) ->
{:evaluating, evaluating.id}
stale = Enum.find(cells, &stale?(&1, data)) ->
{:stale, stale.id}
evaluated = Enum.find(Enum.reverse(cells), &evaluated?(&1, data)) ->
{:evaluated, evaluated.id}
true ->
{:fresh, nil}
end
end
defp evaluating?(cell, data), do: data.cell_infos[cell.id].evaluation_status == :evaluating
defp stale?(cell, data), do: data.cell_infos[cell.id].validity_status == :stale
defp evaluated?(cell, data), do: data.cell_infos[cell.id].validity_status == :evaluated
defp section_to_view(section, data) do
%{
id: section.id,

View file

@ -0,0 +1,106 @@
defmodule LivebookWeb.SessionLive.IndicatorsComponent do
use LivebookWeb, :live_component
@impl true
def render(assigns) do
~L"""
<div class="flex flex-col space-y-2 items-center" data-element="notebook-indicators">
<%= if @data_view.path do %>
<%= if @data_view.dirty do %>
<span class="tooltip left" aria-label="Autosave pending">
<button class="icon-button icon-outlined-button border-blue-400 hover:bg-blue-50 focus:bg-blue-50"
phx-click="save">
<%= remix_icon("save-line", class: "text-xl text-blue-500") %>
</button>
</span>
<% else %>
<span class="tooltip left" aria-label="Notebook saved">
<button class="icon-button icon-outlined-button border-green-300 hover:bg-green-50 focus:bg-green-50 cursor-default">
<%= remix_icon("save-line", class: "text-xl text-green-400") %>
</button>
</span>
<% end %>
<% else %>
<span class="tooltip left" aria-label="Choose a file to save the notebook">
<%= live_patch to: Routes.session_path(@socket, :settings, @session_id, "file"),
class: "icon-button icon-outlined-button border-gray-200 hover:bg-gray-100 focus:bg-gray-100" do %>
<%= remix_icon("save-line", class: "text-xl text-gray-400") %>
<% end %>
</span>
<% end %>
<%= if @data_view.runtime do %>
<%= render_global_evaluation_status(@data_view.global_evaluation_status) %>
<% else %>
<span class="tooltip left" aria-label="Choose a runtime to run the notebook in">
<%= live_patch to: Routes.session_path(@socket, :settings, @session_id, "runtime"),
class: "icon-button icon-outlined-button border-gray-200 hover:bg-gray-100 focus:bg-gray-100" do %>
<%= remix_icon("loader-3-line", class: "text-xl text-gray-400") %>
<% end %>
</span>
<% end %>
<%# Note: this indicator is shown/hidden using CSS based on the current mode %>
<span class="tooltip left" aria-label="Insert mode" data-element="insert-mode-indicator">
<span class="text-sm text-gray-400 font-medium cursor-default">
ins
</span>
</span>
</div>
"""
end
defp render_global_evaluation_status({:evaluating, cell_id}) do
assigns = %{cell_id: cell_id}
~L"""
<span class="tooltip left" aria-label="Go to evaluating cell">
<button class="icon-button icon-outlined-button border-blue-400 hover:bg-blue-50 focus:bg-blue-50"
data-element="focus-cell-button"
data-target="<%= @cell_id %>">
<%= remix_icon("loader-3-line", class: "text-xl text-blue-500 animate-spin") %>
</button>
</span>
"""
end
defp render_global_evaluation_status({:evaluated, cell_id}) do
assigns = %{cell_id: cell_id}
~L"""
<span class="tooltip left" aria-label="Go to last evaluated cell">
<button class="icon-button icon-outlined-button border-green-300 hover:bg-green-50 focus:bg-green-50"
data-element="focus-cell-button"
data-target="<%= @cell_id %>">
<%= remix_icon("loader-3-line", class: "text-xl text-green-400") %>
</button>
</span>
"""
end
defp render_global_evaluation_status({:stale, cell_id}) do
assigns = %{cell_id: cell_id}
~L"""
<span class="tooltip left" aria-label="Go to first stale cell">
<button class="icon-button icon-outlined-button border-yellow-200 hover:bg-yellow-50 focus:bg-yellow-50"
data-element="focus-cell-button"
data-target="<%= @cell_id %>">
<%= remix_icon("loader-3-line", class: "text-xl text-yellow-300") %>
</button>
</span>
"""
end
defp render_global_evaluation_status({:fresh, nil}) do
assigns = %{}
~L"""
<span class="tooltip left" aria-label="Ready to evaluate">
<button class="icon-button icon-outlined-button border-gray-200 hover:bg-gray-100 focus:bg-gray-100 cursor-default">
<%= remix_icon("loader-3-line", class: "text-xl text-gray-400") %>
</button>
</span>
"""
end
end

View file

@ -6,7 +6,8 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
@impl true
def mount(socket) do
session_summaries = SessionSupervisor.get_session_summaries()
{:ok, assign(socket, session_summaries: session_summaries)}
running_paths = Enum.map(session_summaries, & &1.path)
{:ok, assign(socket, running_paths: running_paths)}
end
@impl true
@ -34,7 +35,7 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
id: "path_select",
path: @path,
extnames: [LiveMarkdown.extension()],
running_paths: paths(@session_summaries),
running_paths: @running_paths,
target: @myself %>
</div>
<% end %>
@ -49,7 +50,7 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
class: "button button-blue",
phx_click: "save",
phx_target: @myself,
disabled: not path_savable?(normalize_path(@path), @session_summaries) or normalize_path(@path) == @current_path %>
disabled: not path_savable?(normalize_path(@path), @running_paths) or normalize_path(@path) == @current_path %>
</div>
</div>
</div>
@ -74,26 +75,28 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
def handle_event("save", %{}, socket) do
path = normalize_path(socket.assigns.path)
Session.set_path(socket.assigns.session_id, path)
{:noreply, socket}
running_paths =
if path do
[path | socket.assigns.running_paths]
else
List.delete(socket.assigns.running_paths, path)
end
{:noreply, assign(socket, running_paths: running_paths)}
end
defp default_path() do
File.cwd!() |> Path.join("notebook")
end
defp paths(session_summaries) do
Enum.map(session_summaries, & &1.path)
end
defp path_savable?(nil, _running_paths), do: true
defp path_savable?(nil, _session_summaries), do: true
defp path_savable?(path, session_summaries) do
defp path_savable?(path, running_paths) do
if File.exists?(path) do
running_paths = paths(session_summaries)
File.regular?(path) and path not in running_paths
else
dir = Path.dirname(path)
File.exists?(dir)
true
end
end

View file

@ -1,10 +0,0 @@
defmodule Livebook.Notebook.HelloLivebookTest do
use ExUnit.Case, async: true
alias Livebook.Notebook
alias Livebook.Notebook.HelloLivebook
test "new/0 correctly builds a new notebook" do
assert %Notebook{} = HelloLivebook.new()
end
end

View file

@ -0,0 +1,10 @@
defmodule Livebook.Notebook.WelcomeTest do
use ExUnit.Case, async: true
alias Livebook.Notebook
alias Livebook.Notebook.Welcome
test "new/0 correctly builds a new notebook" do
assert %Notebook{} = Welcome.new()
end
end

View file

@ -222,6 +222,24 @@ defmodule Livebook.SessionTest do
assert File.exists?(path)
assert File.read!(path) =~ "My notebook"
end
@tag :tmp_dir
test "creates nonexistent directories", %{session_id: session_id, tmp_dir: tmp_dir} do
path = Path.join(tmp_dir, "nonexistent/dir/notebook.livemd")
Session.set_path(session_id, path)
# Perform a change, so the notebook is dirty
Session.set_notebook_name(session_id, "My notebook")
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
refute File.exists?(path)
Session.save(session_id)
assert_receive {:operation, {:mark_as_not_dirty, _}}
assert File.exists?(path)
assert File.read!(path) =~ "My notebook"
end
end
describe "close/1" do

View file

@ -147,7 +147,7 @@ defmodule LivebookWeb.HomeLiveTest do
assert to =~ "/sessions/"
{:ok, view, _} = live(conn, to)
assert render(view) =~ "Hello Livebook"
assert render(view) =~ "Welcome to Livebook"
end
# Helpers