Compare commits

..

4 Commits

11 changed files with 1185 additions and 138 deletions

View File

@@ -2,6 +2,7 @@ defmodule BDS.BoundedAtoms do
@moduledoc false @moduledoc false
alias BDS.UI.Registry alias BDS.UI.Registry
alias BDS.UI.MenuBar
@panel_tabs [:tasks, :output, :post_links, :git_log] @panel_tabs [:tasks, :output, :post_links, :git_log]
@post_statuses [:draft, :published, :archived] @post_statuses [:draft, :published, :archived]
@@ -37,11 +38,31 @@ defmodule BDS.BoundedAtoms do
:view_posts, :view_posts,
:view_media, :view_media,
:edit_preferences, :edit_preferences,
:open_in_browser,
:open_data_folder,
:preview_post,
:edit_menu, :edit_menu,
:rebuild_database,
:reindex_text,
:rebuild_embedding_index,
:metadata_diff,
:regenerate_calendar,
:validate_translations,
:find_duplicates,
:generate_sitemap,
:validate_site,
:upload_site,
:documentation, :documentation,
:api_documentation, :api_documentation,
:close_tab :close_tab
] ]
@menu_actions MenuBar.default_groups(dev_mode?: true)
|> Enum.flat_map(fn group ->
Enum.flat_map(group.items, fn
%{separator: true} -> []
%{id: id} -> [id]
end)
end)
def atom(value, allowed, fallback \\ nil) def atom(value, allowed, fallback \\ nil)
@@ -70,6 +91,7 @@ defmodule BDS.BoundedAtoms do
def ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback) def ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback)
def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, fallback) def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, fallback)
def shell_command(value, fallback \\ nil), do: atom(value, @shell_commands, fallback) def shell_command(value, fallback \\ nil), do: atom(value, @shell_commands, fallback)
def menu_action(value, fallback \\ nil), do: atom(value, @menu_actions, fallback)
defp string_atom(value, allowed, fallback) do defp string_atom(value, allowed, fallback) do
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value)) Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))

View File

@@ -279,6 +279,19 @@ defmodule BDS.Desktop.ShellCommands do
end) end)
end end
defp dispatch("regenerate_calendar", project, _params) do
queue_task(project, "regenerate_calendar", "Regenerate Calendar", "Generation", fn report ->
{:ok, generation} = Generation.generate_site(project.id, [:core], on_progress: report)
report.(1.0, "Calendar regenerated")
%{
project_id: project.id,
sections: generation.sections,
generated_count: length(generation.generated_files)
}
end)
end
defp dispatch("repair_metadata_diff", project, params) do defp dispatch("repair_metadata_diff", project, params) do
items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, [])) items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, []))
direction = BDS.MapUtils.attr(params, :direction) direction = BDS.MapUtils.attr(params, :direction)

View File

@@ -5,7 +5,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML import Phoenix.HTML
alias BDS.{AI, BoundedAtoms} alias BDS.{AI, BoundedAtoms, ImportDefinitions, Media, Posts, Scripts}
alias BDS.CliSync.Watcher alias BDS.CliSync.Watcher
alias BDS.Desktop.{FolderPicker, Overlay, ShellData, UILocale} alias BDS.Desktop.{FolderPicker, Overlay, ShellData, UILocale}
@@ -56,6 +56,8 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Projects alias BDS.Projects
alias BDS.Templates alias BDS.Templates
alias BDS.UI.{Commands, MenuBar, Session, Workbench} alias BDS.UI.{Commands, MenuBar, Session, Workbench}
alias Desktop.OS
alias BDS.Desktop.Shutdown
@refresh_interval 1_500 @refresh_interval 1_500
@output_entry_limit 20 @output_entry_limit 20
@@ -71,6 +73,44 @@ defmodule BDS.Desktop.ShellLive do
:api_documentation, :api_documentation,
:close_tab :close_tab
]) ])
@socket_menu_actions MapSet.new([
:new_post,
:import_media,
:save,
:publish_selected,
:quit,
:view_on_github,
:report_issue,
:about
])
@runtime_menu_actions MapSet.new([
:undo,
:redo,
:cut,
:copy,
:paste,
:delete,
:select_all,
:find,
:replace,
:reload,
:force_reload,
:reset_zoom,
:zoom_in,
:zoom_out,
:toggle_full_screen
])
def supported_menu_actions do
@local_menu_actions
|> MapSet.union(@socket_menu_actions)
|> MapSet.union(@runtime_menu_actions)
|> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
|> MapSet.union(MapSet.new([:preview_post, :rebuild_database, :reindex_text]))
|> MapSet.union(MapSet.new([:rebuild_embedding_index, :metadata_diff, :regenerate_calendar]))
|> MapSet.union(MapSet.new([:validate_translations, :find_duplicates]))
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
end
embed_templates("shell_live/*") embed_templates("shell_live/*")
@@ -392,7 +432,10 @@ defmodule BDS.Desktop.ShellLive do
if Layout.ignore_shortcut?(params) do if Layout.ignore_shortcut?(params) do
{:noreply, socket} {:noreply, socket}
else else
{:noreply, reload_shell(socket, Commands.handle_shortcut(socket.assigns.workbench, params))} case Commands.command_for_shortcut(params) do
nil -> {:noreply, socket}
action -> {:noreply, handle_menu_action(socket, action)}
end
end end
end end
@@ -419,67 +462,12 @@ defmodule BDS.Desktop.ShellLive do
|> reload_shell(workbench)} |> reload_shell(workbench)}
end end
def handle_event("delete_sidebar_template", %{"id" => template_id}, socket) do def handle_event(
case Templates.get_template(template_id) do "confirm_sidebar_delete",
%Templates.Template{project_id: project_id} %{"route" => route, "id" => id} = params,
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 socket
|> assign(:tab_meta, tab_meta) ) do
|> reload_shell(workbench)} {:noreply, request_sidebar_delete(socket, route, id, Map.get(params, "title"))}
{: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
end end
def handle_event("toggle_offline_mode", _params, socket) do def handle_event("toggle_offline_mode", _params, socket) do
@@ -1348,6 +1336,9 @@ defmodule BDS.Desktop.ShellLive do
socket = socket =
case {socket.assigns[:shell_overlay], current_tab} do 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}} -> {%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} ->
PostEditor.apply_ai_suggestions( PostEditor.apply_ai_suggestions(
socket, socket,
@@ -1652,6 +1643,7 @@ defmodule BDS.Desktop.ShellLive do
dashboard = ShellData.dashboard(projects.active_project_id) dashboard = ShellData.dashboard(projects.active_project_id)
git_badge_count = ShellData.git_badge_count(projects.active_project_id) git_badge_count = ShellData.git_badge_count(projects.active_project_id)
active_view_id = Atom.to_string(workbench.active_view) active_view_id = Atom.to_string(workbench.active_view)
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
sidebar_data = sidebar_data =
ShellData.sidebar_view( ShellData.sidebar_view(
@@ -1675,6 +1667,7 @@ defmodule BDS.Desktop.ShellLive do
task_status = localize_task_status(raw_task_status, page_language) task_status = localize_task_status(raw_task_status, page_language)
socket socket
|> assign(:tab_meta, tab_meta)
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:projects, projects) |> assign(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects)) |> assign(:current_project, ShellData.current_project(projects))
@@ -1883,17 +1876,99 @@ defmodule BDS.Desktop.ShellLive do
end end
defp handle_native_menu_action(socket, action) do defp handle_native_menu_action(socket, action) do
with action_atom when not is_nil(action_atom) <- shell_command_atom(action) do case BoundedAtoms.menu_action(action) do
if MapSet.member?(@local_menu_actions, action_atom) do nil -> append_output_entry(socket, "Menu", "Unsupported shell command", action, "error")
reload_shell(socket, MenuBar.execute(socket.assigns.workbench, action_atom)) action_atom -> handle_menu_action(socket, action_atom)
else
apply_shell_command(socket, action)
end
else
_other -> append_output_entry(socket, "Menu", "Unsupported shell command", action, "error")
end end
end end
defp handle_menu_action(socket, action) when is_atom(action) do
cond do
MapSet.member?(@local_menu_actions, action) ->
reload_shell(socket, MenuBar.execute(socket.assigns.workbench, action))
MapSet.member?(@socket_menu_actions, action) ->
handle_socket_menu_action(socket, action)
MapSet.member?(@runtime_menu_actions, action) ->
push_event(socket, "menu-runtime-command", %{action: Atom.to_string(action)})
shell_command?(action) ->
apply_shell_command(socket, Atom.to_string(action))
true ->
append_output_entry(socket, "Menu", "Unsupported shell command", Atom.to_string(action), "error")
end
end
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)
defp handle_socket_menu_action(socket, :quit) do
Shutdown.request_quit()
socket
end
defp handle_socket_menu_action(socket, :view_on_github) do
OS.launch_default_browser("https://github.com/rfc1437/bDS")
socket
end
defp handle_socket_menu_action(socket, :report_issue) do
OS.launch_default_browser("https://github.com/rfc1437/bDS/issues")
socket
end
defp handle_socket_menu_action(socket, :about) do
append_output_entry(
socket,
"About",
"Blogging Desktop Server",
"Version #{Application.spec(:bds, :vsn) |> to_string()}",
"info"
)
end
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
PostEditor.persist_socket(socket, post_id, :save, &reload_shell/2, &append_output_entry/5)
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
MediaEditor.persist_socket(socket, media_id, &reload_shell/2, &append_output_entry/5)
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
SettingsEditor.save_project(socket, &reload_shell/2, &append_output_entry/5)
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
MenuEditor.toolbar_action(socket, "save", &reload_shell/2, &append_output_entry/5)
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
TagsEditor.save_tag(socket, &reload_shell/2, &append_output_entry/5)
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts}}} = socket) do
CodeEntityEditor.save_script(socket, &reload_shell/2, &append_output_entry/5)
end
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates}}} = socket) do
CodeEntityEditor.save_template(socket, &reload_shell/2, &append_output_entry/5)
end
defp save_current_tab(socket), do: reload_shell(socket, socket.assigns.workbench)
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
PostEditor.persist_socket(socket, post_id, :publish, &reload_shell/2, &append_output_entry/5)
end
defp publish_current_tab(socket), do: reload_shell(socket, socket.assigns.workbench)
defp apply_shell_command(socket, action, params \\ %{}), defp apply_shell_command(socket, action, params \\ %{}),
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks()) do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())
@@ -1924,4 +1999,247 @@ defmodule BDS.Desktop.ShellLive do
|> append_output_entry(title, translated("Command completed"), details) |> append_output_entry(title, translated("Command completed"), details)
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
end 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 end

View File

@@ -271,6 +271,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
</div> </div>
<div class="sidebar-list"> <div class="sidebar-list">
<%= for item <- Map.get(section, :items, []) do %> <%= for item <- Map.get(section, :items, []) do %>
<div class="sidebar-item-row" data-item-id={item.id}>
<button <button
class={["sidebar-item", "sidebar-post-item", "post-type-post", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]} class={["sidebar-item", "sidebar-post-item", "post-type-post", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
data-testid="sidebar-open-item" data-testid="sidebar-open-item"
@@ -293,6 +294,22 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span> <span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
</span> </span>
</button> </button>
<%= if sidebar_deletable?(item.route) do %>
<button
class="sidebar-delete-button"
data-testid={sidebar_delete_testid(item.route)}
data-item-id={item.id}
type="button"
title={sidebar_delete_title(item.route)}
phx-click="confirm_sidebar_delete"
phx-value-route={item.route}
phx-value-id={item.id}
phx-value-title={item.title}
>
×
</button>
<% end %>
</div>
<% end %> <% end %>
</div> </div>
</section> </section>
@@ -310,6 +327,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
<%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
<div class="sidebar-list media-grid"> <div class="sidebar-list media-grid">
<%= for item <- Map.get(@sidebar_data, :items, []) do %> <%= for item <- Map.get(@sidebar_data, :items, []) do %>
<div class="media-item-row" data-item-id={item.id}>
<button <button
class={["media-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]} class={["media-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
data-testid="sidebar-open-item" data-testid="sidebar-open-item"
@@ -338,6 +356,22 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
<span class="media-item-size"><%= item.meta %></span> <span class="media-item-size"><%= item.meta %></span>
</span> </span>
</button> </button>
<%= if sidebar_deletable?(item.route) do %>
<button
class="sidebar-delete-button"
data-testid={sidebar_delete_testid(item.route)}
data-item-id={item.id}
type="button"
title={sidebar_delete_title(item.route)}
phx-click="confirm_sidebar_delete"
phx-value-route={item.route}
phx-value-id={item.id}
phx-value-title={item.title}
>
×
</button>
<% end %>
</div>
<% end %> <% end %>
</div> </div>
<% else %> <% else %>
@@ -353,7 +387,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
<%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %> <%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
<div class={if(template_sidebar?(@sidebar_data), do: "chat-list-items", else: "settings-nav-list")}> <div class={if(template_sidebar?(@sidebar_data), do: "chat-list-items", else: "settings-nav-list")}>
<%= for item <- 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 %>
<div <div
class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]} class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]}
data-item-id={item.id} data-item-id={item.id}
@@ -378,13 +412,15 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
</span> </span>
</button> </button>
<button <button
class="chat-item-delete" class="sidebar-delete-button"
data-testid={if(item.route == "chat", do: "sidebar-delete-chat", else: "sidebar-delete-template")} data-testid={sidebar_delete_testid(item.route)}
data-item-id={item.id} data-item-id={item.id}
type="button" type="button"
title={if(item.route == "chat", do: translated("sidebar.chat.deleteConversation"), else: translated("Delete") <> " " <> translated("Template"))} title={sidebar_delete_title(item.route)}
phx-click={if(item.route == "chat", do: "delete_sidebar_chat", else: "delete_sidebar_template")} phx-click="confirm_sidebar_delete"
phx-value-route={item.route}
phx-value-id={item.id} phx-value-id={item.id}
phx-value-title={item.title}
> >
× ×
</button> </button>
@@ -466,6 +502,24 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) 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 template_sidebar?(sidebar_data), do: Map.get(sidebar_data, :title) == "Templates"
defp group_year_month_counts(entries) do defp group_year_month_counts(entries) do

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
@moduledoc false @moduledoc false
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.{BoundedAtoms, Media, Posts} alias BDS.{AI, BoundedAtoms, ImportDefinitions, Media, Posts, Scripts, Templates}
alias BDS.Media.Media, as: MediaRecord alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.UI.Registry alias BDS.UI.Registry
@@ -21,10 +21,26 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
def tab_subtitle(tab, tab_meta) do def tab_subtitle(tab, tab_meta) do
case Map.get(tab_meta, {tab.type, tab.id}) do case Map.get(tab_meta, {tab.type, tab.id}) do
%{subtitle: subtitle} when is_binary(subtitle) and subtitle != "" -> subtitle %{subtitle: subtitle} when is_binary(subtitle) and subtitle != "" -> subtitle
_other -> "Desktop workbench content routed through the Elixir shell." _other -> default_tab_subtitle(tab)
end end
end end
def sync_tab_meta(%{tabs: tabs}, tab_meta) when is_list(tabs) and is_map(tab_meta) do
Enum.reduce(tabs, %{}, fn tab, acc ->
key = {tab.type, tab.id}
existing_meta = Map.get(tab_meta, key, %{})
synced_meta = merge_missing_meta(existing_meta, derived_tab_meta(tab))
if map_size(synced_meta) == 0 do
acc
else
Map.put(acc, key, synced_meta)
end
end)
end
def sync_tab_meta(_workbench, tab_meta) when is_map(tab_meta), do: tab_meta
def default_tab_title(%{type: type, id: id}) do def default_tab_title(%{type: type, id: id}) do
case Registry.editor_route(type) do case Registry.editor_route(type) do
%{singleton: true} -> ShellData.route_label(type) %{singleton: true} -> ShellData.route_label(type)
@@ -32,6 +48,8 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
end end
end end
defp default_tab_subtitle(_tab), do: "Desktop workbench content routed through the Elixir shell."
def tab_route_label(nil), do: translated("Dashboard") def tab_route_label(nil), do: translated("Dashboard")
def tab_route_label(%{type: type}), do: ShellData.route_label(type) def tab_route_label(%{type: type}), do: ShellData.route_label(type)
@@ -62,28 +80,28 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
def post_title(post_id) do def post_title(post_id) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
%Post{} = post -> post.title || post.slug || post.id %Post{} = post -> post_record_title(post)
_other -> "Post" _other -> "Post"
end end
end end
def post_subtitle(post_id) do def post_subtitle(post_id) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
%Post{} = post -> post.slug || "draft" %Post{} = post -> post_record_subtitle(post)
_other -> "draft" _other -> "draft"
end end
end end
def media_title(media_id) do def media_title(media_id) do
case Media.get_media(media_id) do case Media.get_media(media_id) do
%MediaRecord{} = media -> media.title || media.filename || media.id %MediaRecord{} = media -> media_record_title(media)
_other -> "Media" _other -> "Media"
end end
end end
def media_subtitle(media_id) do def media_subtitle(media_id) do
case Media.get_media(media_id) do case Media.get_media(media_id) do
%MediaRecord{} = media -> media.filename || media.mime_type || "media" %MediaRecord{} = media -> media_record_subtitle(media)
_other -> "media" _other -> "media"
end end
end end
@@ -97,5 +115,118 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
end end
end end
defp derived_tab_meta(%{type: :post, id: post_id}) do
case Posts.get_post(post_id) do
%Post{} = post -> %{title: post_record_title(post), subtitle: post_record_subtitle(post)}
_other -> %{}
end
end
defp derived_tab_meta(%{type: :media, id: media_id}) do
case Media.get_media(media_id) do
%MediaRecord{} = media ->
%{title: media_record_title(media), subtitle: media_record_subtitle(media)}
_other ->
%{}
end
end
defp derived_tab_meta(%{type: :scripts, id: script_id}) do
case Scripts.get_script(script_id) do
%{title: title, id: id} ->
%{title: blank_to_nil(title) || id, subtitle: translated("Automation helpers")}
_other ->
%{}
end
end
defp derived_tab_meta(%{type: :templates, id: template_id}) do
case Templates.get_template(template_id) do
%{title: title, id: id} ->
%{title: blank_to_nil(title) || id, subtitle: translated("Site rendering")}
_other ->
%{}
end
end
defp derived_tab_meta(%{type: :chat, id: conversation_id}) do
case AI.get_chat_conversation(conversation_id) do
conversation when is_map(conversation) ->
%{title: chat_record_title(conversation), subtitle: translated("AI conversations")}
_other ->
%{}
end
end
defp derived_tab_meta(%{type: :import, id: definition_id}) do
case ImportDefinitions.get_definition(definition_id) do
%{name: name} ->
%{
title: blank_to_nil(name) || translated("importAnalysis.untitledImport"),
subtitle: translated("importAnalysis.headerDescription")
}
_other ->
%{}
end
end
defp derived_tab_meta(%{type: :git_diff, id: "git-working-tree"}) do
%{title: translated("Working tree"), subtitle: translated("Working tree and history")}
end
defp derived_tab_meta(_tab), do: %{}
defp merge_missing_meta(existing_meta, fresh_meta) do
existing_meta
|> maybe_put_missing(:title, Map.get(fresh_meta, :title))
|> maybe_put_missing(:subtitle, Map.get(fresh_meta, :subtitle))
end
defp maybe_put_missing(meta, key, value) do
cond do
blank_to_nil(value) == nil ->
meta
existing_value_present?(Map.get(meta, key)) ->
meta
true ->
Map.put(meta, key, value)
end
end
defp existing_value_present?(value) do
if is_binary(value), do: String.trim(value) != "", else: false
end
defp post_record_title(%Post{} = post), do: blank_to_nil(post.title) || blank_to_nil(post.slug) || post.id
defp post_record_subtitle(%Post{} = post), do: Atom.to_string(post.status)
defp media_record_title(%MediaRecord{} = media) do
blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
end
defp media_record_subtitle(%MediaRecord{} = media) do
blank_to_nil(media.original_name) || blank_to_nil(media.mime_type) || "media"
end
defp chat_record_title(%{title: title, id: id}), do: blank_to_nil(title) || id
defp blank_to_nil(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current()) defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current())
end end

