diff --git a/.vscode/settings.json b/.vscode/settings.json index 4bd8082..c046bca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,8 @@ "chat.tools.terminal.autoApprove": { "mix": true, "allium": true, - "command": true + "command": true, + "printf": true, + "git ls-tree": true } } \ No newline at end of file diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 4aa239d..7fa13eb 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive do import Phoenix.HTML alias BDS.Desktop.{FolderPicker, ShellCommands, ShellData} + alias BDS.Desktop.MenuBar, as: DesktopMenuBar alias BDS.Git alias BDS.Media.Media alias BDS.PostLinks @@ -46,6 +47,9 @@ defmodule BDS.Desktop.ShellLive do |> assign(:page_language, ShellData.ui_language()) |> assign(:client_shortcuts, Commands.client_shortcuts()) |> assign(:offline_mode, true) + |> assign(:is_mac_ui, mac_ui?()) + |> assign(:menu_groups, titlebar_menu_groups()) + |> assign(:titlebar_menu_group, nil) |> assign(:tab_meta, %{}) |> assign(:project_menu_open, false) |> assign(:sidebar_filters_by_view, %{}) @@ -337,6 +341,33 @@ defmodule BDS.Desktop.ShellLive do {:noreply, handle_native_menu_action(socket, action)} end + def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do + next_group = if socket.assigns.titlebar_menu_group == group, do: nil, else: group + {:noreply, assign(socket, :titlebar_menu_group, next_group)} + end + + def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do + socket = + if socket.assigns.titlebar_menu_group do + assign(socket, :titlebar_menu_group, group) + else + socket + end + + {:noreply, socket} + end + + def handle_event("close_titlebar_menu", _params, socket) do + {:noreply, assign(socket, :titlebar_menu_group, nil)} + end + + def handle_event("titlebar_menu_action", %{"action" => action}, socket) do + {:noreply, + socket + |> assign(:titlebar_menu_group, nil) + |> handle_native_menu_action(action)} + end + @impl true def handle_info(:refresh_task_status, socket) do task_status = BDS.Tasks.status_snapshot() @@ -395,6 +426,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:activity_buttons, activity_buttons) |> assign(:panel_tabs, ShellData.panel_tabs(workbench)) |> assign(:supported_ui_languages, ShellData.supported_ui_languages()) + |> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups()) |> assign(:current_tab, current_tab(workbench)) end @@ -1349,6 +1381,21 @@ defmodule BDS.Desktop.ShellLive do |> Enum.map_join(" ", &String.capitalize/1) end + defp titlebar_menu_groups do + DesktopMenuBar.groups(dev_mode?: Application.get_env(:bds, :dev_routes, false)) + end + + defp active_titlebar_menu_group(assigns) do + Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end) + end + + defp mac_ui? do + case Application.get_env(:bds, :shell_platform) do + nil -> match?({:unix, :darwin}, :os.type()) + platform -> match?({:unix, :darwin}, platform) + end + end + defp post_link_entries(assigns) do case assigns.current_tab do %{type: :post, id: post_id} -> diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index 792d9ab..5c50e0f 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -1,12 +1,29 @@
-
- +
+ <%= unless @is_mac_ui do %> +
+ <%= for group <- @menu_groups do %> + + <% end %> +
+ <% end %>
<%= @page_title %>
@@ -47,6 +64,36 @@
+ <%= if not @is_mac_ui do %> + <%= if group = active_titlebar_menu_group(assigns) do %> +
+ <%= for item <- group.items do %> + <%= if item.separator do %> +
+ <% else %> + + <% end %> + <% end %> +
+ <% end %> + <% end %>
diff --git a/priv/ui/app.css b/priv/ui/app.css index a5d05a0..29eb90b 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -146,6 +146,10 @@ button { background-color: var(--vscode-toolbar-hoverBackground); } +.window-titlebar-menu-button.is-active { + background-color: var(--vscode-toolbar-hoverBackground); +} + .window-titlebar-menu-button:focus, .window-titlebar-menu-button:focus-visible, .window-titlebar-action-button:focus, @@ -154,6 +158,61 @@ button { box-shadow: none; } +.window-titlebar-menu-dropdown { + position: absolute; + top: 30px; + left: 6px; + min-width: 210px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + background-color: var(--vscode-menu-background, var(--vscode-editorWidget-background)); + border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border)); + border-radius: 6px; + box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4)); + app-region: no-drag; + -webkit-app-region: no-drag; + z-index: 10; +} + +.window-titlebar-menu-item { + border: none; + background: transparent; + color: var(--vscode-menu-foreground, var(--vscode-foreground)); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + text-align: left; + border-radius: 4px; + padding: 6px 8px; + font-size: 12px; + cursor: pointer; +} + +.window-titlebar-menu-item:focus, +.window-titlebar-menu-item:focus-visible { + outline: none; + box-shadow: none; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.window-titlebar-menu-item:hover, +.window-titlebar-menu-item.is-keyboard-active { + background-color: var(--vscode-menu-selectionBackground, var(--vscode-toolbar-hoverBackground)); +} + +.window-titlebar-menu-item-accelerator { + opacity: 0.8; +} + +.window-titlebar-menu-separator { + height: 1px; + margin: 4px 2px; + background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08)); +} + .window-titlebar-drag-region { flex: 1; height: 100%; diff --git a/priv/ui/live.js b/priv/ui/live.js index 43c90f3..79593a2 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -183,6 +183,27 @@ document.addEventListener("DOMContentLoaded", () => { } }; + this.menuIsOpen = () => { + const titlebar = this.el.querySelector("[data-testid='window-titlebar']"); + return Boolean(titlebar?.dataset.openMenuGroup); + }; + + this.handleTitlebarPointerDown = (event) => { + if (!this.menuIsOpen()) { + return; + } + + if (event.target.closest("[data-testid='window-titlebar-menu-button']")) { + return; + } + + if (event.target.closest("[data-testid='window-titlebar-menu-dropdown']")) { + return; + } + + this.pushEvent("close_titlebar_menu", {}); + }; + this.handleChange = (event) => { const select = event.target.closest(".status-bar-language-select"); @@ -216,8 +237,16 @@ document.addEventListener("DOMContentLoaded", () => { }); }; + this.handleEscapeKey = (event) => { + if (event.key === "Escape" && this.menuIsOpen()) { + this.pushEvent("close_titlebar_menu", {}); + } + }; + window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.addEventListener("keydown", this.handleShortcutKeyDown, true); + window.addEventListener("keydown", this.handleEscapeKey, true); + window.addEventListener("pointerdown", this.handleTitlebarPointerDown, true); this.el.addEventListener("change", this.handleChange); }, @@ -226,6 +255,8 @@ document.addEventListener("DOMContentLoaded", () => { this.el.removeEventListener("change", this.handleChange); window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.removeEventListener("keydown", this.handleShortcutKeyDown, true); + window.removeEventListener("keydown", this.handleEscapeKey, true); + window.removeEventListener("pointerdown", this.handleTitlebarPointerDown, true); if (this.destroyOverlaySync) { this.destroyOverlaySync(); } diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index fc1f670..c6b2e56 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -25,6 +25,16 @@ defmodule BDS.Desktop.ShellLiveTest do {:ok, project} = Projects.create_project(%{name: "Shell Project", data_path: temp_dir}) {:ok, _project} = Projects.set_active_project(project.id) + original_shell_platform = Application.get_env(:bds, :shell_platform) + + on_exit(fn -> + if is_nil(original_shell_platform) do + Application.delete_env(:bds, :shell_platform) + else + Application.put_env(:bds, :shell_platform, original_shell_platform) + end + end) + %{project: project, temp_dir: temp_dir} end @@ -95,6 +105,59 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(class="tab-bar-empty") end + test "titlebar menu stays hidden on macos because the native menu owns it" do + {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + assert html =~ ~s(class="window-titlebar is-mac") + refute html =~ ~s(data-testid="window-titlebar-menu-bar") + refute html =~ ~s(data-testid="window-titlebar-menu-button") + refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") + + html = + render_hook(view, "native_menu_action", %{"action" => "edit_preferences"}) + + assert html =~ ~s(data-tab-type="settings") + assert html =~ ">Settings<" + end + + test "titlebar menu matches the old shell contract on windows and linux" do + Application.put_env(:bds, :shell_platform, {:unix, :linux}) + + {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + refute html =~ ~s(class="window-titlebar is-mac") + assert html =~ ~s(data-testid="window-titlebar-menu-bar") + assert html =~ ~s(data-testid="window-titlebar-menu-button") + assert html =~ ~s(data-menu-group="file") + assert html =~ ~s(>File<) + + html = + view + |> element("[data-testid='window-titlebar-menu-button'][data-menu-group='file']") + |> render_click() + + assert html =~ ~s(data-testid="window-titlebar-menu-dropdown") + assert html =~ ~s(data-testid="window-titlebar-menu-item") + assert html =~ ~s(data-menu-action="new_post") + assert html =~ ~s(>New Post<) + + html = + view + |> element("[data-testid='window-titlebar-menu-button'][data-menu-group='edit']") + |> render_click() + + assert html =~ ~s(data-menu-action="edit_preferences") + + html = + view + |> element("[data-testid='window-titlebar-menu-item'][data-menu-action='edit_preferences']") + |> render_click() + + assert html =~ ~s(data-tab-type="settings") + assert html =~ ">Settings<" + refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") + end + test "sidebar open supports preview and pin intents for entity tabs" do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index d504c48..6209455 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -101,7 +101,9 @@ defmodule BDS.DesktopTest do assert conn.status == 200 assert conn.resp_body =~ ~s(class="app") - assert conn.resp_body =~ ~s(class="window-titlebar") + assert conn.resp_body =~ ~s(data-testid="window-titlebar") + assert conn.resp_body =~ ~s(class="window-titlebar is-mac") + refute conn.resp_body =~ ~s(data-testid="window-titlebar-menu-bar") assert conn.resp_body =~ ~s(class="activity-bar") assert conn.resp_body =~ ~s(class="sidebar") assert conn.resp_body =~ ~s(class="status-bar")