feat: base app now working better

This commit is contained in:
2026-04-24 16:10:21 +02:00
parent 906bad6aa4
commit 7a4c46b0df
8 changed files with 399 additions and 62 deletions

View File

@@ -2,19 +2,20 @@ defmodule BDS.Desktop.MenuBar do
@moduledoc false @moduledoc false
use Desktop.Menu use Desktop.Menu
alias BDS.UI.MenuBar, as: ShellMenuBar
alias Desktop.OS
alias Desktop.Window alias Desktop.Window
def groups(opts \\ []) do def groups(opts \\ []) do
dev_mode? = Keyword.get(opts, :dev_mode?, false) opts
|> ShellMenuBar.default_groups()
[ |> Enum.map(fn group ->
%{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: group.id,
%{id: :edit, label: "Edit", items: [%{id: :undo, label: "Undo"}, %{id: :redo, label: "Redo"}]}, label: group_label(group.id),
%{id: :view, label: "View", items: view_items(dev_mode?)}, items: Enum.map(group.items, &normalize_item/1)
%{id: :window, label: "Window", items: [%{id: :minimize, label: "Minimize"}]}, }
%{id: :help, label: "Help", items: [%{id: :documentation, label: "Documentation"}]} end)
]
end end
@impl true @impl true
@@ -34,8 +35,12 @@ defmodule BDS.Desktop.MenuBar do
<%= for group <- @groups do %> <%= for group <- @groups do %>
<menu label={group.label}> <menu label={group.label}>
<%= for item <- group.items do %> <%= for item <- group.items do %>
<%= if item.separator do %>
<hr />
<% else %>
<item onclick={Atom.to_string(item.id)}>{item.label}</item> <item onclick={Atom.to_string(item.id)}>{item.label}</item>
<% end %> <% end %>
<% end %>
</menu> </menu>
<% end %> <% end %>
</menubar> </menubar>
@@ -48,7 +53,18 @@ defmodule BDS.Desktop.MenuBar do
{:noreply, menu} {:noreply, menu}
end 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} {:noreply, menu}
end end
@@ -57,17 +73,84 @@ defmodule BDS.Desktop.MenuBar do
{:noreply, menu} {:noreply, menu}
end end
defp view_items(dev_mode?) do defp dispatch_shell_menu_action(command) when is_binary(command) do
items = [ with webview when not is_nil(webview) <- webview(),
%{id: :toggle_sidebar, label: "Toggle Sidebar"}, payload <- Jason.encode!(%{action: command}),
%{id: :toggle_panel, label: "Toggle Panel"}, script <-
%{id: :toggle_assistant_sidebar, label: "Toggle Assistant Sidebar"} "window.dispatchEvent(new CustomEvent('bds:native-menu-action', { detail: #{payload} })); true;" do
] :wx.set_env(Desktop.Env.wx_env())
:wxWebView.runScript(webview, script)
if dev_mode? do
items ++ [%{id: :toggle_dev_tools, label: "Toggle Dev Tools"}]
else else
items _ -> :ok
end end
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 end

View File

@@ -7,12 +7,75 @@ defmodule BDS.UI.MenuBar do
dev_mode? = Keyword.get(opts, :dev_mode?, false) 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: :file,
%{id: :edit, items: [%{id: :undo}, %{id: :redo}]}, 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: :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 end
@@ -30,12 +93,28 @@ defmodule BDS.UI.MenuBar do
def execute(state, _command_id), do: state def execute(state, _command_id), do: state
defp view_items(dev_mode?) do defp view_items(dev_mode?) do
base = [ items = [
%{id: :view_posts},
%{id: :view_media},
%{id: :toggle_sidebar}, %{id: :toggle_sidebar},
%{id: :toggle_panel}, %{id: :toggle_panel},
%{id: :toggle_assistant_sidebar} %{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
end end

View File

@@ -104,7 +104,9 @@ defmodule BDS.UI.ShellPage do
id: Atom.to_string(group.id), id: Atom.to_string(group.id),
label: humanize(group.id), label: humanize(group.id),
items: 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)} %{id: Atom.to_string(item.id), label: humanize(item.id)}
end) end)
} }

View File

