mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-24 20:36:26 +08:00
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:
parent
4e90666350
commit
5efd8eb851
14 changed files with 237 additions and 46 deletions
|
|
@ -64,6 +64,10 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
.icon-outlined-button {
|
||||
@apply rounded-full border-2;
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
|
||||
.input {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
106
lib/livebook_web/live/session_live/indicators_component.ex
Normal file
106
lib/livebook_web/live/session_live/indicators_component.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
10
test/livebook/notebook/welcome_test.exs
Normal file
10
test/livebook/notebook/welcome_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue