Compare commits
4 Commits
631ceb0521
...
24f114c24e
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f114c24e | |||
| 07fab7d1ab | |||
| c118412f56 | |||
| e0f13e325b |
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ->
|
socket
|
||||||
case Templates.delete_template(template_id) do
|
) do
|
||||||
{:ok, :deleted} ->
|
{:noreply, request_sidebar_delete(socket, route, id, Map.get(params, "title"))}
|
||||||
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
|
|
||||||
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
|
||||||
|
|||||||
@@ -271,28 +271,45 @@ 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 %>
|
||||||
<button
|
<div class="sidebar-item-row" data-item-id={item.id}>
|
||||||
class={["sidebar-item", "sidebar-post-item", "post-type-post", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
|
<button
|
||||||
data-testid="sidebar-open-item"
|
class={["sidebar-item", "sidebar-post-item", "post-type-post", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
|
||||||
data-route={item.route}
|
data-testid="sidebar-open-item"
|
||||||
data-item-id={item.id}
|
data-route={item.route}
|
||||||
data-open-title={item.title}
|
data-item-id={item.id}
|
||||||
data-open-subtitle={format_sidebar_timestamp(item.meta_timestamp)}
|
data-open-title={item.title}
|
||||||
type="button"
|
data-open-subtitle={format_sidebar_timestamp(item.meta_timestamp)}
|
||||||
phx-click="open_sidebar_item"
|
type="button"
|
||||||
phx-value-route={item.route}
|
phx-click="open_sidebar_item"
|
||||||
phx-value-id={item.id}
|
phx-value-route={item.route}
|
||||||
phx-value-title={item.title}
|
phx-value-id={item.id}
|
||||||
phx-value-subtitle={format_sidebar_timestamp(item.meta_timestamp)}
|
phx-value-title={item.title}
|
||||||
>
|
phx-value-subtitle={format_sidebar_timestamp(item.meta_timestamp)}
|
||||||
<span class="post-type-icon" title="post">●</span>
|
>
|
||||||
<span class="sidebar-item-content">
|
<span class="post-type-icon" title="post">●</span>
|
||||||
<span class="sidebar-item-title-row">
|
<span class="sidebar-item-content">
|
||||||
<span class="sidebar-item-title"><%= item.title %></span>
|
<span class="sidebar-item-title-row">
|
||||||
|
<span class="sidebar-item-title"><%= item.title %></span>
|
||||||
|
</span>
|
||||||
|
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="sidebar-item-meta"><%= format_sidebar_timestamp(item.meta_timestamp) %></span>
|
</button>
|
||||||
</span>
|
<%= if sidebar_deletable?(item.route) do %>
|
||||||
</button>
|
<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,34 +327,51 @@ 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 %>
|
||||||
<button
|
<div class="media-item-row" data-item-id={item.id}>
|
||||||
class={["media-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
|
<button
|
||||||
data-testid="sidebar-open-item"
|
class={["media-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
|
||||||
data-route={item.route}
|
data-testid="sidebar-open-item"
|
||||||
data-item-id={item.id}
|
data-route={item.route}
|
||||||
data-open-title={item.title}
|
data-item-id={item.id}
|
||||||
data-open-subtitle={item.meta}
|
data-open-title={item.title}
|
||||||
type="button"
|
data-open-subtitle={item.meta}
|
||||||
title={item.title}
|
type="button"
|
||||||
phx-click="open_sidebar_item"
|
title={item.title}
|
||||||
phx-value-route={item.route}
|
phx-click="open_sidebar_item"
|
||||||
phx-value-id={item.id}
|
phx-value-route={item.route}
|
||||||
phx-value-title={item.title}
|
phx-value-id={item.id}
|
||||||
phx-value-subtitle={item.meta}
|
phx-value-title={item.title}
|
||||||
>
|
phx-value-subtitle={item.meta}
|
||||||
<span class={media_thumbnail_class(item)}>
|
>
|
||||||
<%= if image_media?(item) do %>
|
<span class={media_thumbnail_class(item)}>
|
||||||
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
<%= if image_media?(item) do %>
|
||||||
<img class="media-thumbnail-image" src={"/media-thumbnail/#{item.id}"} alt="" loading="lazy" decoding="async" />
|
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
||||||
<% else %>
|
<img class="media-thumbnail-image" src={"/media-thumbnail/#{item.id}"} alt="" loading="lazy" decoding="async" />
|
||||||
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
<% else %>
|
||||||
<% end %>
|
<span class="media-thumbnail-fallback"><%= media_thumbnail_glyph(item.mime_type) %></span>
|
||||||
</span>
|
<% end %>
|
||||||
<span class="media-item-info">
|
</span>
|
||||||
<span class="media-item-name"><%= item.title %></span>
|
<span class="media-item-info">
|
||||||
<span class="media-item-size"><%= item.meta %></span>
|
<span class="media-item-name"><%= item.title %></span>
|
||||||
</span>
|
<span class="media-item-size"><%= item.meta %></span>
|
||||||
</button>
|
</span>
|
||||||
|
</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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
114
priv/ui/live.js
114
priv/ui/live.js
@@ -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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user