mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-12 07:54:49 +08:00
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:
parent
dd904699bc
commit
e755ff8122
22 changed files with 364 additions and 247 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,3 @@
|
|||
font-family: "JetBrains Mono";
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shadow-center {
|
||||
box-shadow: 0px 15px 99px rgba(13, 24, 41, 0.15);
|
||||
}
|
||||
|
|
|
@ -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
44
assets/js/menu/index.js
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue