diff --git a/assets/css/components.css b/assets/css/components.css index d36d52e5a..d497df72d 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -163,6 +163,19 @@ background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='9.5' stroke='%233E64FF' fill='white' /%3e%3ccircle cx='10' cy='10' r='6' fill='%233E64FF' /%3e%3c/svg%3e"); } + .checkbox-base { + @apply h-5 w-5 appearance-none border border-gray-300 rounded text-blue-600 cursor-pointer; + } + + .checkbox-base:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; + } + /* Custom scrollbars */ .tiny-scrollbar::-webkit-scrollbar { @@ -212,6 +225,14 @@ @apply w-full flex space-x-3 px-5 py-2 items-center hover:bg-gray-100 focus:bg-gray-100 whitespace-nowrap; } + .menu-item:disabled { + @apply pointer-events-none opacity-50; + } + + .menu-item--disabled { + @apply pointer-events-none opacity-50; + } + /* Boxes */ .error-box { diff --git a/assets/js/app.js b/assets/js/app.js index 6eb30c51d..8a1641881 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -91,6 +91,14 @@ window.addEventListener("lb:set_value", (event) => { event.target.value = event.detail.value; }); +window.addEventListener("lb:check", (event) => { + event.target.checked = true; +}); + +window.addEventListener("lb:uncheck", (event) => { + event.target.checked = false; +}); + window.addEventListener("lb:clipcopy", (event) => { if ("clipboard" in navigator) { const text = event.target.textContent; @@ -113,6 +121,18 @@ window.addEventListener("contextmenu", (event) => { } }); +window.addEventListener("lb:session_list:on_selection_change", () => { + const anySessionSelected = !!document.querySelector( + "[name='session_ids[]']:checked" + ); + const disconnect = document.querySelector( + "#edit-sessions [name='disconnect']" + ); + const closeAll = document.querySelector("#edit-sessions [name='close_all']"); + disconnect.disabled = !anySessionSelected; + closeAll.disabled = !anySessionSelected; +}); + // Global configuration settingsStore.getAndSubscribe((settings) => { diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index b7efec9a8..f6ada6776 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -375,4 +375,18 @@ defmodule LivebookWeb.Helpers do def file_system_label(%FileSystem.Local{}), do: "Local disk" def file_system_label(%FileSystem.S3{} = fs), do: fs.bucket_url + + @doc """ + Returns the text in singular or plural depending on the quantity + + ## Examples + + iex> LivebookWeb.Helpers.pluralize(1, "notebook is not persisted", "notebooks are not persisted") + "1 notebook is not persisted" + + iex> LivebookWeb.Helpers.pluralize(3, "notebook is not persisted", "notebooks are not persisted") + "3 notebooks are not persisted" + """ + def pluralize(1, singular, _plural), do: "1 #{singular}" + def pluralize(count, _singular, plural), do: "#{count} #{plural}" end diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index 7bbe84f67..9e0c4a7f1 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -107,7 +107,7 @@ defmodule LivebookWeb.HomeLive do
<.live_component module={LivebookWeb.HomeLive.SessionListComponent} id="session-list" - sessions={@sessions} /> + sessions={@sessions}/>
@@ -136,6 +136,17 @@ defmodule LivebookWeb.HomeLive do import_opts={@import_opts} /> <% end %> + + <%= if @live_action == :edit_sessions do %> + <.modal class="w-full max-w-xl" return_to={Routes.home_path(@socket, :page)}> + <.live_component module={LivebookWeb.HomeLive.EditSessionsComponent} + id="edit-sessions" + action={@bulk_action} + return_to={Routes.home_path(@socket, :page)} + sessions={@sessions} + selected_sessions={selected_sessions(@sessions, @selected_session_ids)} /> + + <% end %> """ end @@ -153,6 +164,14 @@ defmodule LivebookWeb.HomeLive do {:noreply, assign(socket, session: session)} end + def handle_params( + %{"action" => action}, + _url, + %{assigns: %{live_action: :edit_sessions}} = socket + ) do + {:noreply, assign(socket, bulk_action: action)} + end + def handle_params(%{"tab" => tab} = params, _url, %{assigns: %{live_action: :import}} = socket) do import_opts = [url: params["url"]] {:noreply, assign(socket, tab: tab, import_opts: import_opts)} @@ -221,6 +240,22 @@ defmodule LivebookWeb.HomeLive do {:noreply, socket} end + def handle_event("bulk_action", %{"action" => "disconnect"} = params, socket) do + socket = assign(socket, selected_session_ids: params["session_ids"]) + {:noreply, push_patch(socket, to: Routes.home_path(socket, :edit_sessions, "disconnect"))} + end + + def handle_event("bulk_action", %{"action" => "close_all"} = params, socket) do + socket = assign(socket, selected_session_ids: params["session_ids"]) + {:noreply, push_patch(socket, to: Routes.home_path(socket, :edit_sessions, "close_all"))} + end + + def handle_event("disconnect_runtime", %{"id" => session_id}, socket) do + session = Enum.find(socket.assigns.sessions, &(&1.id == session_id)) + Session.disconnect_runtime(session.pid) + {:noreply, socket} + end + def handle_event("fork_session", %{"id" => session_id}, socket) do session = Enum.find(socket.assigns.sessions, &(&1.id == session_id)) %{images_dir: images_dir} = session @@ -345,4 +380,8 @@ defmodule LivebookWeb.HomeLive do {:error, _} -> :none end end + + defp selected_sessions(sessions, selected_session_ids) do + Enum.filter(sessions, &(&1.id in selected_session_ids)) + end end diff --git a/lib/livebook_web/live/home_live/close_session_component.ex b/lib/livebook_web/live/home_live/close_session_component.ex index d93bf3ccd..3d147ee69 100644 --- a/lib/livebook_web/live/home_live/close_session_component.ex +++ b/lib/livebook_web/live/home_live/close_session_component.ex @@ -1,6 +1,8 @@ defmodule LivebookWeb.HomeLive.CloseSessionComponent do use LivebookWeb, :live_component + import LivebookWeb.HomeLive.SessionListComponent, only: [toggle_edit: 1] + @impl true def render(assigns) do ~H""" @@ -14,10 +16,11 @@ defmodule LivebookWeb.HomeLive.CloseSessionComponent do
<%= if @session.file, do: "This won't delete any persisted files.", - else: "The notebook is not persisted and all content will be lost." %> + else: "The notebook is not persisted and content may be lost." %>