@@ -102,6 +102,10 @@ button {
z-index: 2; z-index: 2;
} }
.window-titlebar-menu-bar.is-hidden {
display: none;
}
.window-titlebar-menu-button { .window-titlebar-menu-button {
height: 24px; height: 24px;
border: none; border: none;
@@ -694,10 +698,30 @@ button {
.status-bar-right { .status-bar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 4px;
flex-shrink: 0;
}
.status-bar-left {
flex-shrink: 1;
min-width: 0; 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 { .status-bar-item.brand {
font-weight: 600; font-weight: 600;
} }

View File

@@ -8,11 +8,13 @@ if (!root || !bootstrapNode) {
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
const bootstrap = JSON.parse(bootstrapNode.textContent); const bootstrap = JSON.parse(bootstrapNode.textContent);
const isMac = typeof navigator !== "undefined" && navigator.platform.toLowerCase().includes("mac");
const state = { const state = {
session: hydrateSession(clone(bootstrap.session)), session: hydrateSession(clone(bootstrap.session)),
tabMeta: {}, tabMeta: {},
}; };
bindNativeMenuBridge();
render(); render();
function render() { function render() {
@@ -32,8 +34,10 @@ function render() {
} }
function renderTitlebar() { function renderTitlebar() {
const menuBarClass = isMac ? "window-titlebar-menu-bar is-hidden" : "window-titlebar-menu-bar";
root.querySelector(".window-titlebar").innerHTML = ` root.querySelector(".window-titlebar").innerHTML = `
<div class="window-titlebar-menu-bar"> <div class="${menuBarClass}">
${bootstrap.menu_groups ${bootstrap.menu_groups
.map((group) => `<button class="window-titlebar-menu-button" type="button">${escapeHtml(group.label)}</button>`) .map((group) => `<button class="window-titlebar-menu-button" type="button">${escapeHtml(group.label)}</button>`)
.join("")} .join("")}
@@ -330,28 +334,6 @@ function bindEvents() {
button.onclick = () => { button.onclick = () => {
const next = button.dataset.activity; 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) { if (state.session.active_view === next && state.session.sidebar_visible) {
state.session.sidebar_visible = false; state.session.sidebar_visible = false;
} else { } else {
@@ -386,6 +368,130 @@ function bindEvents() {
render(); 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) { function openTab(type, id, title, transient) {

View File

@@ -35,13 +35,33 @@ defmodule BDS.DesktopTest do
test "desktop menu bar exposes the native menu groups for the shell window" do test "desktop menu bar exposes the native menu groups for the shell window" do
groups = BDS.Desktop.MenuBar.groups(dev_mode?: false) 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)) view_group = Enum.find(groups, &(&1.id == :view))
assert :toggle_sidebar in Enum.map(view_group.items, & &1.id) assert :toggle_sidebar in item_ids.(view_group.items)
assert :toggle_panel in Enum.map(view_group.items, & &1.id) assert :toggle_panel in item_ids.(view_group.items)
assert :toggle_assistant_sidebar in Enum.map(view_group.items, & &1.id) 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 end
test "desktop shell html follows the old app frame regions and references bundled assets" do test "desktop shell html follows the old app frame regions and references bundled assets" do

View File

@@ -124,4 +124,19 @@ defmodule BDS.UI.ShellTest do
assert js =~ "activity-bar-top" assert js =~ "activity-bar-top"
assert js =~ "activity-bar-bottom" assert js =~ "activity-bar-bottom"
end 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 end

View File

@@ -161,16 +161,24 @@ defmodule BDS.UI.WorkbenchTest do
test "menu commands expose generic shell controls through a shared command model" do test "menu commands expose generic shell controls through a shared command model" do
state = Workbench.new(sidebar_visible: false, panel_visible: false) state = Workbench.new(sidebar_visible: false, panel_visible: false)
groups = MenuBar.default_groups(dev_mode?: 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)) 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_sidebar in command_ids
assert :toggle_panel in command_ids assert :toggle_panel in command_ids
refute :toggle_dev_tools 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_sidebar)
state = MenuBar.execute(state, :toggle_panel) state = MenuBar.execute(state, :toggle_panel)