Restructure settings (#233)

* Force menu items into a single line

* Add shortcut for saving the notebook

* Make the disk icon always show file dialog

* Split runtime and file settings into separate modals

* Add ctrl+s to the shortcuts list

* Add togglable menu to the session page

* Make sure newly saved file appears in the file selector

* Fix path seletor force reloading

* Remove notebook generated in tests

* Add test for file list refresh after save
This commit is contained in:
Jonatan Kłosko 2021-04-21 23:02:09 +02:00 committed by GitHub
parent dd904699bc
commit e755ff8122
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 364 additions and 247 deletions

View file

@ -124,3 +124,14 @@
.tabs .tab.active { .tabs .tab.active {
@apply text-blue-600 border-blue-600; @apply text-blue-600 border-blue-600;
} }
/* Toggleable menu */
.menu {
@apply absolute right-0 z-20 rounded-lg bg-white flex flex-col py-2;
box-shadow: 0px 15px 99px rgba(13, 24, 41, 0.15);
}
.menu__item {
@apply flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 whitespace-nowrap;
}

View file

@ -9,7 +9,7 @@ solely client-side operations.
/* === Global === */ /* === Global === */
[data-element="menu"]:not([data-js-shown]) { [data-element="menu"]:not([data-js-open]) > [data-content] {
@apply hidden; @apply hidden;
} }

View file

@ -12,7 +12,3 @@
font-family: "JetBrains Mono"; font-family: "JetBrains Mono";
font-size: 14px; font-size: 14px;
} }
.shadow-center {
box-shadow: 0px 15px 99px rgba(13, 24, 41, 0.15);
}

View file

@ -17,6 +17,7 @@ import Session from "./session";
import FocusOnUpdate from "./focus_on_update"; import FocusOnUpdate from "./focus_on_update";
import ScrollOnUpdate from "./scroll_on_update"; import ScrollOnUpdate from "./scroll_on_update";
import VirtualizedLines from "./virtualized_lines"; import VirtualizedLines from "./virtualized_lines";
import Menu from "./menu";
import morphdomCallbacks from "./morphdom_callbacks"; import morphdomCallbacks from "./morphdom_callbacks";
const hooks = { const hooks = {
@ -26,6 +27,7 @@ const hooks = {
FocusOnUpdate, FocusOnUpdate,
ScrollOnUpdate, ScrollOnUpdate,
VirtualizedLines, VirtualizedLines,
Menu,
}; };
const csrfToken = document const csrfToken = document

44
assets/js/menu/index.js Normal file
View file

@ -0,0 +1,44 @@
/**
* A hook controlling a toggleable menu.
*
* The element should have two children:
*
* * one annotated with `data-toggle` being a clickable element
*
* * one annotated with `data-content` with menu content
*/
const Menu = {
mounted() {
const toggleElement = this.el.querySelector("[data-toggle]");
if (!toggleElement) {
throw new Error("Menu must have a child with data-toggle attribute");
}
const contentElement = this.el.querySelector("[data-content]");
if (!contentElement) {
throw new Error("Menu must have a child with data-content attribute");
}
toggleElement.addEventListener("click", (event) => {
if (this.el.hasAttribute("data-js-open")) {
this.el.removeAttribute("data-js-open");
} else {
this.el.setAttribute("data-js-open", "true");
// Postpone callback registration until the current click finishes bubbling.
setTimeout(() => {
document.addEventListener(
"click",
(event) => {
this.el.removeAttribute("data-js-open");
},
{ once: true }
);
}, 0);
}
});
},
};
export default Menu;

View file

@ -8,37 +8,6 @@ const callbacks = {
} }
} }
}, },
onNodeAdded(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.getAttribute("data-element") === "menu-toggle") {
initializeMenuToggle(node);
}
}
},
}; };
function initializeMenuToggle(element) {
element.addEventListener("click", (event) => {
const menu = element.nextElementSibling;
if (menu.getAttribute("data-element") === "menu") {
if (menu.hasAttribute("data-js-shown")) {
menu.removeAttribute("data-js-shown");
} else {
menu.setAttribute("data-js-shown", "true");
// Postpone callback registration until the current click finishes bubbling.
setTimeout(() => {
document.addEventListener(
"click",
(event) => {
menu.removeAttribute("data-js-shown");
},
{ once: true }
);
}, 0);
}
}
});
}
export default callbacks; export default callbacks;

View file

