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 {
@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 === */
[data-element="menu"]:not([data-js-shown]) {
[data-element="menu"]:not([data-js-open]) > [data-content] {
@apply hidden;
}

View file

@ -12,7 +12,3 @@
font-family: "JetBrains Mono";
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 ScrollOnUpdate from "./scroll_on_update";
import VirtualizedLines from "./virtualized_lines";
import Menu from "./menu";
import morphdomCallbacks from "./morphdom_callbacks";
const hooks = {
@ -26,6 +27,7 @@ const hooks = {
FocusOnUpdate,
ScrollOnUpdate,
VirtualizedLines,
Menu,
};
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;

View file

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

View file

@ -66,7 +66,7 @@ defmodule Livebook.Notebook.Welcome do
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
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.
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`),
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!
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

View file

@ -1,6 +1,7 @@
defmodule LivebookWeb.Helpers do
import Phoenix.LiveView.Helpers
import Phoenix.HTML.Tag
alias LivebookWeb.Router.Helpers, as: Routes
@doc """
Renders a component inside the `Livebook.ModalComponent` component.
@ -68,4 +69,12 @@ defmodule LivebookWeb.Helpers do
|> String.split("\n")
|> Enum.map(&Phoenix.HTML.raw/1)
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

View file

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

View file

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

View file

@ -13,6 +13,9 @@ defmodule LivebookWeb.PathSelectComponent do
#
# Optionally inner block may be passed (e.g. with action buttons)
# 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
def mount(socket) do
@ -22,12 +25,14 @@ defmodule LivebookWeb.PathSelectComponent do
@impl true
def update(assigns, socket) do
{force_reload?, assigns} = Map.pop(assigns, :force_reload, false)
%{assigns: assigns} = socket = assign(socket, assigns)
{dir, basename} = split_path(assigns.path)
dir = Path.expand(dir)
files =
if assigns.current_dir != dir do
if assigns.current_dir != dir or force_reload? do
list_files(dir, assigns.extnames, assigns.running_paths)
else
assigns.files

View file

@ -16,11 +16,18 @@ defmodule LivebookWeb.SessionLive do
Session.get_data(session_id)
end
session_pid = Session.get_pid(session_id)
platform = platform_from_socket(socket)
{:ok,
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)
|> allow_upload(:cell_image,
accept: ~w(.jpg .jpeg .png .gif),
@ -65,10 +72,10 @@ defmodule LivebookWeb.SessionLive do
<%= 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-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 %>
<%= remix_icon("settings-4-fill", class: "text-2xl") %>
<span class="tooltip right distant" aria-label="Runtime settings (sr)">
<%= 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 == :runtime_settings, do: "text-gray-50 bg-gray-700")}" do %>
<%= remix_icon("cpu-line", class: "text-2xl") %>
<% end %>
</span>
<div class="flex-grow"></div>
@ -103,8 +110,8 @@ defmodule LivebookWeb.SessionLive do
</div>
<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="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"
<div class="flex space-x-4 items-center pb-4 mb-6 border-b border-gray-200">
<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"
data-element="notebook-name"
contenteditable
@ -112,6 +119,29 @@ defmodule LivebookWeb.SessionLive do
phx-blur="set_notebook_name"
phx-hook="ContentEditable"
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 class="flex flex-col w-full space-y-16">
<%= if @data_view.section_views == [] do %>
@ -140,13 +170,21 @@ defmodule LivebookWeb.SessionLive do
</div>
</div>
<%= if @live_action == :settings do %>
<%= live_modal @socket, LivebookWeb.SessionLive.SettingsComponent,
id: :settings_modal,
<%= if @live_action == :runtime_settings do %>
<%= live_modal @socket, LivebookWeb.SessionLive.RuntimeComponent,
id: :runtime_settings_modal,
return_to: Routes.session_path(@socket, :page, @session_id),
tab: @tab,
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 %>
<%= if @live_action == :shortcuts do %>
@ -181,10 +219,6 @@ defmodule LivebookWeb.SessionLive do
{:noreply, assign(socket, cell: cell)}
end
def handle_params(%{"tab" => tab}, _url, socket) do
{:noreply, assign(socket, tab: tab)}
end
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
@ -352,9 +386,15 @@ defmodule LivebookWeb.SessionLive do
end
def handle_event("save", %{}, socket) do
Session.save(socket.assigns.session_id)
{:noreply, socket}
if socket.private.data.path do
Session.save(socket.assigns.session_id)
{:noreply, socket}
else
{:noreply,
push_patch(socket,
to: Routes.session_path(socket, :file_settings, socket.assigns.session_id)
)}
end
end
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))}
end
def handle_event("show_notebook_settings", %{}, socket) do
def handle_event("show_runtime_settings", %{}, socket) do
{:noreply,
push_patch(socket,
to: Routes.session_path(socket, :settings, socket.assigns.session_id, "file")
)}
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")
to: Routes.session_path(socket, :runtime_settings, socket.assigns.session_id)
)}
end
@ -399,6 +432,22 @@ defmodule LivebookWeb.SessionLive do
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
def handle_info({:operation, operation}, socket) 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.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">
<%= live_patch to: Routes.session_path(@socket, :file_settings, @session_id),
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") %>
</button>
<% end %>
</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">
<%= 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") %>
</button>
<% end %>
</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"),
<%= 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 %>
<%= remix_icon("save-line", class: "text-xl text-gray-400") %>
<% end %>
@ -33,7 +34,7 @@ defmodule LivebookWeb.SessionLive.IndicatorsComponent 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"),
<%= 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 %>
<%= remix_icon("loader-3-line", class: "text-xl text-gray-400") %>
<% end %>

View file

@ -13,45 +13,50 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
@impl true
def render(assigns) do
~L"""
<div class="w-full flex-col space-y-5">
<p class="text-gray-700">
Specify where the notebook should be automatically persisted.
</p>
<div class="flex space-x-4">
<%= content_tag :button, "Save to file",
class: "choice-button #{if(@path != nil, do: "active")}",
phx_click: "set_persistence_type",
phx_value_type: "file",
phx_target: @myself %>
<%= content_tag :button, "Memory only",
class: "choice-button #{if(@path == nil, do: "active")}",
phx_click: "set_persistence_type",
phx_value_type: "memory",
phx_target: @myself %>
</div>
<%= if @path != nil do %>
<div class="h-full h-52">
<%= 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 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">
File
</h3>
<div class="w-full flex-col space-y-5">
<p class="text-gray-700">
Specify where the notebook should be automatically persisted.
</p>
<div class="flex space-x-4">
<%= content_tag :button, "Save to file",
class: "choice-button #{if(@path != nil, do: "active")}",
phx_click: "set_persistence_type",
phx_value_type: "file",
phx_target: @myself %>
<%= content_tag :button, "Memory only",
class: "choice-button #{if(@path == nil, do: "active")}",
phx_click: "set_persistence_type",
phx_value_type: "memory",
phx_target: @myself %>
</div>
<% end %>
<div class="flex flex-col space-y-2">
<%= if @path != nil do %>
<div class="text-gray-500 text-sm">
File: <%= normalize_path(@path) %>
<div class="h-full h-52">
<%= 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>
<% 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 class="flex flex-col space-y-2">
<%= if @path != nil do %>
<div class="text-gray-500 text-sm">
File: <%= normalize_path(@path) %>
</div>
<% 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>
@ -85,6 +90,14 @@ defmodule LivebookWeb.SessionLive.PersistenceComponent do
List.delete(socket.assigns.running_paths, path)
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)}
end

View file

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

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

View file

@ -22,7 +22,8 @@ defmodule LivebookWeb.Router do
live "/home/sessions/:session_id/close", HomeLive, :close_session
live "/sessions/:id", SessionLive, :page
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-upload/:cell_id", SessionLive, :cell_upload
get "/sessions/:id/images/:image", SessionController, :show_image

View file

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

View file

@ -188,6 +188,32 @@ defmodule LivebookWeb.SessionLiveTest do
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
test "replies with nil completion reference when no runtime is started",
%{conn: conn, session_id: session_id} do
@ -227,6 +253,23 @@ defmodule LivebookWeb.SessionLiveTest do
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
defp wait_for_session_update(session_id) do