View File

@@ -32,6 +32,13 @@ defmodule BDS.UI.Commands do
] ]
def handle_shortcut(state, shortcut) when is_map(shortcut) do def handle_shortcut(state, shortcut) when is_map(shortcut) do
case command_for_shortcut(shortcut) do
nil -> state
command_id -> MenuBar.execute(state, command_id)
end
end
def command_for_shortcut(shortcut) when is_map(shortcut) do
key = shortcut |> BDS.MapUtils.attr(:key, "") |> String.downcase() key = shortcut |> BDS.MapUtils.attr(:key, "") |> String.downcase()
primary = primary =
@@ -42,8 +49,8 @@ defmodule BDS.UI.Commands do
alt = BDS.MapUtils.attr(shortcut, :alt, false) alt = BDS.MapUtils.attr(shortcut, :alt, false)
case Enum.find(@menu_shortcuts, &shortcut_match?(&1, key, primary, shift, alt)) do case Enum.find(@menu_shortcuts, &shortcut_match?(&1, key, primary, shift, alt)) do
%{id: command_id} -> MenuBar.execute(state, command_id) %{id: command_id} -> command_id
nil -> state nil -> nil
end end
end end

View File

@@ -6819,6 +6819,11 @@ button svg * {
flex-direction: column; flex-direction: column;
} }
.sidebar-item-row {
display: flex;
align-items: stretch;
}
.sidebar-item { .sidebar-item {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -6921,6 +6926,12 @@ button svg * {
padding: 4px; padding: 4px;
} }
.media-item-row {
display: flex;
align-items: stretch;
gap: 4px;
}
.media-item { .media-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -7004,6 +7015,12 @@ button svg * {
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
} }
.sidebar-item-row .sidebar-item,
.media-item-row .media-item {
flex: 1;
min-width: 0;
}
.sidebar-actions { .sidebar-actions {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -8197,27 +8214,38 @@ button.import-taxonomy-pill {
font-size: 12px; font-size: 12px;
} }
.chat-item-delete { .sidebar-delete-button {
background: transparent; background: transparent;
border: none; border: none;
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 16px;
line-height: 1; line-height: 1;
padding: 0 4px; padding: 0 6px;
flex-shrink: 0;
opacity: 0; opacity: 0;
transition: opacity 0.15s, color 0.15s; transition: opacity 0.15s, color 0.15s;
} }
.chat-list-item:hover .chat-item-delete, .sidebar-item-row:hover .sidebar-delete-button,
.chat-list-item.active .chat-item-delete { .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; opacity: 1;
} }
.chat-item-delete:hover { .sidebar-delete-button:hover {
color: var(--vscode-errorForeground); 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 { .settings-nav-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -159,6 +159,111 @@ document.addEventListener("DOMContentLoaded", () => {
let liquidLanguageRegistered = false; let liquidLanguageRegistered = false;
let markdownWithMacrosRegistered = false; let markdownWithMacrosRegistered = false;
let monacoThemeSignature = null; let monacoThemeSignature = null;
const monacoEditors = new Map();
const activeMonacoEditor = () => {
for (const editor of monacoEditors.values()) {
if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) {
return editor;
}
}
return null;
};
const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => {
if (!editor) {
return false;
}
const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null;
if (action && typeof action.run === "function") {
action.run();
return true;
}
if (typeof editor.trigger === "function") {
editor.trigger("bds-menu", triggerId, null);
return true;
}
return false;
};
const runDocumentCommand = (command) => {
if (typeof document.execCommand !== "function") {
return false;
}
try {
return document.execCommand(command);
} catch (_error) {
return false;
}
};
const applyAppZoom = (nextZoom) => {
const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2);
window.__bdsAppZoom = zoom;
document.documentElement.style.zoom = String(zoom);
};
const runMenuRuntimeCommand = (action) => {
const editor = activeMonacoEditor();
switch (action) {
case "undo":
return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo");
case "redo":
return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo");
case "cut":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCutAction")
: runDocumentCommand("cut");
case "copy":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction")
: runDocumentCommand("copy");
case "paste":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction")
: runDocumentCommand("paste");
case "delete":
return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete");
case "select_all":
return editor
? runMonacoEditorAction(editor, "editor.action.selectAll")
: runDocumentCommand("selectAll");
case "find":
return editor ? runMonacoEditorAction(editor, "actions.find") : false;
case "replace":
return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false;
case "reload":
case "force_reload":
window.location.reload();
return true;
case "reset_zoom":
applyAppZoom(1);
return true;
case "zoom_in":
applyAppZoom((window.__bdsAppZoom || 1) + 0.1);
return true;
case "zoom_out":
applyAppZoom((window.__bdsAppZoom || 1) - 0.1);
return true;
case "toggle_full_screen":
if (document.fullscreenElement) {
document.exitFullscreen?.();
} else {
document.documentElement.requestFullscreen?.();
}
return true;
default:
return false;
}
};
const cssVar = (name, fallback) => { const cssVar = (name, fallback) => {
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
@@ -613,6 +718,12 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}; };
this.handleEvent("menu-runtime-command", ({ action }) => {
if (action) {
runMenuRuntimeCommand(String(action));
}
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true); window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true); this.el.addEventListener("load", this.handleThumbnailLoad, true);
@@ -1090,6 +1201,8 @@ document.addEventListener("DOMContentLoaded", () => {
insertSpaces: true insertSpaces: true
}); });
monacoEditors.set(this.editorId || this.el.id, this.editor);
this.changeSubscription = this.editor.onDidChangeModelContent(() => { this.changeSubscription = this.editor.onDidChangeModelContent(() => {
if (this.isApplyingRemoteUpdate) { if (this.isApplyingRemoteUpdate) {
return; return;
@@ -1140,6 +1253,7 @@ document.addEventListener("DOMContentLoaded", () => {
destroyed() { destroyed() {
window.clearTimeout(this.syncTimer); window.clearTimeout(this.syncTimer);
this.changeSubscription?.dispose(); this.changeSubscription?.dispose();
monacoEditors.delete(this.editorId || this.el.id);
this.editor?.dispose(); this.editor?.dispose();
} }
}, },

View File

@@ -19,6 +19,25 @@ defmodule BDS.BoundedAtomsTest do
assert BoundedAtoms.shell_command("toggle_panel") == :toggle_panel assert BoundedAtoms.shell_command("toggle_panel") == :toggle_panel
end end
test "accepts implemented blog menu shell commands" do
commands = [
{"preview_post", :preview_post},
{"rebuild_database", :rebuild_database},
{"reindex_text", :reindex_text},
{"rebuild_embedding_index", :rebuild_embedding_index},
{"metadata_diff", :metadata_diff},
{"validate_translations", :validate_translations},
{"find_duplicates", :find_duplicates},
{"generate_sitemap", :generate_sitemap},
{"validate_site", :validate_site},
{"upload_site", :upload_site}
]
for {value, expected} <- commands do
assert BoundedAtoms.shell_command(value) == expected
end
end
test "falls back without creating atoms for unknown strings" do test "falls back without creating atoms for unknown strings" do
assert BoundedAtoms.sidebar_view("unknown", :posts) == :posts assert BoundedAtoms.sidebar_view("unknown", :posts) == :posts
assert BoundedAtoms.editor_route("unknown", :dashboard) == :dashboard assert BoundedAtoms.editor_route("unknown", :dashboard) == :dashboard

View File

@@ -289,6 +289,12 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='sidebar-delete-chat'][data-item-id='#{created_chat.id}']") |> element("[data-testid='sidebar-delete-chat'][data-item-id='#{created_chat.id}']")
|> render_click() |> 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 Repo.get(BDS.AI.ChatConversation, created_chat.id)
refute html =~ ~s(data-tab-id="#{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") assert html =~ ~s(data-settings-scroll-target="settings-section-ai")
end 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: "<article>{{ post.content }}</article>",
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", test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change",
%{project: project} do %{project: project} do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
@@ -611,6 +733,71 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ "Desktop workbench content routed through the Elixir shell." refute html =~ "Desktop workbench content routed through the Elixir shell."
end end
test "native metadata diff action queues the maintenance task" do
:ok = BDS.Tasks.clear_finished()
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
existing_ids = MapSet.new(Enum.map(BDS.Tasks.list_tasks(), & &1.id))
_html = render_hook(view, "native_menu_action", %{"action" => "metadata_diff"})
assert %{} = new_task!(existing_ids, "Metadata Diff")
end
test "native new post action reuses the sidebar create flow" do
count_before = Repo.aggregate(Post, :count, :id)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_hook(view, "native_menu_action", %{"action" => "new_post"})
assert Repo.aggregate(Post, :count, :id) == count_before + 1
end
test "native save action persists the active post editor", %{project: project} do
{:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Draft Shell Post",
content: "Initial body",
excerpt: "Initial excerpt"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
_html =
view
|> form("[data-testid='post-editor-form']", %{
post_editor: %{
title: "Saved Through Menu",
content: "Saved body",
excerpt: "Saved excerpt",
tags: "",
categories: "",
author: "",
language: "en",
do_not_translate: "false"
}
})
|> render_change()
_html = render_hook(view, "native_menu_action", %{"action" => "save"})
saved_post = Posts.get_post!(post.id)
assert saved_post.title == "Saved Through Menu"
assert saved_post.content == "Saved body"
assert saved_post.excerpt == "Saved excerpt"
end
test "menu editor adds a submenu, nests an entry, and saves the opml", %{ test "menu editor adds a submenu, nests an entry, and saves the opml", %{
project: project, project: project,
temp_dir: temp_dir temp_dir: temp_dir
@@ -707,6 +894,136 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(class="tab active transient") assert html =~ ~s(class="tab active transient")
end end
test "workbench session restore rehydrates chat tab titles from stored conversations" do
assert {:ok, conversation} = AI.start_chat(%{title: "Editorial Plan"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
session_payload =
Workbench.new()
|> Workbench.open_tab(:chat, conversation.id, :pin)
|> Session.serialize()
_html = render_hook(view, "restore_workbench_session", %{"session" => session_payload})
assert has_element?(
view,
".tab[data-tab-type='chat'][data-tab-id='#{conversation.id}'] .tab-title",
"Editorial Plan"
)
assert has_element?(view, ".chat-panel-title-main", "Editorial Plan")
end
test "workbench session restore rehydrates entity tab titles from backing records", %{
project: project,
temp_dir: temp_dir
} do
assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "Restored Post"})
source_path = Path.join(temp_dir, "restored-media.txt")
File.write!(source_path, "media body")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "Restored Media"
})
assert {:ok, script} =
Scripts.create_script(%{
project_id: project.id,
title: "Restored Script",
kind: :utility,
content: "print(\"ok\")",
entrypoint: "main",
enabled: true
})
assert {:ok, template} =
Templates.create_template(%{
project_id: project.id,
title: "Restored Template",
kind: :post,
content: "",
enabled: true
})
assert {:ok, definition} =
ImportDefinitions.create_definition(%{
project_id: project.id,
name: "Restored Import"
})
assert {:ok, conversation} = AI.start_chat(%{title: "Restored Chat"})
posts_dir = Path.join(temp_dir, "posts")
File.mkdir_p!(posts_dir)
git_file_path = Path.join(posts_dir, "restore.md")
File.write!(git_file_path, "Old content\n")
init_git_repo!(temp_dir, "initial")
File.write!(git_file_path, "New content\n")
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
session_payload =
Workbench.new()
|> Workbench.open_tab(:post, post.id, :pin)
|> Workbench.open_tab(:media, media.id, :pin)
|> Workbench.open_tab(:scripts, script.id, :pin)
|> Workbench.open_tab(:templates, template.id, :pin)
|> Workbench.open_tab(:import, definition.id, :pin)
|> Workbench.open_tab(:chat, conversation.id, :pin)
|> Workbench.open_tab(:git_diff, "git-working-tree", :pin)
|> Session.serialize()
_html = render_hook(view, "restore_workbench_session", %{"session" => session_payload})
assert has_element?(
view,
".tab[data-tab-type='post'][data-tab-id='#{post.id}'] .tab-title",
"Restored Post"
)
assert has_element?(
view,
".tab[data-tab-type='media'][data-tab-id='#{media.id}'] .tab-title",
"Restored Media"
)
assert has_element?(
view,
".tab[data-tab-type='scripts'][data-tab-id='#{script.id}'] .tab-title",
"Restored Script"
)
assert has_element?(
view,
".tab[data-tab-type='templates'][data-tab-id='#{template.id}'] .tab-title",
"Restored Template"
)
assert has_element?(
view,
".tab[data-tab-type='import'][data-tab-id='#{definition.id}'] .tab-title",
"Restored Import"
)
assert has_element?(
view,
".tab[data-tab-type='chat'][data-tab-id='#{conversation.id}'] .tab-title",
"Restored Chat"
)
assert has_element?(
view,
".tab[data-tab-type='git_diff'][data-tab-id='git-working-tree'] .tab-title",
"Working tree"
)
end
test "metadata diff refresh reruns after workbench session restore", %{project: project} do test "metadata diff refresh reruns after workbench session restore", %{project: project} do
:ok = BDS.Tasks.clear_finished() :ok = BDS.Tasks.clear_finished()
@@ -3162,6 +3479,12 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='sidebar-delete-template'][data-item-id='#{template.id}']") |> element("[data-testid='sidebar-delete-template'][data-item-id='#{template.id}']")
|> render_click() |> 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 assert BDS.Repo.get(BDS.Templates.Template, template.id) == nil
refute html =~ "Sidebar Template" refute html =~ "Sidebar Template"
refute html =~ ~s(data-tab-type="templates") refute html =~ ~s(data-tab-type="templates")

View File

@@ -131,6 +131,24 @@ defmodule BDS.DesktopTest do
assert menu_item(groups, :metadata_diff).shortcut == nil assert menu_item(groups, :metadata_diff).shortcut == nil
end end
test "prod forwarded menu surface is covered by the shell dispatcher except unresolved filler action" do
forwarded_actions =
BDS.Desktop.MenuBar.groups(dev_mode?: false)
|> Enum.flat_map(fn group ->
group.items
|> Enum.reject(&Map.get(&1, :separator, false))
|> Enum.map(& &1.id)
end)
|> MapSet.new()
unsupported_actions =
forwarded_actions
|> MapSet.difference(BDS.Desktop.ShellLive.supported_menu_actions())
|> Enum.sort()
assert unsupported_actions == [:fill_missing_translations]
end
test "native menu quit requests app-owned shutdown" do test "native menu quit requests app-owned shutdown" do
previous_module = Application.get_env(:bds, :desktop_shutdown_module) previous_module = Application.get_env(:bds, :desktop_shutdown_module)
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid) previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)