fix: made menus more stable and verified and hooke up stuff that got lost

This commit is contained in:
2026-05-02 09:37:24 +02:00
parent 07fab7d1ab
commit 24f114c24e
8 changed files with 394 additions and 11 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

@@ -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
@@ -1833,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())

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

@@ -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

@@ -733,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

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)