diff --git a/lib/bds/bounded_atoms.ex b/lib/bds/bounded_atoms.ex index 4c0b049..0728e1a 100644 --- a/lib/bds/bounded_atoms.ex +++ b/lib/bds/bounded_atoms.ex @@ -2,6 +2,7 @@ defmodule BDS.BoundedAtoms do @moduledoc false alias BDS.UI.Registry + alias BDS.UI.MenuBar @panel_tabs [:tasks, :output, :post_links, :git_log] @post_statuses [:draft, :published, :archived] @@ -37,11 +38,31 @@ defmodule BDS.BoundedAtoms do :view_posts, :view_media, :edit_preferences, + :open_in_browser, + :open_data_folder, + :preview_post, :edit_menu, + :rebuild_database, + :reindex_text, + :rebuild_embedding_index, + :metadata_diff, + :regenerate_calendar, + :validate_translations, + :find_duplicates, + :generate_sitemap, + :validate_site, + :upload_site, :documentation, :api_documentation, :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) @@ -70,6 +91,7 @@ defmodule BDS.BoundedAtoms do 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 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 Enum.find(allowed, fallback, &(Atom.to_string(&1) == value)) diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex index ae213af..142423f 100644 --- a/lib/bds/desktop/shell_commands.ex +++ b/lib/bds/desktop/shell_commands.ex @@ -279,6 +279,19 @@ defmodule BDS.Desktop.ShellCommands do 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 items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, [])) direction = BDS.MapUtils.attr(params, :direction) diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index cd731ac..8e55464 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -56,6 +56,8 @@ defmodule BDS.Desktop.ShellLive do alias BDS.Projects alias BDS.Templates alias BDS.UI.{Commands, MenuBar, Session, Workbench} + alias Desktop.OS + alias BDS.Desktop.Shutdown @refresh_interval 1_500 @output_entry_limit 20 @@ -71,6 +73,44 @@ defmodule BDS.Desktop.ShellLive do :api_documentation, :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/*") @@ -392,7 +432,10 @@ defmodule BDS.Desktop.ShellLive do if Layout.ignore_shortcut?(params) do {:noreply, socket} 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 @@ -1833,17 +1876,99 @@ defmodule BDS.Desktop.ShellLive do end defp handle_native_menu_action(socket, action) do - with action_atom when not is_nil(action_atom) <- shell_command_atom(action) do - if MapSet.member?(@local_menu_actions, action_atom) do - reload_shell(socket, MenuBar.execute(socket.assigns.workbench, action_atom)) - else - apply_shell_command(socket, action) - end - else - _other -> append_output_entry(socket, "Menu", "Unsupported shell command", action, "error") + case BoundedAtoms.menu_action(action) do + nil -> append_output_entry(socket, "Menu", "Unsupported shell command", action, "error") + action_atom -> handle_menu_action(socket, action_atom) 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 \\ %{}), do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks()) diff --git a/lib/bds/ui/commands.ex b/lib/bds/ui/commands.ex index 6bc46cb..f0e6a56 100644 --- a/lib/bds/ui/commands.ex +++ b/lib/bds/ui/commands.ex @@ -32,6 +32,13 @@ defmodule BDS.UI.Commands 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() primary = @@ -42,8 +49,8 @@ defmodule BDS.UI.Commands do alt = BDS.MapUtils.attr(shortcut, :alt, false) case Enum.find(@menu_shortcuts, &shortcut_match?(&1, key, primary, shift, alt)) do - %{id: command_id} -> MenuBar.execute(state, command_id) - nil -> state + %{id: command_id} -> command_id + nil -> nil end end diff --git a/priv/ui/live.js b/priv/ui/live.js index aa6e79a..607c176 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -159,6 +159,111 @@ document.addEventListener("DOMContentLoaded", () => { let liquidLanguageRegistered = false; let markdownWithMacrosRegistered = false; 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 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("keydown", this.handleShortcutKeyDown, true); this.el.addEventListener("load", this.handleThumbnailLoad, true); @@ -1090,6 +1201,8 @@ document.addEventListener("DOMContentLoaded", () => { insertSpaces: true }); + monacoEditors.set(this.editorId || this.el.id, this.editor); + this.changeSubscription = this.editor.onDidChangeModelContent(() => { if (this.isApplyingRemoteUpdate) { return; @@ -1140,6 +1253,7 @@ document.addEventListener("DOMContentLoaded", () => { destroyed() { window.clearTimeout(this.syncTimer); this.changeSubscription?.dispose(); + monacoEditors.delete(this.editorId || this.el.id); this.editor?.dispose(); } }, diff --git a/test/bds/bounded_atoms_test.exs b/test/bds/bounded_atoms_test.exs index e5e49c8..f4992b0 100644 --- a/test/bds/bounded_atoms_test.exs +++ b/test/bds/bounded_atoms_test.exs @@ -19,6 +19,25 @@ defmodule BDS.BoundedAtomsTest do assert BoundedAtoms.shell_command("toggle_panel") == :toggle_panel 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 assert BoundedAtoms.sidebar_view("unknown", :posts) == :posts assert BoundedAtoms.editor_route("unknown", :dashboard) == :dashboard diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 7091297..7fad2c9 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -733,6 +733,71 @@ defmodule BDS.Desktop.ShellLiveTest do refute html =~ "Desktop workbench content routed through the Elixir shell." 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", %{ project: project, temp_dir: temp_dir diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index 64cf90d..cb15aa9 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -131,6 +131,24 @@ defmodule BDS.DesktopTest do assert menu_item(groups, :metadata_diff).shortcut == nil 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 previous_module = Application.get_env(:bds, :desktop_shutdown_module) previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)