@ -141,6 +141,9 @@ function handleDocumentKeyDown(hook, event) {
if (hook.state.focusedCellType === "elixir") { if (hook.state.focusedCellType === "elixir") {
queueFocusedCellEvaluation(hook); queueFocusedCellEvaluation(hook);
} }
} else if (cmd && key === "s") {
cancelEvent(event);
saveNotebook(hook);
} }
} else { } else {
// Ignore inputs and notebook/section title fields // Ignore inputs and notebook/section title fields
@ -151,7 +154,10 @@ function handleDocumentKeyDown(hook, event) {
keyBuffer.push(event.key); keyBuffer.push(event.key);
if (keyBuffer.tryMatch(["d", "d"])) { if (cmd && key === "s") {
cancelEvent(event);
saveNotebook(hook);
} else if (keyBuffer.tryMatch(["d", "d"])) {
deleteFocusedCell(hook); deleteFocusedCell(hook);
} else if ( } else if (
hook.state.focusedCellType === "elixir" && hook.state.focusedCellType === "elixir" &&
@ -166,8 +172,6 @@ function handleDocumentKeyDown(hook, event) {
queueChildCellsEvaluation(hook); queueChildCellsEvaluation(hook);
} else if (keyBuffer.tryMatch(["s", "s"])) { } else if (keyBuffer.tryMatch(["s", "s"])) {
toggleSectionsPanel(hook); toggleSectionsPanel(hook);
} else if (keyBuffer.tryMatch(["s", "n"])) {
showNotebookSettings(hook);
} else if (keyBuffer.tryMatch(["s", "r"])) { } else if (keyBuffer.tryMatch(["s", "r"])) {
showNotebookRuntimeSettings(hook); showNotebookRuntimeSettings(hook);
} else if (keyBuffer.tryMatch(["e", "x"])) { } else if (keyBuffer.tryMatch(["e", "x"])) {
@ -314,12 +318,12 @@ function toggleSectionsPanel(hook) {
hook.el.toggleAttribute("data-js-sections-panel-expanded"); hook.el.toggleAttribute("data-js-sections-panel-expanded");
} }
function showNotebookSettings(hook) { function showNotebookRuntimeSettings(hook) {
hook.pushEvent("show_notebook_settings", {}); hook.pushEvent("show_runtime_settings", {});
} }
function showNotebookRuntimeSettings(hook) { function saveNotebook(hook) {
hook.pushEvent("show_notebook_runtime_settings", {}); hook.pushEvent("save", {});
} }
function deleteFocusedCell(hook) { function deleteFocusedCell(hook) {

View file

@ -287,4 +287,12 @@ defmodule Livebook.Notebook do
|> Enum.drop_while(fn {cell, _} -> cell.id != cell_id end) |> Enum.drop_while(fn {cell, _} -> cell.id != cell_id end)
|> Enum.drop(1) |> Enum.drop(1)
end end
@doc """
Returns a forked version of the given notebook.
"""
@spec forked(t()) :: t()
def forked(notebook) do
%{notebook | name: notebook.name <> " - fork"}
end
end end

View file

@ -66,7 +66,7 @@ defmodule Livebook.Notebook.Welcome do
By default notebooks are kept in memory, which is fine for interactive hacking, By default notebooks are kept in memory, which is fine for interactive hacking,
but oftentimes you will want to save your work for later. Fortunately, notebooks but oftentimes you will want to save your work for later. Fortunately, notebooks
can be persisted by clicking on the "Settings" icon in the sidebar can be persisted by clicking on the "Disk" icon in the bottom-right corner
and selecting the file location. and selecting the file location.
Notebooks are stored in **live markdown** format, which is essentially the markdown you know, Notebooks are stored in **live markdown** format, which is essentially the markdown you know,
@ -127,7 +127,7 @@ defmodule Livebook.Notebook.Welcome do
By default, a new Elixir node is started (similarly to starting `iex`), By default, a new Elixir node is started (similarly to starting `iex`),
but you can also choose to run inside a Mix project (as you would with `iex -S mix`) but you can also choose to run inside a Mix project (as you would with `iex -S mix`)
or even manually attach to an existing distributed node! or even manually attach to an existing distributed node!
You can configure the runtime by clicking the "Settings" icon on the sidebar. You can configure the runtime by clicking the "Runtime" icon on the sidebar.
## Using packages ## Using packages

View file

@ -1,6 +1,7 @@
defmodule LivebookWeb.Helpers do defmodule LivebookWeb.Helpers do
import Phoenix.LiveView.Helpers import Phoenix.LiveView.Helpers
import Phoenix.HTML.Tag import Phoenix.HTML.Tag
alias LivebookWeb.Router.Helpers, as: Routes
@doc """ @doc """
Renders a component inside the `Livebook.ModalComponent` component. Renders a component inside the `Livebook.ModalComponent` component.
@ -68,4 +69,12 @@ defmodule LivebookWeb.Helpers do
|> String.split("\n") |> String.split("\n")
|> Enum.map(&Phoenix.HTML.raw/1) |> Enum.map(&Phoenix.HTML.raw/1)
end end
@doc """
Returns path to specific process dialog within LiveDashboard.
"""
def live_dashboard_process_path(socket, pid) do
pid_str = Phoenix.LiveDashboard.Helpers.encode_pid(pid)
Routes.live_dashboard_path(socket, :page, node(), "processes", info: pid_str)
end
end end

View file

@ -1,7 +1,7 @@
defmodule LivebookWeb.HomeLive do defmodule LivebookWeb.HomeLive do
use LivebookWeb, :live_view use LivebookWeb, :live_view
alias Livebook.{SessionSupervisor, Session, LiveMarkdown} alias Livebook.{SessionSupervisor, Session, LiveMarkdown, Notebook}
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -128,7 +128,7 @@ defmodule LivebookWeb.HomeLive do
def handle_event("fork", %{}, socket) do def handle_event("fork", %{}, socket) do
{notebook, messages} = import_notebook(socket.assigns.path) {notebook, messages} = import_notebook(socket.assigns.path)
socket = put_import_flash_messages(socket, messages) socket = put_import_flash_messages(socket, messages)
notebook = %{notebook | name: notebook.name <> " - fork"} notebook = Notebook.forked(notebook)
images_dir = Session.images_dir_for_notebook(socket.assigns.path) images_dir = Session.images_dir_for_notebook(socket.assigns.path)
create_session(socket, notebook: notebook, copy_images_from: images_dir) create_session(socket, notebook: notebook, copy_images_from: images_dir)
end end
@ -141,7 +141,7 @@ defmodule LivebookWeb.HomeLive do
def handle_event("fork_session", %{"id" => session_id}, socket) do def handle_event("fork_session", %{"id" => session_id}, socket) do
data = Session.get_data(session_id) data = Session.get_data(session_id)
notebook = %{data.notebook | name: data.notebook.name <> " - fork"} notebook = Notebook.forked(data.notebook)
%{images_dir: images_dir} = Session.get_summary(session_id) %{images_dir: images_dir} = Session.get_summary(session_id)
create_session(socket, notebook: notebook, copy_images_from: images_dir) create_session(socket, notebook: notebook, copy_images_from: images_dir)
end end

View file

@ -15,25 +15,25 @@ defmodule LivebookWeb.SessionLive.SessionsComponent do
<%= summary.path || "No file" %> <%= summary.path || "No file" %>
</div> </div>
</div> </div>
<div class="relative"> <div class="relative" id="session-<%= summary.session_id %>-menu" phx-hook="Menu" data-element="menu">
<button class="icon-button" data-element="menu-toggle"> <button class="icon-button" data-toggle>
<%= remix_icon("more-2-fill", class: "text-xl") %> <%= remix_icon("more-2-fill", class: "text-xl") %>
</button> </button>
<div class="absolute right-0 z-20 rounded-lg shadow-center bg-white flex flex-col py-2" data-element="menu"> <div class="menu" data-content>
<button class="flex space-x-3 px-5 py-2 items-center text-gray-500 hover:bg-gray-50" <button class="menu__item text-gray-500"
phx-click="fork_session" phx-click="fork_session"
phx-value-id="<%= summary.session_id %>"> phx-value-id="<%= summary.session_id %>">
<%= remix_icon("git-branch-line") %> <%= remix_icon("git-branch-line") %>
<span class="font-medium">Fork</span> <span class="font-medium">Fork</span>
</button> </button>
<%= link to: Routes.live_dashboard_path(@socket, :page, node(), "processes", info: Phoenix.LiveDashboard.Helpers.encode_pid(summary.pid)), <%= link to: live_dashboard_process_path(@socket, summary.pid),
class: "flex space-x-3 px-5 py-2 items-center text-gray-600 hover:bg-gray-50", class: "menu__item text-gray-500",
target: "_blank" do %> target: "_blank" do %>
<%= remix_icon("dashboard-2-line") %> <%= remix_icon("dashboard-2-line") %>
<span class="font-medium">See on Dashboard</span> <span class="font-medium">See on Dashboard</span>
<% end %> <% end %>
<%= live_patch to: Routes.home_path(@socket, :close_session, summary.session_id), <%= live_patch to: Routes.home_path(@socket, :close_session, summary.session_id),
class: "flex space-x-3 px-5 py-2 items-center text-red-600 hover:bg-gray-50" do %> class: "menu__item text-red-600" do %>
<%= remix_icon("close-circle-line") %> <%= remix_icon("close-circle-line") %>
<span class="font-medium">Close</span> <span class="font-medium">Close</span>
<% end %> <% end %>

View file

@ -13,6 +13,9 @@ defmodule LivebookWeb.PathSelectComponent do
# #
# Optionally inner block may be passed (e.g. with action buttons) # Optionally inner block may be passed (e.g. with action buttons)
# and it's rendered next to the text input. # and it's rendered next to the text input.
#
# To force the component refetch the displayed files
# you can `send_update` with `force_reload: true` to the component.
@impl true @impl true
def mount(socket) do def mount(socket) do
@ -22,12 +25,14 @@ defmodule LivebookWeb.PathSelectComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
{force_reload?, assigns} = Map.pop(assigns, :force_reload, false)
%{assigns: assigns} = socket = assign(socket, assigns) %{assigns: assigns} = socket = assign(socket, assigns)
{dir, basename} = split_path(assigns.path) {dir, basename} = split_path(assigns.path)
dir = Path.expand(dir) dir = Path.expand(dir)
files = files =
if assigns.current_dir != dir do if assigns.current_dir != dir or force_reload? do
list_files(dir, assigns.extnames, assigns.running_paths) list_files(dir, assigns.extnames, assigns.running_paths)
else else
assigns.files assigns.files

View file

@ -16,11 +16,18 @@ defmodule LivebookWeb.SessionLive do
Session.get_data(session_id) Session.get_data(session_id)
end end
session_pid = Session.get_pid(session_id)
platform = platform_from_socket(socket) platform = platform_from_socket(socket)
{:ok, {:ok,
socket socket
|> assign(platform: platform, session_id: session_id, data_view: data_to_view(data)) |> assign(
platform: platform,
session_id: session_id,
session_pid: session_pid,
data_view: data_to_view(data)
)
|> assign_private(data: data) |> assign_private(data: data)
|> allow_upload(:cell_image, |> allow_upload(:cell_image,
accept: ~w(.jpg .jpeg .png .gif), accept: ~w(.jpg .jpeg .png .gif),
@ -65,10 +72,10 @@ defmodule LivebookWeb.SessionLive do
<%= remix_icon("booklet-fill") %> <%= remix_icon("booklet-fill") %>
</button> </button>
</span> </span>
<span class="tooltip right distant" aria-label="Notebook settings (sn)"> <span class="tooltip right distant" aria-label="Runtime settings (sr)">
<%= live_patch to: Routes.session_path(@socket, :settings, @session_id, "file"), <%= live_patch to: Routes.session_path(@socket, :runtime_settings, @session_id),
class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center #{if(@live_action == :settings, do: "text-gray-50 bg-gray-700")}" do %> class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center #{if(@live_action == :runtime_settings, do: "text-gray-50 bg-gray-700")}" do %>
<%= remix_icon("settings-4-fill", class: "text-2xl") %> <%= remix_icon("cpu-line", class: "text-2xl") %>
<% end %> <% end %>
</span> </span>
<div class="flex-grow"></div> <div class="flex-grow"></div>
@ -103,8 +110,8 @@ defmodule LivebookWeb.SessionLive do
</div> </div>
<div class="flex-grow overflow-y-auto" data-element="notebook"> <div class="flex-grow overflow-y-auto" data-element="notebook">
<div class="py-7 px-16 max-w-screen-lg w-full mx-auto"> <div class="py-7 px-16 max-w-screen-lg w-full mx-auto">
<div class="pb-4 mb-6 border-b border-gray-200"> <div class="flex space-x-4 items-center pb-4 mb-6 border-b border-gray-200">
<h1 class="text-gray-800 font-semibold text-3xl p-1 -ml-1 rounded-lg border border-transparent hover:border-blue-200 focus:border-blue-300" <h1 class="flex-grow text-gray-800 font-semibold text-3xl p-1 -ml-1 rounded-lg border border-transparent hover:border-blue-200 focus:border-blue-300"
id="notebook-name" id="notebook-name"
data-element="notebook-name" data-element="notebook-name"
contenteditable contenteditable
@ -112,6 +119,29 @@ defmodule LivebookWeb.SessionLive do
phx-blur="set_notebook_name" phx-blur="set_notebook_name"
phx-hook="ContentEditable" phx-hook="ContentEditable"
data-update-attribute="phx-value-name"><%= @data_view.notebook_name %></h1> data-update-attribute="phx-value-name"><%= @data_view.notebook_name %></h1>
<div class="relative" id="session-menu" phx-hook="Menu" data-element="menu">
<button class="icon-button" data-toggle>
<%= remix_icon("more-2-fill", class: "text-xl") %>
</button>
<div class="menu" data-content>
<button class="menu__item text-gray-500"
phx-click="fork_session">
<%= remix_icon("git-branch-line") %>
<span class="font-medium">Fork</span>
</button>
<%= link to: live_dashboard_process_path(@socket, @session_pid),
class: "menu__item text-gray-500",
target: "_blank" do %>
<%= remix_icon("dashboard-2-line") %>
<span class="font-medium">See on Dashboard</span>
<% end %>
<%= live_patch to: Routes.home_path(@socket, :close_session, @session_id),
class: "menu__item text-red-600" do %>
<%= remix_icon("close-circle-line") %>
<span class="font-medium">Close</span>
<% end %>
</div>
</div>
</div> </div>
<div class="flex flex-col w-full space-y-16"> <div class="flex flex-col w-full space-y-16">
<%= if @data_view.section_views == [] do %> <%= if @data_view.section_views == [] do %>
@ -140,13 +170,21 @@ defmodule LivebookWeb.SessionLive do
</div> </div>
</div> </div>
<%= if @live_action == :settings do %> <%= if @live_action == :runtime_settings do %>
<%= live_modal @socket, LivebookWeb.SessionLive.SettingsComponent, <%= live_modal @socket, LivebookWeb.SessionLive.RuntimeComponent,
id: :settings_modal, id: :runtime_settings_modal,
return_to: Routes.session_path(@socket, :page, @session_id), return_to: Routes.session_path(@socket, :page, @session_id),
tab: @tab,
session_id: @session_id, session_id: @session_id,
data_view: @data_view %> runtime: @data_view.runtime %>
<% end %>
<%= if @live_action == :file_settings do %>
<%= live_modal @socket, LivebookWeb.SessionLive.PersistenceComponent,
id: :runtime_settings_modal,
return_to: Routes.session_path(@socket, :page, @session_id),
session_id: @session_id,
current_path: @data_view.path,
path: @data_view.path %>
<% end %> <% end %>
<%= if @live_action == :shortcuts do %> <%= if @live_action == :shortcuts do %>
@ -181,10 +219,6 @@ defmodule LivebookWeb.SessionLive do
{:noreply, assign(socket, cell: cell)} {:noreply, assign(socket, cell: cell)}
end end
def handle_params(%{"tab" => tab}, _url, socket) do
{:noreply, assign(socket, tab: tab)}
end
def handle_params(_params, _url, socket) do def handle_params(_params, _url, socket) do
{:noreply, socket} {:noreply, socket}
end end
@ -352,9 +386,15 @@ defmodule LivebookWeb.SessionLive do
end end
def handle_event("save", %{}, socket) do def handle_event("save", %{}, socket) do
Session.save(socket.assigns.session_id) if socket.private.data.path do
Session.save(socket.assigns.session_id)
{:noreply, socket} {:noreply, socket}
else
{:noreply,
push_patch(socket,
to: Routes.session_path(socket, :file_settings, socket.assigns.session_id)
)}
end
end end
def handle_event("show_shortcuts", %{}, socket) do def handle_event("show_shortcuts", %{}, socket) do
@ -362,17 +402,10 @@ defmodule LivebookWeb.SessionLive do
push_patch(socket, to: Routes.session_path(socket, :shortcuts, socket.assigns.session_id))} push_patch(socket, to: Routes.session_path(socket, :shortcuts, socket.assigns.session_id))}
end end
def handle_event("show_notebook_settings", %{}, socket) do def handle_event("show_runtime_settings", %{}, socket) do
{:noreply, {:noreply,
push_patch(socket, push_patch(socket,
to: Routes.session_path(socket, :settings, socket.assigns.session_id, "file") to: Routes.session_path(socket, :runtime_settings, socket.assigns.session_id)
)}
end
def handle_event("show_notebook_runtime_settings", %{}, socket) do
{:noreply,
push_patch(socket,
to: Routes.session_path(socket, :settings, socket.assigns.session_id, "runtime")
)} )}
end end
@ -399,6 +432,22 @@ defmodule LivebookWeb.SessionLive do
end end
end end
def handle_event("fork_session", %{}, socket) do
notebook = Notebook.forked(socket.private.data.notebook)
%{images_dir: images_dir} = Session.get_summary(socket.assigns.session_id)
create_session(socket, notebook: notebook, copy_images_from: images_dir)
end
defp create_session(socket, opts) do
case SessionSupervisor.create_session(opts) do
{:ok, id} ->
{:noreply, push_redirect(socket, to: Routes.session_path(socket, :page, id))}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to create a notebook: #{reason}")}
end
end
@impl true @impl true
def handle_info({:operation, operation}, socket) do def handle_info({:operation, operation}, socket) do
case Session.Data.apply_operation(socket.private.data, operation) do case Session.Data.apply_operation(socket.private.data, operation) do

View file

@ -8,21 +8,22 @@ defmodule LivebookWeb.SessionLive.IndicatorsComponent do
<%= if @data_view.path do %> <%= if @data_view.path do %>
<%= if @data_view.dirty do %> <%= if @data_view.dirty do %>
<span class="tooltip left" aria-label="Autosave pending"> <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" <%= live_patch to: Routes.session_path(@socket, :file_settings, @session_id),
phx-click="save"> class: "icon-button icon-outlined-button border-blue-400 hover:bg-blue-50 focus:bg-blue-50" do %>
<%= remix_icon("save-line", class: "text-xl text-blue-500") %> <%= remix_icon("save-line", class: "text-xl text-blue-500") %>
</button> <% end %>
</span> </span>
<% else %> <% else %>
<span class="tooltip left" aria-label="Notebook saved"> <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"> <%= live_patch to: Routes.session_path(@socket, :file_settings, @session_id),
class: "icon-button icon-outlined-button border-green-300 hover:bg-green-50 focus:bg-green-50" do %>
<%= remix_icon("save-line", class: "text-xl text-green-400") %> <%= remix_icon("save-line", class: "text-xl text-green-400") %>
</button> <% end %>
</span> </span>
<% end %> <% end %>
<% else %> <% else %>
<span class="tooltip left" aria-label="Choose a file to save the notebook"> <span class="tooltip left" aria-label="Choose a file to save the notebook">
<%= live_patch to: Routes.session_path(@socket, :settings, @session_id, "file"), <%= live_patch to: Routes.session_path(@socket, :file_settings, @session_id),
class: "icon-button icon-outlined-button border-gray-200 hover:bg-gray-100 focus:bg-gray-100" do %> 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") %> <%= remix_icon("save-line", class: "text-xl text-gray-400") %>
<% end %> <% end %>
@ -33,7 +34,7 @@ defmodule LivebookWeb.SessionLive.IndicatorsComponent do
<%= render_global_evaluation_status(@data_view.global_evaluation_status) %> <%= render_global_evaluation_status(@data_view.global_evaluation_status) %>
<% else %> <% else %>
<span class="tooltip left" aria-label="Choose a runtime to run the notebook in"> <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"), <%= live_patch to: Routes.session_path(@socket, :runtime_settings, @session_id),
class: "icon-button icon-outlined-button border-gray-200 hover:bg-gray-100 focus:bg-gray-100" do %> 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") %> <%= remix_icon("loader-3-line", class: "text-xl text-gray-400") %>
<% end %> <% end %>

View file

@ -13,45 +13,50 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~L""" ~L"""
<div class="w-full flex-col space-y-5"> <div class="p-6 pb-4 max-w-4xl w-screen flex flex-col space-y-3">
<p class="text-gray-700"> <h3 class="text-2xl font-semibold text-gray-800">
Specify where the notebook should be automatically persisted. File
</p> </h3>
<div class="flex space-x-4"> <div class="w-full flex-col space-y-5">
<%= content_tag :button, "Save to file", <p class="text-gray-700">
class: "choice-button #{if(@path != nil, do: "active")}", Specify where the notebook should be automatically persisted.
phx_click: "set_persistence_type", </p>
phx_value_type: "file", <div class="flex space-x-4">
phx_target: @myself %> <%= content_tag :button, "Save to file",
<%= content_tag :button, "Memory only", class: "choice-button #{if(@path != nil, do: "active")}",
class: "choice-button #{if(@path == nil, do: "active")}", phx_click: "set_persistence_type",
phx_click: "set_persistence_type", phx_value_type: "file",
phx_value_type: "memory", phx_target: @myself %>
phx_target: @myself %> <%= content_tag :button, "Memory only",
</div> class: "choice-button #{if(@path == nil, do: "active")}",
<%= if @path != nil do %> phx_click: "set_persistence_type",
<div class="h-full h-52"> phx_value_type: "memory",
<%= live_component @socket, LivebookWeb.PathSelectComponent, phx_target: @myself %>
id: "path_select",
path: @path,
extnames: [LiveMarkdown.extension()],
running_paths: @running_paths,
phx_target: @myself,
phx_submit: if(disabled?(@path, @current_path, @running_paths), do: nil, else: "save") %>
</div> </div>
<% end %>
<div class="flex flex-col space-y-2">
<%= if @path != nil do %> <%= if @path != nil do %>
<div class="text-gray-500 text-sm"> <div class="h-full h-52">
File: <%= normalize_path(@path) %> <%= live_component @socket, LivebookWeb.PathSelectComponent,
id: "path_select",
path: @path,
extnames: [LiveMarkdown.extension()],
running_paths: @running_paths,
phx_target: @myself,
phx_submit: if(disabled?(@path, @current_path, @running_paths), do: nil, else: "save") %>
</div> </div>
<% end %> <% end %>
<div> <div class="flex flex-col space-y-2">
<%= content_tag :button, "Save", <%= if @path != nil do %>
class: "button button-blue mt-2", <div class="text-gray-500 text-sm">
phx_click: "save", File: <%= normalize_path(@path) %>
phx_target: @myself, </div>
disabled: disabled?(@path, @current_path, @running_paths) %> <% end %>
<div>
<%= content_tag :button, "Save",
class: "button button-blue mt-2",
phx_click: "save",
phx_target: @myself,
disabled: disabled?(@path, @current_path, @running_paths) %>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -85,6 +90,14 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
List.delete(socket.assigns.running_paths, path) List.delete(socket.assigns.running_paths, path)
end end
# After saving the file reload the directory contents,
# so that the new file gets shown.
send_update(LivebookWeb.PathSelectComponent,
id: "path_select",
running_paths: running_paths,
force_reload: true
)
{:noreply, assign(socket, running_paths: running_paths)} {:noreply, assign(socket, running_paths: running_paths)}
end end

View file

@ -30,74 +30,79 @@ defmodule LivebookWeb.SessionLive.RuntimeComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~L""" ~L"""
<div class="w-full flex-col space-y-5"> <div class="p-6 pb-4 max-w-4xl w-screen flex flex-col space-y-3">
<p class="text-gray-700"> <h3 class="text-2xl font-semibold text-gray-800">
The code is evaluated in a separate Elixir runtime (node), Runtime
which you can configure yourself here. </h3>
</p> <div class="w-full flex-col space-y-5">
<div class="flex items-center justify-between border border-gray-200 rounded-lg p-4"> <p class="text-gray-700">
<%= if @runtime do %> The code is evaluated in a separate Elixir runtime (node),
<div class="flex flex-col space-y-1"> which you can configure yourself here.
<span class="text-xs text-gray-500"> </p>
Type <div class="flex items-center justify-between border border-gray-200 rounded-lg p-4">
</span> <%= if @runtime do %>
<span class="text-gray-800 text-sm font-semibold"> <div class="flex flex-col space-y-1">
<%= runtime_type_label(@runtime) %> <span class="text-xs text-gray-500">
</span> Type
</div> </span>
<div class="flex flex-col space-y-1"> <span class="text-gray-800 text-sm font-semibold">
<span class="text-xs text-gray-500"> <%= runtime_type_label(@runtime) %>
Node name </span>
</span> </div>
<span class="text-gray-800 text-sm font-semibold"> <div class="flex flex-col space-y-1">
<%= @runtime.node %> <span class="text-xs text-gray-500">
</span> Node name
</div> </span>
<button class="button button-outlined-red" <span class="text-gray-800 text-sm font-semibold">
type="button" <%= @runtime.node %>
phx-click="disconnect" </span>
phx-target="<%= @myself %>"> </div>
Disconnect <button class="button button-outlined-red"
</button> type="button"
<% else %> phx-click="disconnect"
<p class="text-sm text-gray-700"> phx-target="<%= @myself %>">
No connected node Disconnect
</p> </button>
<% end %> <% else %>
</div> <p class="text-sm text-gray-700">
<div class="flex space-x-4"> No connected node
<%= content_tag :button, "Elixir standalone", </p>
class: "choice-button #{if(@type == "elixir_standalone", do: "active")}", <% end %>
phx_click: "set_runtime_type", </div>
phx_value_type: "elixir_standalone", <div class="flex space-x-4">
phx_target: @myself %> <%= content_tag :button, "Elixir standalone",
<%= content_tag :button, "Mix standalone", class: "choice-button #{if(@type == "elixir_standalone", do: "active")}",
class: "choice-button #{if(@type == "mix_standalone", do: "active")}", phx_click: "set_runtime_type",
phx_click: "set_runtime_type", phx_value_type: "elixir_standalone",
phx_value_type: "mix_standalone", phx_target: @myself %>
phx_target: @myself %> <%= content_tag :button, "Mix standalone",
<%= content_tag :button, "Attached node", class: "choice-button #{if(@type == "mix_standalone", do: "active")}",
class: "choice-button #{if(@type == "attached", do: "active")}", phx_click: "set_runtime_type",
phx_click: "set_runtime_type", phx_value_type: "mix_standalone",
phx_value_type: "attached", phx_target: @myself %>
phx_target: @myself %> <%= content_tag :button, "Attached node",
</div> class: "choice-button #{if(@type == "attached", do: "active")}",
<div> phx_click: "set_runtime_type",
<%= if @type == "elixir_standalone" do %> phx_value_type: "attached",
<%= live_render @socket, LivebookWeb.SessionLive.ElixirStandaloneLive, phx_target: @myself %>
id: :elixir_standalone_runtime, </div>
session: %{"session_id" => @session_id, "current_runtime" => @runtime} %> <div>
<% end %> <%= if @type == "elixir_standalone" do %>
<%= if @type == "mix_standalone" do %> <%= live_render @socket, LivebookWeb.SessionLive.ElixirStandaloneLive,
<%= live_render @socket, LivebookWeb.SessionLive.MixStandaloneLive, id: :elixir_standalone_runtime,
id: :mix_standalone_runtime, session: %{"session_id" => @session_id, "current_runtime" => @runtime} %>
session: %{"session_id" => @session_id, "current_runtime" => @runtime} %> <% end %>
<% end %> <%= if @type == "mix_standalone" do %>
<%= if @type == "attached" do %> <%= live_render @socket, LivebookWeb.SessionLive.MixStandaloneLive,
<%= live_render @socket, LivebookWeb.SessionLive.AttachedLive, id: :mix_standalone_runtime,
id: :attached_runtime, session: %{"session_id" => @session_id, "current_runtime" => @runtime} %>
session: %{"session_id" => @session_id, "current_runtime" => @runtime} %> <% end %>
<% end %> <%= if @type == "attached" do %>
<%= live_render @socket, LivebookWeb.SessionLive.AttachedLive,
id: :attached_runtime,
session: %{"session_id" => @session_id, "current_runtime" => @runtime} %>
<% end %>
</div>
</div> </div>
</div> </div>
""" """

View file

@ -1,47 +0,0 @@
defmodule LivebookWeb.SessionLive.SettingsComponent do
use LivebookWeb, :live_component
@impl true
def render(assigns) do
~L"""
<div class="p-6 pb-4 max-w-4xl w-screen flex flex-col space-y-3">
<h3 class="text-2xl font-semibold text-gray-800">
Notebook settings
</h3>
<div class="tabs">
<%= live_patch to: Routes.session_path(@socket, :settings, @session_id, "file"),
class: "tab #{if(@tab == "file", do: "active")}" do %>
<%= remix_icon("file-settings-line", class: "align-middle") %>
<span class="font-medium">
File
</span>
<% end %>
<%= live_patch to: Routes.session_path(@socket, :settings, @session_id, "runtime"),
class: "tab #{if(@tab == "runtime", do: "active")}" do %>
<%= remix_icon("play-circle-line", class: "align-middle") %>
<span class="font-medium">
Runtime
</span>
<% end %>
<div class="flex-grow tab">
</div>
</div>
<div class="pt-2">
<%= if @tab == "file" do %>
<%= live_component @socket, LivebookWeb.SessionLive.PersistenceComponent,
id: :persistence,
session_id: @session_id,
current_path: @data_view.path,
path: @data_view.path %>
<% end %>
<%= if @tab == "runtime" do %>
<%= live_component @socket, LivebookWeb.SessionLive.RuntimeComponent,
id: :runtime,
session_id: @session_id,
runtime: @data_view.runtime %>
<% end %>
</div>
</div>
"""
end
end

View file

@ -27,8 +27,10 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
%{seq: ["ej"], desc: "Evaluate cells below"}, %{seq: ["ej"], desc: "Evaluate cells below"},
%{seq: ["ex"], desc: "Cancel cell evaluation"}, %{seq: ["ex"], desc: "Cancel cell evaluation"},
%{seq: ["ss"], desc: "Toggle sections panel"}, %{seq: ["ss"], desc: "Toggle sections panel"},
%{seq: ["sn"], desc: "Show notebook settings"}, %{seq: ["sr"], desc: "Show runtime settings"}
%{seq: ["sr"], desc: "Show notebook runtime settings"} ],
universal: [
%{seq: ["ctrl", "s"], desc: "Save notebook"}
] ]
} }
@ -53,6 +55,7 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do
</p> </p>
<%= render_shortcuts_section("Navigation mode", @shortcuts.navigation_mode, @platform) %> <%= render_shortcuts_section("Navigation mode", @shortcuts.navigation_mode, @platform) %>
<%= render_shortcuts_section("Insert mode", @shortcuts.insert_mode, @platform) %> <%= render_shortcuts_section("Insert mode", @shortcuts.insert_mode, @platform) %>
<%= render_shortcuts_section("Universal", @shortcuts.universal, @platform) %>
</div> </div>
""" """
end end

View file

@ -22,7 +22,8 @@ defmodule LivebookWeb.Router do
live "/home/sessions/:session_id/close", HomeLive, :close_session live "/home/sessions/:session_id/close", HomeLive, :close_session
live "/sessions/:id", SessionLive, :page live "/sessions/:id", SessionLive, :page
live "/sessions/:id/shortcuts", SessionLive, :shortcuts live "/sessions/:id/shortcuts", SessionLive, :shortcuts
live "/sessions/:id/settings/:tab", SessionLive, :settings live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
live "/sessions/:id/settings/file", SessionLive, :file_settings
live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings live "/sessions/:id/cell-settings/:cell_id", SessionLive, :cell_settings
live "/sessions/:id/cell-upload/:cell_id", SessionLive, :cell_upload live "/sessions/:id/cell-upload/:cell_id", SessionLive, :cell_upload
get "/sessions/:id/images/:image", SessionController, :show_image get "/sessions/:id/images/:image", SessionController, :show_image

View file

@ -23,9 +23,10 @@ defmodule LivebookWeb.PathSelectComponentTest do
end end
test "relative paths are expanded from the current working directory" do test "relative paths are expanded from the current working directory" do
File.cd!(notebooks_path()) File.cd!(notebooks_path(), fn ->
path = "" path = ""
assert render_component(PathSelectComponent, attrs(path: path)) =~ "basic.livemd" assert render_component(PathSelectComponent, attrs(path: path)) =~ "basic.livemd"
end)
end end
defp attrs(attrs) do defp attrs(attrs) do

View file

@ -188,6 +188,32 @@ defmodule LivebookWeb.SessionLiveTest do
end end
end end
@tag :tmp_dir
describe "persistence settings" do
test "saving to file shows the newly created file",
%{conn: conn, session_id: session_id, tmp_dir: tmp_dir} do
{:ok, view, _} = live(conn, "/sessions/#{session_id}/settings/file")
path = Path.join(tmp_dir, "notebook.livemd")
view
|> element("button", "Save to file")
|> render_click()
view
|> element("form")
|> render_change(%{path: path})
view
|> element(~s{button[phx-click="save"]}, "Save")
|> render_click()
assert view
|> element("button", "notebook.livemd")
|> has_element?()
end
end
describe "completion" do describe "completion" do
test "replies with nil completion reference when no runtime is started", test "replies with nil completion reference when no runtime is started",
%{conn: conn, session_id: session_id} do %{conn: conn, session_id: session_id} do
@ -227,6 +253,23 @@ defmodule LivebookWeb.SessionLiveTest do
end end
end end
test "forking the session", %{conn: conn, session_id: session_id} do
Session.set_notebook_name(session_id, "My notebook")
wait_for_session_update(session_id)
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
assert {:error, {:live_redirect, %{to: to}}} =
view
|> element("button", "Fork")
|> render_click()
assert to =~ "/sessions/"
{:ok, view, _} = live(conn, to)
assert render(view) =~ "My notebook - fork"
end
# Helpers # Helpers
defp wait_for_session_update(session_id) do defp wait_for_session_update(session_id) do