From 7a4c46b0df1538370742410eb026d3eba24d31fb Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Apr 2026 16:10:21 +0200 Subject: [PATCH] feat: base app now working better --- lib/bds/desktop/menu_bar.ex | 127 ++++++++++++++++++++++----- lib/bds/ui/menu_bar.ex | 95 +++++++++++++++++++-- lib/bds/ui/shell_page.ex | 4 +- priv/ui/app.css | 26 +++++- priv/ui/app.js | 152 ++++++++++++++++++++++++++++----- test/bds/desktop_test.exs | 28 +++++- test/bds/ui/shell_test.exs | 15 ++++ test/bds/ui/workbench_test.exs | 14 ++- 8 files changed, 399 insertions(+), 62 deletions(-) diff --git a/lib/bds/desktop/menu_bar.ex b/lib/bds/desktop/menu_bar.ex index 73ce56a..6199652 100644 --- a/lib/bds/desktop/menu_bar.ex +++ b/lib/bds/desktop/menu_bar.ex @@ -2,19 +2,20 @@ defmodule BDS.Desktop.MenuBar do @moduledoc false use Desktop.Menu + alias BDS.UI.MenuBar, as: ShellMenuBar + alias Desktop.OS alias Desktop.Window def groups(opts \\ []) do - dev_mode? = Keyword.get(opts, :dev_mode?, false) - - [ - %{id: :app, label: "App", items: [%{id: :about, label: "About"}]}, - %{id: :file, label: "File", items: [%{id: :new_post, label: "New Post"}, %{id: :close_tab, label: "Close Tab"}]}, - %{id: :edit, label: "Edit", items: [%{id: :undo, label: "Undo"}, %{id: :redo, label: "Redo"}]}, - %{id: :view, label: "View", items: view_items(dev_mode?)}, - %{id: :window, label: "Window", items: [%{id: :minimize, label: "Minimize"}]}, - %{id: :help, label: "Help", items: [%{id: :documentation, label: "Documentation"}]} - ] + opts + |> ShellMenuBar.default_groups() + |> Enum.map(fn group -> + %{ + id: group.id, + label: group_label(group.id), + items: Enum.map(group.items, &normalize_item/1) + } + end) end @impl true @@ -34,7 +35,11 @@ defmodule BDS.Desktop.MenuBar do <%= for group <- @groups do %> <%= for item <- group.items do %> - {item.label} + <%= if item.separator do %> +
+ <% else %> + {item.label} + <% end %> <% end %>
<% end %> @@ -48,7 +53,18 @@ defmodule BDS.Desktop.MenuBar do {:noreply, menu} end - def handle_event(_, menu) do + def handle_event("view_on_github", menu) do + OS.launch_default_browser("https://github.com/rfc1437/bDS") + {:noreply, menu} + end + + def handle_event("report_issue", menu) do + OS.launch_default_browser("https://github.com/rfc1437/bDS/issues") + {:noreply, menu} + end + + def handle_event(command, menu) do + dispatch_shell_menu_action(command) {:noreply, menu} end @@ -57,17 +73,84 @@ defmodule BDS.Desktop.MenuBar do {:noreply, menu} end - defp view_items(dev_mode?) do - items = [ - %{id: :toggle_sidebar, label: "Toggle Sidebar"}, - %{id: :toggle_panel, label: "Toggle Panel"}, - %{id: :toggle_assistant_sidebar, label: "Toggle Assistant Sidebar"} - ] - - if dev_mode? do - items ++ [%{id: :toggle_dev_tools, label: "Toggle Dev Tools"}] + defp dispatch_shell_menu_action(command) when is_binary(command) do + with webview when not is_nil(webview) <- webview(), + payload <- Jason.encode!(%{action: command}), + script <- + "window.dispatchEvent(new CustomEvent('bds:native-menu-action', { detail: #{payload} })); true;" do + :wx.set_env(Desktop.Env.wx_env()) + :wxWebView.runScript(webview, script) else - items + _ -> :ok end end + + defp webview do + try do + Window.webview(BDS.Desktop.MainWindow.window_id()) + catch + :exit, _ -> nil + end + end + + defp normalize_item(%{separator: true}), do: %{separator: true} + + defp normalize_item(item) do + %{id: item.id, label: item_label(item.id), separator: false} + end + + defp group_label(:file), do: "File" + defp group_label(:edit), do: "Edit" + defp group_label(:view), do: "View" + defp group_label(:blog), do: "Blog" + defp group_label(:help), do: "Help" + + defp item_label(:new_post), do: "New Post" + defp item_label(:import_media), do: "Import Media" + defp item_label(:save), do: "Save" + defp item_label(:open_in_browser), do: "Open in Browser" + defp item_label(:open_data_folder), do: "Open Data Folder" + defp item_label(:close_tab), do: "Close Tab" + defp item_label(:quit), do: "Quit" + defp item_label(:undo), do: "Undo" + defp item_label(:redo), do: "Redo" + defp item_label(:cut), do: "Cut" + defp item_label(:copy), do: "Copy" + defp item_label(:paste), do: "Paste" + defp item_label(:delete), do: "Delete" + defp item_label(:select_all), do: "Select All" + defp item_label(:find), do: "Find" + defp item_label(:replace), do: "Replace" + defp item_label(:edit_preferences), do: "Preferences" + defp item_label(:view_posts), do: "Posts" + defp item_label(:view_media), do: "Media" + defp item_label(:toggle_sidebar), do: "Toggle Sidebar" + defp item_label(:toggle_panel), do: "Toggle Panel" + defp item_label(:toggle_assistant_sidebar), do: "Toggle Assistant Sidebar" + defp item_label(:toggle_dev_tools), do: "Toggle Dev Tools" + defp item_label(:reload), do: "Reload" + defp item_label(:force_reload), do: "Force Reload" + defp item_label(:reset_zoom), do: "Reset Zoom" + defp item_label(:zoom_in), do: "Zoom In" + defp item_label(:zoom_out), do: "Zoom Out" + defp item_label(:toggle_full_screen), do: "Toggle Full Screen" + defp item_label(:publish_selected), do: "Publish Selected" + defp item_label(:preview_post), do: "Preview Post" + defp item_label(:edit_menu), do: "Edit Menu" + defp item_label(:rebuild_database), do: "Rebuild Database" + defp item_label(:reindex_text), do: "Reindex Text" + defp item_label(:rebuild_embedding_index), do: "Rebuild Embedding Index" + defp item_label(:metadata_diff), do: "Metadata Diff" + defp item_label(:regenerate_calendar), do: "Regenerate Calendar" + defp item_label(:validate_translations), do: "Validate Translations" + defp item_label(:fill_missing_translations), do: "Fill Missing Translations" + defp item_label(:find_duplicates), do: "Find Duplicate Posts" + defp item_label(:generate_sitemap), do: "Generate Sitemap" + defp item_label(:validate_site), do: "Validate Site" + defp item_label(:upload_site), do: "Upload Site" + defp item_label(:about), do: "About" + defp item_label(:documentation), do: "Documentation" + defp item_label(:api_documentation), do: "API Documentation" + defp item_label(:view_on_github), do: "View on GitHub" + defp item_label(:report_issue), do: "Report Issue" end diff --git a/lib/bds/ui/menu_bar.ex b/lib/bds/ui/menu_bar.ex index 3d2f259..fa11dc6 100644 --- a/lib/bds/ui/menu_bar.ex +++ b/lib/bds/ui/menu_bar.ex @@ -7,12 +7,75 @@ defmodule BDS.UI.MenuBar do dev_mode? = Keyword.get(opts, :dev_mode?, false) [ - %{id: :app, items: [%{id: :about}, %{id: :settings}]}, - %{id: :file, items: [%{id: :new_post}, %{id: :new_page}, %{id: :close_tab}]}, - %{id: :edit, items: [%{id: :undo}, %{id: :redo}]}, + %{ + id: :file, + items: [ + %{id: :new_post}, + %{id: :import_media}, + %{separator: true}, + %{id: :save}, + %{separator: true}, + %{id: :open_in_browser}, + %{separator: true}, + %{id: :open_data_folder}, + %{separator: true}, + %{id: :close_tab}, + %{id: :quit} + ] + }, + %{ + id: :edit, + items: [ + %{id: :undo}, + %{id: :redo}, + %{separator: true}, + %{id: :cut}, + %{id: :copy}, + %{id: :paste}, + %{id: :delete}, + %{separator: true}, + %{id: :select_all}, + %{separator: true}, + %{id: :find}, + %{id: :replace}, + %{id: :edit_preferences} + ] + }, %{id: :view, items: view_items(dev_mode?)}, - %{id: :window, items: [%{id: :minimize}, %{id: :zoom}]}, - %{id: :help, items: [%{id: :documentation}, %{id: :api_documentation}]} + %{ + id: :blog, + items: [ + %{id: :publish_selected}, + %{separator: true}, + %{id: :preview_post}, + %{id: :edit_menu}, + %{separator: true}, + %{id: :rebuild_database}, + %{id: :reindex_text}, + %{id: :rebuild_embedding_index}, + %{separator: true}, + %{id: :metadata_diff}, + %{id: :regenerate_calendar}, + %{id: :validate_translations}, + %{id: :fill_missing_translations}, + %{id: :find_duplicates}, + %{separator: true}, + %{id: :generate_sitemap}, + %{id: :validate_site}, + %{id: :upload_site} + ] + }, + %{ + id: :help, + items: [ + %{id: :about}, + %{id: :documentation}, + %{id: :api_documentation}, + %{separator: true}, + %{id: :view_on_github}, + %{id: :report_issue} + ] + } ] end @@ -30,12 +93,28 @@ defmodule BDS.UI.MenuBar do def execute(state, _command_id), do: state defp view_items(dev_mode?) do - base = [ + items = [ + %{id: :view_posts}, + %{id: :view_media}, %{id: :toggle_sidebar}, %{id: :toggle_panel}, %{id: :toggle_assistant_sidebar} ] - if dev_mode?, do: base ++ [%{id: :toggle_dev_tools}], else: base + items = + if dev_mode?, do: items ++ [%{id: :toggle_dev_tools}], else: items + + items ++ + [ + %{separator: true}, + %{id: :reload}, + %{id: :force_reload}, + %{separator: true}, + %{id: :reset_zoom}, + %{id: :zoom_in}, + %{id: :zoom_out}, + %{separator: true}, + %{id: :toggle_full_screen} + ] end -end \ No newline at end of file +end diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex index e822e8a..e1bd1d2 100644 --- a/lib/bds/ui/shell_page.ex +++ b/lib/bds/ui/shell_page.ex @@ -104,7 +104,9 @@ defmodule BDS.UI.ShellPage do id: Atom.to_string(group.id), label: humanize(group.id), items: - Enum.map(group.items, fn item -> + group.items + |> Enum.reject(&Map.get(&1, :separator, false)) + |> Enum.map(fn item -> %{id: Atom.to_string(item.id), label: humanize(item.id)} end) } diff --git a/priv/ui/app.css b/priv/ui/app.css index b422624..5f4f442 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -102,6 +102,10 @@ button { z-index: 2; } +.window-titlebar-menu-bar.is-hidden { + display: none; +} + .window-titlebar-menu-button { height: 24px; border: none; @@ -694,10 +698,30 @@ button { .status-bar-right { display: flex; align-items: center; - gap: 10px; + gap: 4px; + flex-shrink: 0; +} + +.status-bar-left { + flex-shrink: 1; min-width: 0; } +.status-bar-item { + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + height: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.status-bar-item:hover { + background-color: rgba(255, 255, 255, 0.1); +} + .status-bar-item.brand { font-weight: 600; } diff --git a/priv/ui/app.js b/priv/ui/app.js index 542c202..f9e4382 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -8,11 +8,13 @@ if (!root || !bootstrapNode) { const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; const bootstrap = JSON.parse(bootstrapNode.textContent); +const isMac = typeof navigator !== "undefined" && navigator.platform.toLowerCase().includes("mac"); const state = { session: hydrateSession(clone(bootstrap.session)), tabMeta: {}, }; +bindNativeMenuBridge(); render(); function render() { @@ -32,8 +34,10 @@ function render() { } function renderTitlebar() { + const menuBarClass = isMac ? "window-titlebar-menu-bar is-hidden" : "window-titlebar-menu-bar"; + root.querySelector(".window-titlebar").innerHTML = ` -
+
${bootstrap.menu_groups .map((group) => ``) .join("")} @@ -330,28 +334,6 @@ function bindEvents() { button.onclick = () => { const next = button.dataset.activity; - bindResizeHandle("sidebar", { - key: SIDEBAR_STORAGE_KEY, - min: 200, - max: 500, - get: () => state.session.sidebar_width, - set: (value) => { - state.session.sidebar_width = value; - state.session.sidebar_visible = true; - }, - }); - - bindResizeHandle("assistant", { - key: ASSISTANT_STORAGE_KEY, - min: 280, - max: 640, - get: () => state.session.assistant_sidebar_width, - set: (value) => { - state.session.assistant_sidebar_width = value; - state.session.assistant_sidebar_visible = true; - }, - invert: true, - }); if (state.session.active_view === next && state.session.sidebar_visible) { state.session.sidebar_visible = false; } else { @@ -386,6 +368,130 @@ function bindEvents() { render(); }; }); + + bindResizeHandle("sidebar", { + key: SIDEBAR_STORAGE_KEY, + min: 200, + max: 500, + get: () => state.session.sidebar_width, + set: (value) => { + state.session.sidebar_width = value; + state.session.sidebar_visible = true; + }, + }); + + bindResizeHandle("assistant", { + key: ASSISTANT_STORAGE_KEY, + min: 280, + max: 640, + get: () => state.session.assistant_sidebar_width, + set: (value) => { + state.session.assistant_sidebar_width = value; + state.session.assistant_sidebar_visible = true; + }, + invert: true, + }); +} + +function bindNativeMenuBridge() { + if (window.__BDS_NATIVE_MENU_BRIDGE__) { + return; + } + + window.__BDS_NATIVE_MENU_BRIDGE__ = true; + window.addEventListener("bds:native-menu-action", (event) => { + handleNativeMenuAction(event.detail?.action); + }); +} + +function handleNativeMenuAction(action) { + if (!action) { + return; + } + + switch (action) { + case "toggle_sidebar": + state.session.sidebar_visible = !state.session.sidebar_visible; + persistSessionWidths(); + break; + case "toggle_panel": + state.session.panel.visible = !state.session.panel.visible; + break; + case "toggle_assistant_sidebar": + state.session.assistant_sidebar_visible = !state.session.assistant_sidebar_visible; + persistSessionWidths(); + break; + case "view_posts": + state.session.active_view = "posts"; + state.session.sidebar_visible = true; + break; + case "view_media": + state.session.active_view = "media"; + state.session.sidebar_visible = true; + break; + case "close_tab": + closeActiveTab(); + break; + case "edit_preferences": + openSingletonTab("settings"); + break; + case "edit_menu": + openSingletonTab("menu_editor"); + break; + case "metadata_diff": + openSingletonTab("metadata_diff"); + break; + case "documentation": + openSingletonTab("documentation"); + break; + case "api_documentation": + openSingletonTab("api_documentation"); + break; + case "validate_site": + openSingletonTab("site_validation"); + break; + case "validate_translations": + openSingletonTab("translation_validation"); + break; + case "find_duplicates": + openSingletonTab("find_duplicates"); + break; + default: + return; + } + + render(); +} + +function openSingletonTab(type) { + openTab(type, type, routeLabel(type), false); +} + +function closeActiveTab() { + const active = currentTabRef(); + if (!active) { + return; + } + + const index = state.session.tabs.findIndex((tab) => tab.type === active.type && tab.id === active.id); + if (index < 0) { + return; + } + + state.session.tabs.splice(index, 1); + + if (state.session.tabs.length === 0) { + state.session.active_tab = null; + return; + } + + if (index < state.session.tabs.length) { + const next = state.session.tabs[index]; + state.session.active_tab = { type: next.type, id: next.id }; + } else { + const next = state.session.tabs[state.session.tabs.length - 1]; + state.session.active_tab = { type: next.type, id: next.id }; + } } function openTab(type, id, title, transient) { diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index cc2ca65..9601a47 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -35,13 +35,33 @@ defmodule BDS.DesktopTest do test "desktop menu bar exposes the native menu groups for the shell window" do groups = BDS.Desktop.MenuBar.groups(dev_mode?: false) + item_ids = fn items -> + items + |> Enum.reject(&Map.get(&1, :separator, false)) + |> Enum.map(& &1.id) + end - assert Enum.map(groups, & &1.id) == [:app, :file, :edit, :view, :window, :help] + assert Enum.map(groups, & &1.id) == [:file, :edit, :view, :blog, :help] view_group = Enum.find(groups, &(&1.id == :view)) - assert :toggle_sidebar in Enum.map(view_group.items, & &1.id) - assert :toggle_panel in Enum.map(view_group.items, & &1.id) - assert :toggle_assistant_sidebar in Enum.map(view_group.items, & &1.id) + assert :toggle_sidebar in item_ids.(view_group.items) + assert :toggle_panel in item_ids.(view_group.items) + assert :toggle_assistant_sidebar in item_ids.(view_group.items) + + blog_group = Enum.find(groups, &(&1.id == :blog)) + blog_actions = item_ids.(blog_group.items) + + assert :metadata_diff in blog_actions + assert :edit_menu in blog_actions + assert :rebuild_database in blog_actions + assert :find_duplicates in blog_actions + assert :validate_site in blog_actions + + help_group = Enum.find(groups, &(&1.id == :help)) + help_actions = item_ids.(help_group.items) + + assert :documentation in help_actions + assert :api_documentation in help_actions end test "desktop shell html follows the old app frame regions and references bundled assets" do diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 46aa86b..348bf54 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -124,4 +124,19 @@ defmodule BDS.UI.ShellTest do assert js =~ "activity-bar-top" assert js =~ "activity-bar-bottom" end + + test "static shell bundle hides the fake titlebar menu on macOS and keeps old status-bar alignment rules" do + css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") + js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js") + + assert js =~ "navigator.platform" + assert js =~ "isMac" + assert js =~ "window-titlebar-menu-bar is-hidden" + + assert css =~ ".window-titlebar-menu-bar.is-hidden" + assert css =~ ".status-bar-left," + assert css =~ "gap: 4px" + assert css =~ "padding: 0 8px" + assert css =~ "height: 100%" + end end diff --git a/test/bds/ui/workbench_test.exs b/test/bds/ui/workbench_test.exs index 91dc93a..4b57894 100644 --- a/test/bds/ui/workbench_test.exs +++ b/test/bds/ui/workbench_test.exs @@ -161,20 +161,28 @@ defmodule BDS.UI.WorkbenchTest do test "menu commands expose generic shell controls through a shared command model" do state = Workbench.new(sidebar_visible: false, panel_visible: false) groups = MenuBar.default_groups(dev_mode?: false) + item_ids = fn items -> + items + |> Enum.reject(&Map.get(&1, :separator, false)) + |> Enum.map(& &1.id) + end - assert Enum.map(groups, & &1.id) == [:app, :file, :edit, :view, :window, :help] + assert Enum.map(groups, & &1.id) == [:file, :edit, :view, :blog, :help] view_group = Enum.find(groups, &(&1.id == :view)) - command_ids = Enum.map(view_group.items, & &1.id) + command_ids = item_ids.(view_group.items) assert :toggle_sidebar in command_ids assert :toggle_panel in command_ids refute :toggle_dev_tools in command_ids + blog_group = Enum.find(groups, &(&1.id == :blog)) + assert :metadata_diff in item_ids.(blog_group.items) + state = MenuBar.execute(state, :toggle_sidebar) state = MenuBar.execute(state, :toggle_panel) assert state.sidebar_visible == true assert state.panel.visible == true end -end \ No newline at end of file +end