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." %>
-