- diff --git a/lib/livebook_web/live/home_live/edit_sessions_component.ex b/lib/livebook_web/live/home_live/edit_sessions_component.ex new file mode 100644 index 000000000..43e45d7fe --- /dev/null +++ b/lib/livebook_web/live/home_live/edit_sessions_component.ex @@ -0,0 +1,76 @@ +defmodule LivebookWeb.HomeLive.EditSessionsComponent do + use LivebookWeb, :live_component + + import LivebookWeb.HomeLive.SessionListComponent, only: [toggle_edit: 1] + + @impl true + def render(assigns) do + ~H""" +
+

+ <%= title(@action) %> +

+ <.message action={@action} selected_sessions={@selected_sessions} sessions={@sessions}/> +
+ + <%= live_patch "Cancel", to: @return_to, class: "button-base button-outlined-gray" %> +
+
+ """ + end + + defp message(%{action: "close_all"} = assigns) do + ~H""" +

+ Are you sure you want to close <%= pluralize(length(@selected_sessions), "session", "sessions") %>? + <%= if not_persisted_count(@selected_sessions) > 0 do %> +
+ Important: + <%= pluralize( + not_persisted_count(@selected_sessions), + "notebook is not persisted and its content may be lost.", + "notebooks are not persisted and their content may be lost." + ) %> + <% end %> +

+ """ + end + + defp message(%{action: "disconnect"} = assigns) do + ~H""" +

+ Are you sure you want to disconnect <%= pluralize(length(@selected_sessions), "session", "sessions") %>? +

+ """ + end + + @impl true + def handle_event("close_all", %{}, socket) do + socket.assigns.selected_sessions + |> Enum.each(&Livebook.Session.close(&1.pid)) + + {:noreply, push_patch(socket, to: socket.assigns.return_to, replace: true)} + end + + def handle_event("disconnect", %{}, socket) do + socket.assigns.selected_sessions + |> Enum.reject(&(&1.memory_usage.runtime == nil)) + |> Enum.each(&Livebook.Session.disconnect_runtime(&1.pid)) + + {:noreply, push_patch(socket, to: socket.assigns.return_to, replace: true)} + end + + defp button_label("close_all"), do: "Close sessions" + defp button_label("disconnect"), do: "Disconnect runtime" + + defp title("close_all"), do: "Close sessions" + defp title("disconnect"), do: "Disconnect runtime" + + defp not_persisted_count(selected_sessions) do + Enum.count(selected_sessions, &(!&1.file)) + end +end diff --git a/lib/livebook_web/live/home_live/session_list_component.ex b/lib/livebook_web/live/home_live/session_list_component.ex index f1fa286d5..68b1d8869 100644 --- a/lib/livebook_web/live/home_live/session_list_component.ex +++ b/lib/livebook_web/live/home_live/session_list_component.ex @@ -32,35 +32,43 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do @impl true def render(assigns) do ~H""" -
+
-

- Running sessions (<%= length(@sessions) %>) -

- <.memory_info /> - <.menu id="sessions-order-menu"> - <:toggle> - - - <:content> - <%= for order_by <- ["date", "title", "memory"] do %> -
+
+ <.memory_info /> + <%= if @sessions != [] do %> + <.edit_sessions sessions={@sessions} socket={@socket}/> + <% end %> + <.menu id="sessions-order-menu"> + <:toggle> + - <% end %> - - + + <:content> + <%= for order_by <- ["date", "title", "memory"] do %> + + <% end %> + +
- <.session_list sessions={@sessions} socket={@socket} show_autosave_note?={@show_autosave_note?} /> -
+ <.session_list sessions={@sessions} socket={@socket} + show_autosave_note?={@show_autosave_note?} /> + """ end @@ -93,6 +101,12 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do <%= for session <- @sessions do %>
+
+ +
<%= live_redirect session.notebook_name, to: Routes.session_path(@socket, :page, session.id), @@ -113,12 +127,13 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
<.menu id={"session-#{session.id}-menu"}> <:toggle> - <:content> <%= live_patch to: Routes.home_path(@socket, :close_session, session.id), class: "menu-item text-red-600", role: "menuitem" do %> @@ -171,6 +195,49 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do """ end + defp edit_sessions(assigns) do + ~H""" +
+ <.menu id="edit-sessions"> + <:toggle> + + + + <:content> + + + + + + + +
+ """ + end + @impl true def handle_event("set_order", %{"order_by" => order_by}, socket) do sessions = sort_sessions(socket.assigns.sessions, order_by) @@ -182,6 +249,19 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do time_words <> " ago" end + def toggle_edit(:on) do + JS.remove_class("hidden", to: "[data-element='bulk-edit-member']") + |> JS.add_class("hidden", to: "#toggle-edit") + |> JS.dispatch("lb:session_list:on_selection_change") + end + + def toggle_edit(:off) do + JS.add_class("hidden", to: "[data-element='bulk-edit-member']") + |> JS.remove_class("hidden", to: "#toggle-edit") + |> JS.dispatch("lb:uncheck", to: "[name='session_ids[]']") + |> JS.dispatch("lb:session_list:on_selection_change") + end + defp order_by_label("date"), do: "Date" defp order_by_label("title"), do: "Title" defp order_by_label("memory"), do: "Memory" @@ -206,4 +286,14 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do defp total_runtime_memory(%{memory_usage: %{runtime: nil}}), do: 0 defp total_runtime_memory(%{memory_usage: %{runtime: %{total: total}}}), do: total + + defp select_all() do + JS.dispatch("lb:check", to: "[name='session_ids[]']") + |> JS.dispatch("lb:session_list:on_selection_change") + end + + defp set_action(action) do + JS.dispatch("lb:set_value", to: "#bulk-action-input", detail: %{value: action}) + |> JS.dispatch("submit", to: "#bulk-action-form") + end end diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index df20a134f..a08ccc50a 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -35,6 +35,7 @@ defmodule LivebookWeb.Router do live "/home/user-profile", HomeLive, :user live "/home/import/:tab", HomeLive, :import live "/home/sessions/:session_id/close", HomeLive, :close_session + live "/home/sessions/edit_sessions/:action", HomeLive, :edit_sessions live "/settings", SettingsLive, :page live "/settings/user-profile", SettingsLive, :user diff --git a/test/livebook_web/helpers_test.exs b/test/livebook_web/helpers_test.exs index 5e2c5545c..b4caf30a1 100644 --- a/test/livebook_web/helpers_test.exs +++ b/test/livebook_web/helpers_test.exs @@ -3,6 +3,8 @@ defmodule LivebookWeb.HelpersTest do alias LivebookWeb.Helpers + doctest Helpers + describe "names_to_html_ids/1" do test "title case" do assert(Helpers.names_to_html_ids(["Title of a Section"]) == ["title-of-a-section"]) diff --git a/test/livebook_web/live/home_live_test.exs b/test/livebook_web/live/home_live_test.exs index 717bc5267..f34abb386 100644 --- a/test/livebook_web/live/home_live_test.exs +++ b/test/livebook_web/live/home_live_test.exs @@ -29,7 +29,7 @@ defmodule LivebookWeb.HomeLiveTest do path = Path.expand("../../../lib", __DIR__) <> "/" view - |> element("form") + |> element(~s{form[phx-change="set_path"]}) |> render_change(%{path: path}) # Render the view separately to make sure it received the :set_file event @@ -42,7 +42,7 @@ defmodule LivebookWeb.HomeLiveTest do path = test_notebook_path("basic") view - |> element("form") + |> element(~s{form[phx-change="set_path"]}) |> render_change(%{path: Path.dirname(path) <> "/"}) view @@ -62,7 +62,7 @@ defmodule LivebookWeb.HomeLiveTest do {:ok, view, _} = live(conn, "/") view - |> element("form") + |> element(~s{form[phx-change="set_path"]}) |> render_change(%{path: tmp_dir <> "/"}) assert view @@ -76,7 +76,7 @@ defmodule LivebookWeb.HomeLiveTest do path = File.cwd!() |> Path.join("nonexistent.livemd") view - |> element("form") + |> element(~s{form[phx-change="set_path"]}) |> render_change(%{path: path}) assert view @@ -94,7 +94,7 @@ defmodule LivebookWeb.HomeLiveTest do File.chmod!(path, 0o444) view - |> element("form") + |> element(~s{form[phx-change="set_path"]}) |> render_change(%{path: tmp_dir <> "/"}) view @@ -165,11 +165,40 @@ defmodule LivebookWeb.HomeLiveTest do |> render_click() view - |> element(~s{button}, "Close session") + |> element(~s{button[role=button]}, "Close session") |> render_click() refute render(view) =~ session.id end + + test "close all selected sessions using bulk action", %{conn: conn} do + {:ok, session1} = Sessions.create_session() + {:ok, session2} = Sessions.create_session() + {:ok, session3} = Sessions.create_session() + + {:ok, view, _} = live(conn, "/") + + assert render(view) =~ session1.id + assert render(view) =~ session2.id + assert render(view) =~ session3.id + + view + |> form("#bulk-action-form", %{ + "action" => "close_all", + "session_ids" => [session1.id, session2.id, session3.id] + }) + |> render_submit() + + assert render(view) =~ "Are you sure you want to close 3 sessions?" + + view + |> element(~s{button[role="button"]}, "Close sessions") + |> render_click() + + refute render(view) =~ session1.id + refute render(view) =~ session2.id + refute render(view) =~ session3.id + end end test "link to introductory notebook correctly creates a new session", %{conn: conn} do