From 557df4a5bf0ed27b0491f50f92fbf309b03c8624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Fri, 2 Jul 2021 23:06:05 +0200 Subject: [PATCH] Add UI for file deletion and renaming (#426) --- assets/js/lib/attribute.js | 13 ++ assets/js/menu/index.js | 75 +++++-- .../live/path_select_component.ex | 207 +++++++++++++++--- 3 files changed, 251 insertions(+), 44 deletions(-) diff --git a/assets/js/lib/attribute.js b/assets/js/lib/attribute.js index ffb1f54aa..60da3a49f 100644 --- a/assets/js/lib/attribute.js +++ b/assets/js/lib/attribute.js @@ -10,6 +10,19 @@ export function getAttributeOrThrow(element, attr, transform = null) { return transform ? transform(value) : value; } +export function getAttributeOrDefault( + element, + attr, + defaultAttrVal, + transform = null +) { + const value = element.hasAttribute(attr) + ? element.getAttribute(attr) + : defaultAttrVal; + + return transform ? transform(value) : value; +} + export function parseBoolean(value) { if (value === "true") { return true; diff --git a/assets/js/menu/index.js b/assets/js/menu/index.js index e72bc6d4d..ecc18b245 100644 --- a/assets/js/menu/index.js +++ b/assets/js/menu/index.js @@ -1,6 +1,13 @@ +import { getAttributeOrDefault, parseBoolean } from "../lib/attribute"; + /** * A hook controlling a toggleable menu. * + * Configuration: + * + * * `data-primary` - a boolean indicating whether to open on the primary + * click or the secondary click (like right mouse button click). Defaults to `true`. + * * The element should have two children: * * * one annotated with `data-toggle` being a clickable element @@ -9,6 +16,8 @@ */ const Menu = { mounted() { + this.props = getProps(this); + const toggleElement = this.el.querySelector("[data-toggle]"); if (!toggleElement) { @@ -21,24 +30,58 @@ const Menu = { 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) => { + if (this.props.primary) { + 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); + } + }); + } else { + toggleElement.addEventListener("contextmenu", (event) => { + event.preventDefault(); + + 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(() => { + const handler = (event) => { this.el.removeAttribute("data-js-open"); - }, - { once: true } - ); - }, 0); - } - }); + document.removeEventListener("click", handler); + document.removeEventListener("contextmenu", handler); + }; + + document.addEventListener("click", handler); + document.addEventListener("contextmenu", handler); + }, 0); + } + }); + } }, }; +function getProps(hook) { + return { + primary: getAttributeOrDefault( + hook.el, + "data-primary", + "true", + parseBoolean + ), + }; +} + export default Menu; diff --git a/lib/livebook_web/live/path_select_component.ex b/lib/livebook_web/live/path_select_component.ex index 2633c21b7..23e75ca64 100644 --- a/lib/livebook_web/live/path_select_component.ex +++ b/lib/livebook_web/live/path_select_component.ex @@ -20,7 +20,16 @@ defmodule LivebookWeb.PathSelectComponent do @impl true def mount(socket) do inner_block = Map.get(socket.assigns, :inner_block, nil) - {:ok, assign(socket, inner_block: inner_block, current_dir: nil, new_directory_name: nil)} + + {:ok, + assign(socket, + inner_block: inner_block, + current_dir: nil, + new_directory_name: nil, + deleting_path: nil, + renaming_path: nil, + renamed_name: "" + )} end @impl true @@ -89,6 +98,27 @@ defmodule LivebookWeb.PathSelectComponent do <% end %> + <%= if @deleting_path do %> +
+

+ Are you sure you want to irreversibly delete + <%= @deleting_path %>? +

+
+ + +
+
+ <% end %>
<%= if @new_directory_name do %>
@@ -97,19 +127,21 @@ defmodule LivebookWeb.PathSelectComponent do <%= remix_icon("folder-add-fill", class: "text-xl align-middle text-gray-400") %> -
+
- +
@@ -118,14 +150,14 @@ defmodule LivebookWeb.PathSelectComponent do <%= if highlighting?(@files) do %>
<%= for file <- @files, file.highlighted != "" do %> - <%= render_file(file, @phx_target) %> + <%= render_file(file, @phx_target, @myself, @renaming_path, @renamed_name) %> <% end %>
<% end %>
<%= for file <- @files, file.highlighted == "" do %> - <%= render_file(file, @phx_target) %> + <%= render_file(file, @phx_target, @myself, @renaming_path, @renamed_name) %> <% end %>
@@ -137,7 +169,42 @@ defmodule LivebookWeb.PathSelectComponent do Enum.any?(files, &(&1.highlighted != "")) end - defp render_file(file, phx_target) do + defp render_file( + %{path: renaming_path} = file, + _phx_target, + myself, + renaming_path, + renamed_name + ) do + assigns = %{file: file, myself: myself, renamed_name: renamed_name} + + ~L""" +
+ + <%= remix_icon("edit-line", class: "text-xl align-middle text-gray-400") %> + + +
+ +
+
+
+ """ + end + + defp render_file(file, phx_target, myself, _renaming_path, _renamed_name) do icon = case file do %{is_running: true} -> "play-circle-line" @@ -145,27 +212,50 @@ defmodule LivebookWeb.PathSelectComponent do _ -> "file-code-line" end - assigns = %{file: file, icon: icon} + assigns = %{file: file, icon: icon, myself: myself} ~L""" - + "> + <%= if @file.highlighted != "" do %> + "> + <%= @file.highlighted %> + + <% end %> + + <%= @file.unhighlighted %> + + + + + """ end @@ -264,7 +354,7 @@ defmodule LivebookWeb.PathSelectComponent do {:noreply, assign(socket, new_directory_name: nil)} end - def handle_event("create_directory", %{"name" => name}, socket) do + def handle_event("create_directory", %{"value" => name}, socket) do socket = case create_directory(socket.assigns.current_dir, name) do :ok -> @@ -279,10 +369,71 @@ defmodule LivebookWeb.PathSelectComponent do {:noreply, socket} end + def handle_event("delete_file", %{"path" => path}, socket) do + {:noreply, assign(socket, deleting_path: path)} + end + + def handle_event("cancel_delete_file", %{}, socket) do + {:noreply, assign(socket, deleting_path: nil)} + end + + def handle_event("do_delete_file", %{}, socket) do + socket = + case delete_file(socket.assigns.deleting_path) do + :ok -> + socket + |> assign(deleting_path: nil) + |> update_files(true) + + _ -> + socket + end + + {:noreply, socket} + end + + def handle_event("rename_file", %{"path" => path}, socket) do + {_, name} = path |> Path.expand() |> split_path() + {:noreply, assign(socket, renaming_path: path, renamed_name: name)} + end + + def handle_event("cancel_rename_file", %{}, socket) do + {:noreply, assign(socket, renaming_path: nil)} + end + + def handle_event("do_rename_file", %{"value" => name}, socket) do + socket = + case rename_file(socket.assigns.renaming_path, name) do + :ok -> + socket + |> assign(renaming_path: nil) + |> update_files(true) + + _ -> + assign(socket, renamed_name: name) + end + + {:noreply, socket} + end + defp create_directory(_parent_dir, ""), do: {:error, :empty} defp create_directory(parent_dir, name) do new_dir = Path.join(parent_dir, name) File.mkdir(new_dir) end + + defp delete_file(path) do + with {:ok, _paths} <- File.rm_rf(path) do + :ok + end + end + + defp rename_file(_path, ""), do: {:error, :empty} + + defp rename_file(path, name) do + dir = path |> Path.expand() |> Path.dirname() + new_path = Path.join(dir, name) + File.rename(path, new_path) + end end