From 07fab7d1aba0922b1cb03c3bdfe96fa38e4e5136 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 2 May 2026 09:15:54 +0200 Subject: [PATCH] feat: delete buttons on sidebar entries --- lib/bds/desktop/shell_live.ex | 315 ++++++++++++++---- .../desktop/shell_live/sidebar_components.ex | 162 ++++++--- priv/ui/app.css | 38 ++- test/bds/desktop/shell_live_test.exs | 128 +++++++ 4 files changed, 522 insertions(+), 121 deletions(-) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 497be34..cd731ac 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -5,7 +5,7 @@ defmodule BDS.Desktop.ShellLive do import Phoenix.HTML - alias BDS.{AI, BoundedAtoms} + alias BDS.{AI, BoundedAtoms, ImportDefinitions, Media, Posts, Scripts} alias BDS.CliSync.Watcher alias BDS.Desktop.{FolderPicker, Overlay, ShellData, UILocale} @@ -419,67 +419,12 @@ defmodule BDS.Desktop.ShellLive do |> reload_shell(workbench)} end - def handle_event("delete_sidebar_template", %{"id" => template_id}, socket) do - case Templates.get_template(template_id) do - %Templates.Template{project_id: project_id} - when project_id == socket.assigns.projects.active_project_id -> - case Templates.delete_template(template_id) do - {:ok, :deleted} -> - workbench = Workbench.close_tab(socket.assigns.workbench, :templates, template_id) - tab_meta = Map.delete(socket.assigns.tab_meta, {:templates, template_id}) - - {:noreply, - socket - |> assign(:tab_meta, tab_meta) - |> reload_shell(workbench)} - - {:error, reason} -> - {:noreply, - socket - |> append_output_entry( - translated("Delete") <> " " <> translated("Template"), - inspect(reason), - nil, - "error" - ) - |> reload_shell(socket.assigns.workbench)} - end - - _other -> - {:noreply, - socket - |> append_output_entry( - translated("Delete") <> " " <> translated("Template"), - inspect(:not_found), - nil, - "error" - ) - |> reload_shell(socket.assigns.workbench)} - end - end - - def handle_event("delete_sidebar_chat", %{"id" => conversation_id}, socket) do - case AI.delete_chat_conversation(conversation_id) do - {:ok, :deleted} -> - workbench = Workbench.close_tab(socket.assigns.workbench, :chat, conversation_id) - tab_meta = Map.delete(socket.assigns.tab_meta, {:chat, conversation_id}) - - {:noreply, - socket - |> assign(:tab_meta, tab_meta) - |> reload_shell(workbench)} - - {:error, reason} -> - {:noreply, - socket - |> append_output_entry( - translated("sidebar.chat.deleteConversation"), - inspect(reason), - nil, - "error" - ) - |> reload_shell(socket.assigns.workbench)} - end + def handle_event( + "confirm_sidebar_delete", + %{"route" => route, "id" => id} = params, + socket + ) do + {:noreply, request_sidebar_delete(socket, route, id, Map.get(params, "title"))} end def handle_event("toggle_offline_mode", _params, socket) do @@ -1348,6 +1293,9 @@ defmodule BDS.Desktop.ShellLive do socket = case {socket.assigns[:shell_overlay], current_tab} do + {%{kind: :confirm_delete, delete_action: %{source: :sidebar, route: route, id: id}}, _tab} -> + execute_sidebar_delete(socket, route, id) + {%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} -> PostEditor.apply_ai_suggestions( socket, @@ -1926,4 +1874,247 @@ defmodule BDS.Desktop.ShellLive do |> append_output_entry(title, translated("Command completed"), details) |> assign(:shell_overlay, nil) end + + defp request_sidebar_delete(socket, route, id, fallback_title) do + case sidebar_delete_target(socket, route, id, fallback_title) do + {:ok, entity_name} -> + assign(socket, :shell_overlay, %{ + kind: :confirm_delete, + title: sidebar_delete_title(route), + entity_name: entity_name, + entity_type: route, + reference_count: 0, + reference_list: [], + delete_action: %{source: :sidebar, route: route, id: id} + }) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(sidebar_delete_title(route), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + + defp execute_sidebar_delete(socket, route, id) do + case route do + "post" -> + socket + |> assign(:shell_overlay, nil) + |> PostEditor.delete_socket(id, &reload_shell/2, &append_output_entry/5) + + "media" -> + socket + |> assign(:shell_overlay, nil) + |> MediaEditor.delete_socket(id, &reload_shell/2, &append_output_entry/5) + + "scripts" -> + delete_sidebar_script(socket, id) + + "templates" -> + delete_sidebar_template(socket, id) + + "chat" -> + delete_sidebar_chat(socket, id) + + "import" -> + delete_sidebar_import(socket, id) + + _other -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(translated("Delete"), inspect(:unsupported_route), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + + defp delete_sidebar_script(socket, script_id) do + case Scripts.delete_script(script_id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :scripts, script_id) + + socket + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:scripts, script_id})) + |> assign(:script_editor_drafts, Map.delete(socket.assigns.script_editor_drafts, script_id)) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(sidebar_delete_title("scripts"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + + defp delete_sidebar_template(socket, template_id) do + case Templates.delete_template(template_id, force: true) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :templates, template_id) + + socket + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:templates, template_id})) + |> assign( + :template_editor_drafts, + Map.delete(socket.assigns.template_editor_drafts, template_id) + ) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(sidebar_delete_title("templates"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + + defp delete_sidebar_chat(socket, conversation_id) do + case AI.delete_chat_conversation(conversation_id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :chat, conversation_id) + + socket + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:chat, conversation_id})) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(sidebar_delete_title("chat"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + + defp delete_sidebar_import(socket, definition_id) do + case ImportDefinitions.delete_definition(definition_id) do + {:ok, :deleted} -> + workbench = Workbench.close_tab(socket.assigns.workbench, :import, definition_id) + + socket + |> assign(:shell_overlay, nil) + |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:import, definition_id})) + |> clear_import_editor_state(definition_id) + |> reload_shell(workbench) + + {:error, reason} -> + socket + |> assign(:shell_overlay, nil) + |> append_output_entry(sidebar_delete_title("import"), inspect(reason), nil, "error") + |> reload_shell(socket.assigns.workbench) + end + end + + defp clear_import_editor_state(socket, definition_id) do + socket + |> assign( + :import_editor_analysis_states, + Map.delete(socket.assigns.import_editor_analysis_states, definition_id) + ) + |> assign( + :import_editor_analysis_task_refs, + Map.delete(socket.assigns.import_editor_analysis_task_refs, definition_id) + ) + |> assign( + :import_editor_execution_states, + Map.delete(socket.assigns.import_editor_execution_states, definition_id) + ) + |> assign( + :import_editor_execution_task_refs, + Map.delete(socket.assigns.import_editor_execution_task_refs, definition_id) + ) + |> assign(:import_editor_sections, Map.delete(socket.assigns.import_editor_sections, definition_id)) + |> assign( + :import_editor_taxonomy_edits, + Map.delete(socket.assigns.import_editor_taxonomy_edits, definition_id) + ) + |> assign( + :import_editor_model_selectors_open, + Map.delete(socket.assigns.import_editor_model_selectors_open, definition_id) + ) + |> assign( + :import_editor_selected_models, + Map.delete(socket.assigns.import_editor_selected_models, definition_id) + ) + end + + defp sidebar_delete_target(socket, route, id, fallback_title) do + active_project_id = socket.assigns.projects.active_project_id + + case route do + "post" -> + case Posts.get_post(id) do + %{project_id: ^active_project_id} = post -> + {:ok, present_title(fallback_title) || present_title(post.title) || present_title(post.slug) || id} + + _other -> + {:error, :not_found} + end + + "media" -> + case Media.get_media(id) do + %{project_id: ^active_project_id} = media -> + {:ok, + present_title(fallback_title) || present_title(media.title) || + present_title(media.original_name) || id} + + _other -> + {:error, :not_found} + end + + "scripts" -> + case Scripts.get_script(id) do + %{project_id: ^active_project_id} = script -> + {:ok, present_title(fallback_title) || present_title(script.title) || id} + + _other -> + {:error, :not_found} + end + + "templates" -> + case Templates.get_template(id) do + %{project_id: ^active_project_id} = template -> + {:ok, present_title(fallback_title) || present_title(template.title) || id} + + _other -> + {:error, :not_found} + end + + "chat" -> + case AI.get_chat_conversation(id) do + %{title: title} -> {:ok, present_title(fallback_title) || present_title(title) || id} + _other -> {:error, :not_found} + end + + "import" -> + case ImportDefinitions.get_definition(id) do + %{project_id: ^active_project_id} = definition -> + {:ok, present_title(fallback_title) || present_title(definition.name) || id} + + _other -> + {:error, :not_found} + end + + _other -> + {:error, :unsupported_route} + end + end + + defp sidebar_delete_title("chat"), do: translated("sidebar.chat.deleteConversation") + defp sidebar_delete_title("post"), do: translated("Delete") <> " " <> translated("Post") + defp sidebar_delete_title("media"), do: translated("Delete") <> " " <> translated("Media") + defp sidebar_delete_title("scripts"), do: translated("Delete") <> " " <> translated("Script") + defp sidebar_delete_title("templates"), do: translated("Delete") <> " " <> translated("Template") + defp sidebar_delete_title("import"), do: translated("Delete") <> " " <> translated("Import") + defp sidebar_delete_title(_route), do: translated("Delete") + + defp present_title(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp present_title(_value), do: nil end diff --git a/lib/bds/desktop/shell_live/sidebar_components.ex b/lib/bds/desktop/shell_live/sidebar_components.ex index 7287b28..32ebfaf 100644 --- a/lib/bds/desktop/shell_live/sidebar_components.ex +++ b/lib/bds/desktop/shell_live/sidebar_components.ex @@ -271,28 +271,45 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do <% end %> @@ -310,34 +327,51 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> <% else %> @@ -353,7 +387,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
<%= for item <- Map.get(@sidebar_data, :items, []) do %> - <%= if item.route in ["templates", "chat"] do %> + <%= if sidebar_deletable?(item.route) do %>
@@ -466,6 +502,24 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) + defp sidebar_deletable?(route), do: route in ["post", "media", "scripts", "templates", "chat", "import"] + + defp sidebar_delete_testid("post"), do: "sidebar-delete-post" + defp sidebar_delete_testid("media"), do: "sidebar-delete-media" + defp sidebar_delete_testid("scripts"), do: "sidebar-delete-script" + defp sidebar_delete_testid("templates"), do: "sidebar-delete-template" + defp sidebar_delete_testid("chat"), do: "sidebar-delete-chat" + defp sidebar_delete_testid("import"), do: "sidebar-delete-import" + defp sidebar_delete_testid(route), do: "sidebar-delete-#{route}" + + defp sidebar_delete_title("chat"), do: translated("sidebar.chat.deleteConversation") + defp sidebar_delete_title("post"), do: translated("Delete") <> " " <> translated("Post") + defp sidebar_delete_title("media"), do: translated("Delete") <> " " <> translated("Media") + defp sidebar_delete_title("scripts"), do: translated("Delete") <> " " <> translated("Script") + defp sidebar_delete_title("templates"), do: translated("Delete") <> " " <> translated("Template") + defp sidebar_delete_title("import"), do: translated("Delete") <> " " <> translated("Import") + defp sidebar_delete_title(_route), do: translated("Delete") + defp template_sidebar?(sidebar_data), do: Map.get(sidebar_data, :title) == "Templates" defp group_year_month_counts(entries) do diff --git a/priv/ui/app.css b/priv/ui/app.css index 1740389..0a8c1a7 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -6819,6 +6819,11 @@ button svg * { flex-direction: column; } +.sidebar-item-row { + display: flex; + align-items: stretch; +} + .sidebar-item { width: 100%; display: flex; @@ -6921,6 +6926,12 @@ button svg * { padding: 4px; } +.media-item-row { + display: flex; + align-items: stretch; + gap: 4px; +} + .media-item { display: flex; align-items: center; @@ -7004,6 +7015,12 @@ button svg * { color: var(--vscode-descriptionForeground); } +.sidebar-item-row .sidebar-item, +.media-item-row .media-item { + flex: 1; + min-width: 0; +} + .sidebar-actions { display: flex; gap: 4px; @@ -8197,27 +8214,38 @@ button.import-taxonomy-pill { font-size: 12px; } -.chat-item-delete { +.sidebar-delete-button { background: transparent; border: none; color: var(--vscode-descriptionForeground); cursor: pointer; font-size: 16px; line-height: 1; - padding: 0 4px; + padding: 0 6px; + flex-shrink: 0; opacity: 0; transition: opacity 0.15s, color 0.15s; } -.chat-list-item:hover .chat-item-delete, -.chat-list-item.active .chat-item-delete { +.sidebar-item-row:hover .sidebar-delete-button, +.sidebar-item-row .sidebar-item.selected ~ .sidebar-delete-button, +.media-item-row:hover .sidebar-delete-button, +.media-item-row .media-item.selected ~ .sidebar-delete-button, +.chat-list-item:hover .sidebar-delete-button, +.chat-list-item.active .sidebar-delete-button { opacity: 1; } -.chat-item-delete:hover { +.sidebar-delete-button:hover { color: var(--vscode-errorForeground); } +.sidebar-item-row .sidebar-item.selected ~ .sidebar-delete-button, +.media-item-row .media-item.selected ~ .sidebar-delete-button { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + .settings-nav-list { display: flex; flex-direction: column; diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 2e30e4e..7091297 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -289,6 +289,12 @@ defmodule BDS.Desktop.ShellLiveTest do |> element("[data-testid='sidebar-delete-chat'][data-item-id='#{created_chat.id}']") |> render_click() + assert Repo.get(BDS.AI.ChatConversation, created_chat.id) + assert html =~ "confirm-delete-modal" + assert html =~ created_chat.title + + html = render_click(view, "overlay_confirm", %{}) + refute Repo.get(BDS.AI.ChatConversation, created_chat.id) refute html =~ ~s(data-tab-id="#{created_chat.id}") @@ -324,6 +330,122 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-settings-scroll-target="settings-section-ai") end + test "database-backed sidebar entries require confirmation before deletion", %{ + project: project, + temp_dir: temp_dir + } do + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Sidebar Delete Post", + content: "delete me" + }) + + media_source_path = Path.join(temp_dir, "sidebar-delete-media.txt") + File.write!(media_source_path, "media body") + + assert {:ok, media} = + Media.import_media(%{ + project_id: project.id, + source_path: media_source_path, + title: "Sidebar Delete Media" + }) + + assert {:ok, script} = + Scripts.create_script(%{ + project_id: project.id, + title: "Sidebar Delete Script", + kind: :utility, + content: "print(\"delete\")", + entrypoint: "main", + enabled: true + }) + + assert {:ok, template} = + Templates.create_template(%{ + project_id: project.id, + title: "Sidebar Delete Template", + kind: :post, + content: "
{{ post.content }}
", + enabled: true + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "Sidebar Delete Chat"}) + + assert {:ok, definition} = + ImportDefinitions.create_definition(%{ + project_id: project.id, + name: "Sidebar Delete Import" + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + cases = [ + %{ + view: "posts", + id: post.id, + title: post.title, + testid: "sidebar-delete-post", + exists?: fn -> Repo.get(Post, post.id) != nil end + }, + %{ + view: "media", + id: media.id, + title: media.title, + testid: "sidebar-delete-media", + exists?: fn -> Repo.get(BDS.Media.Media, media.id) != nil end + }, + %{ + view: "scripts", + id: script.id, + title: script.title, + testid: "sidebar-delete-script", + exists?: fn -> Repo.get(BDS.Scripts.Script, script.id) != nil end + }, + %{ + view: "templates", + id: template.id, + title: template.title, + testid: "sidebar-delete-template", + exists?: fn -> Repo.get(BDS.Templates.Template, template.id) != nil end + }, + %{ + view: "chat", + id: conversation.id, + title: conversation.title, + testid: "sidebar-delete-chat", + exists?: fn -> Repo.get(BDS.AI.ChatConversation, conversation.id) != nil end + }, + %{ + view: "import", + id: definition.id, + title: definition.name, + testid: "sidebar-delete-import", + exists?: fn -> Repo.get(ImportDefinitions.ImportDefinition, definition.id) != nil end + } + ] + + Enum.each(cases, fn sidebar_case -> + html = render_click(view, "select_view", %{"view" => sidebar_case.view}) + + assert html =~ ~s(data-testid="#{sidebar_case.testid}") + + html = + view + |> element("[data-testid='#{sidebar_case.testid}'][data-item-id='#{sidebar_case.id}']") + |> render_click() + + assert sidebar_case.exists?.() + assert html =~ "confirm-delete-modal" + assert html =~ sidebar_case.title + + html = render_click(view, "overlay_confirm", %{}) + + refute sidebar_case.exists?.() + refute html =~ sidebar_case.title + end) + end + test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change", %{project: project} do {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) @@ -3292,6 +3414,12 @@ defmodule BDS.Desktop.ShellLiveTest do |> element("[data-testid='sidebar-delete-template'][data-item-id='#{template.id}']") |> render_click() + assert BDS.Repo.get(BDS.Templates.Template, template.id) + assert html =~ "confirm-delete-modal" + assert html =~ template.title + + html = render_click(view, "overlay_confirm", %{}) + assert BDS.Repo.get(BDS.Templates.Template, template.id) == nil refute html =~ "Sidebar Template" refute html =~ ~s(data-tab-type="templates")