fix: more stuff moved into liveview

This commit is contained in:
2026-04-26 11:36:53 +02:00
parent ad9d8263ec
commit 0376dbf0c3
7 changed files with 347 additions and 52 deletions

View File

@@ -52,6 +52,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:is_mac_ui, mac_ui?()) |> assign(:is_mac_ui, mac_ui?())
|> assign(:menu_groups, titlebar_menu_groups()) |> assign(:menu_groups, titlebar_menu_groups())
|> assign(:titlebar_menu_group, nil) |> assign(:titlebar_menu_group, nil)
|> assign(:titlebar_menu_item_index, nil)
|> assign(:tab_meta, %{}) |> assign(:tab_meta, %{})
|> assign(:project_menu_open, false) |> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filters_by_view, %{})
@@ -362,15 +363,22 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, handle_native_menu_action(socket, action)} {:noreply, handle_native_menu_action(socket, action)}
end end
def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do
{:noreply, handle_titlebar_menu_keydown(socket, key)}
end
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
next_group = if socket.assigns.titlebar_menu_group == group, do: nil, else: group {:noreply,
{:noreply, assign(socket, :titlebar_menu_group, next_group)} if(socket.assigns.titlebar_menu_group == group,
do: close_titlebar_menu(socket),
else: open_titlebar_menu(socket, group)
)}
end end
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
socket = socket =
if socket.assigns.titlebar_menu_group do if socket.assigns.titlebar_menu_group do
assign(socket, :titlebar_menu_group, group) open_titlebar_menu(socket, group)
else else
socket socket
end end
@@ -379,13 +387,13 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("close_titlebar_menu", _params, socket) do def handle_event("close_titlebar_menu", _params, socket) do
{:noreply, assign(socket, :titlebar_menu_group, nil)} {:noreply, close_titlebar_menu(socket)}
end end
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
{:noreply, {:noreply,
socket socket
|> assign(:titlebar_menu_group, nil) |> close_titlebar_menu()
|> handle_native_menu_action(action)} |> handle_native_menu_action(action)}
end end
@@ -449,6 +457,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:panel_tabs, ShellData.panel_tabs(workbench)) |> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|> assign(:supported_ui_languages, ShellData.supported_ui_languages()) |> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups()) |> assign(:menu_groups, socket.assigns[:menu_groups] || titlebar_menu_groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> assign(:current_tab, current_tab(workbench)) |> assign(:current_tab, current_tab(workbench))
end end
@@ -1407,10 +1416,149 @@ defmodule BDS.Desktop.ShellLive do
DesktopMenuBar.groups(dev_mode?: Application.get_env(:bds, :dev_routes, false)) DesktopMenuBar.groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
end end
defp titlebar_menu_dropdown_items(group) do
group.items
|> Enum.map_reduce(0, fn item, keyboard_index ->
if Map.get(item, :separator, false) do
{%{separator: true}, keyboard_index}
else
{Map.put(item, :keyboard_index, keyboard_index), keyboard_index + 1}
end
end)
|> elem(0)
end
defp active_titlebar_menu_group(assigns) do defp active_titlebar_menu_group(assigns) do
Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end) Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end)
end end
defp active_titlebar_menu_items(assigns) do
assigns
|> active_titlebar_menu_group()
|> case do
nil -> []
group -> Enum.reject(group.items, &Map.get(&1, :separator, false))
end
end
defp open_titlebar_menu(socket, group) do
socket
|> assign(:titlebar_menu_group, group)
|> assign(:titlebar_menu_item_index, nil)
end
defp close_titlebar_menu(socket) do
socket
|> assign(:titlebar_menu_group, nil)
|> assign(:titlebar_menu_item_index, nil)
end
defp handle_titlebar_menu_keydown(socket, key) do
if socket.assigns.titlebar_menu_group do
case key do
"Escape" ->
close_titlebar_menu(socket)
"ArrowRight" ->
rotate_titlebar_menu_group(socket, 1)
"ArrowLeft" ->
rotate_titlebar_menu_group(socket, -1)
"ArrowDown" ->
advance_titlebar_menu_item_index(socket, 1)
"ArrowUp" ->
advance_titlebar_menu_item_index(socket, -1)
"Home" ->
set_first_titlebar_menu_item_index(socket)
"End" ->
set_last_titlebar_menu_item_index(socket)
"Enter" ->
invoke_active_titlebar_menu_item(socket)
" " ->
invoke_active_titlebar_menu_item(socket)
_other ->
socket
end
else
socket
end
end
defp rotate_titlebar_menu_group(socket, offset) do
groups = socket.assigns.menu_groups || []
current_group = socket.assigns.titlebar_menu_group
current_index = Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end)
if is_nil(current_index) or groups == [] do
socket
else
next_index = rem(current_index + offset + length(groups), length(groups))
next_group = Enum.at(groups, next_index)
open_titlebar_menu(socket, Atom.to_string(next_group.id))
end
end
defp advance_titlebar_menu_item_index(socket, offset) do
items = active_titlebar_menu_items(socket.assigns)
current_index = socket.assigns[:titlebar_menu_item_index]
cond do
items == [] ->
socket
current_index == nil and offset > 0 ->
assign(socket, :titlebar_menu_item_index, 0)
current_index == nil and offset < 0 ->
assign(socket, :titlebar_menu_item_index, length(items) - 1)
true ->
next_index = rem(current_index + offset + length(items), length(items))
assign(socket, :titlebar_menu_item_index, next_index)
end
end
defp set_last_titlebar_menu_item_index(socket) do
items = active_titlebar_menu_items(socket.assigns)
if items == [] do
socket
else
assign(socket, :titlebar_menu_item_index, length(items) - 1)
end
end
defp set_first_titlebar_menu_item_index(socket) do
items = active_titlebar_menu_items(socket.assigns)
if items == [] do
socket
else
assign(socket, :titlebar_menu_item_index, 0)
end
end
defp invoke_active_titlebar_menu_item(socket) do
items = active_titlebar_menu_items(socket.assigns)
case Enum.at(items, socket.assigns[:titlebar_menu_item_index]) do
%{id: id} ->
socket
|> close_titlebar_menu()
|> handle_native_menu_action(Atom.to_string(id))
_other ->
socket
end
end
defp mac_ui? do defp mac_ui? do
case Application.get_env(:bds, :shell_platform) do case Application.get_env(:bds, :shell_platform) do
nil -> match?({:unix, :darwin}, :os.type()) nil -> match?({:unix, :darwin}, :os.type())

View File

@@ -9,6 +9,7 @@
data-region="title-bar" data-region="title-bar"
data-testid="window-titlebar" data-testid="window-titlebar"
data-open-menu-group={@titlebar_menu_group || ""} data-open-menu-group={@titlebar_menu_group || ""}
phx-window-keydown={if(@titlebar_menu_group, do: "titlebar_menu_keydown")}
> >
<div class="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar"> <div class="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar">
<%= for group <- @menu_groups do %> <%= for group <- @menu_groups do %>
@@ -73,12 +74,15 @@
data-testid="window-titlebar-menu-dropdown" data-testid="window-titlebar-menu-dropdown"
phx-click-away="close_titlebar_menu" phx-click-away="close_titlebar_menu"
> >
<%= for item <- group.items do %> <%= for item <- titlebar_menu_dropdown_items(group) do %>
<%= if item.separator do %> <%= if item.separator do %>
<div class="window-titlebar-menu-separator"></div> <div class="window-titlebar-menu-separator"></div>
<% else %> <% else %>
<button <button
class="window-titlebar-menu-item" class={[
"window-titlebar-menu-item",
if(@titlebar_menu_item_index == item.keyboard_index, do: "is-keyboard-active")
]}
data-testid="window-titlebar-menu-item" data-testid="window-titlebar-menu-item"
data-menu-action={item.id} data-menu-action={item.id}
type="button" type="button"
@@ -588,7 +592,10 @@
<% end %> <% end %>
</div> </div>
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="open_tasks_panel"> <button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
<span><%= @status.left.running_task_message || translated("Idle") %></span> <%= if @status.left.running_task_message do %>
<span class="task-spinner"></span>
<% end %>
<span class="task-message-text"><%= @status.left.running_task_message || translated("Idle") %></span>
<%= if (@status.left.running_task_overflow || 0) > 0 do %> <%= if (@status.left.running_task_overflow || 0) > 0 do %>
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span> <span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
<% end %> <% end %>

View File

@@ -369,7 +369,7 @@ defmodule BDS.Git do
end end
defp parse_count(value) do defp parse_count(value) do
case Integer.parse(to_string(value || "")) do case Integer.parse(to_string(value)) do
{count, _rest} -> count {count, _rest} -> count
:error -> 0 :error -> 0
end end

View File

@@ -161,7 +161,7 @@ button {
.window-titlebar-menu-dropdown { .window-titlebar-menu-dropdown {
position: absolute; position: absolute;
top: 30px; top: 30px;
left: 6px; left: var(--bds-titlebar-menu-left, 6px);
min-width: 210px; min-width: 210px;
padding: 6px; padding: 6px;
display: flex; display: flex;
@@ -2046,7 +2046,7 @@ button {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: 14px; padding: 12px;
} }
.assistant-sidebar-header { .assistant-sidebar-header {
@@ -2086,10 +2086,10 @@ button {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
padding: 12px; padding: 8px;
border: 1px solid var(--vscode-panel-border); border: 1px solid var(--vscode-panel-border);
border-radius: 10px; border-radius: 6px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 78%, var(--vscode-editor-background)); background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
} }
.assistant-sidebar-context-row { .assistant-sidebar-context-row {
@@ -2127,13 +2127,13 @@ button {
.assistant-sidebar-prompt { .assistant-sidebar-prompt {
width: 100%; width: 100%;
min-height: 112px; min-height: 120px;
resize: vertical; resize: vertical;
border: 1px solid var(--vscode-input-border); border: 1px solid var(--vscode-input-border);
border-radius: 10px; border-radius: 6px;
background: var(--vscode-input-background); background: var(--vscode-input-background);
color: var(--vscode-input-foreground); color: var(--vscode-input-foreground);
padding: 10px 12px; padding: 10px;
font: inherit; font: inherit;
} }
@@ -2164,17 +2164,17 @@ button {
gap: 6px; gap: 6px;
padding: 12px; padding: 12px;
border: 1px solid var(--vscode-panel-border); border: 1px solid var(--vscode-panel-border);
border-radius: 10px; border-radius: 6px;
border-bottom-width: 1px; border-bottom-width: 1px;
background: color-mix(in srgb, var(--vscode-sideBar-background) 82%, var(--vscode-editor-background)); background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
} }
.assistant-sidebar-message.user { .assistant-sidebar-message.user {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 76%, transparent); background: var(--vscode-list-hoverBackground);
} }
.assistant-sidebar-message.assistant { .assistant-sidebar-message.assistant {
background: color-mix(in srgb, var(--vscode-sideBar-background) 70%, var(--vscode-editor-background)); background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
} }
.status-bar { .status-bar {
@@ -2216,6 +2216,55 @@ button {
font-size: 12px; font-size: 12px;
} }
.status-bar-item .task-message-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.task-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.panel-content {
padding: 8px;
}
.task-list {
gap: 4px;
}
.output-list,
.git-log-list {
gap: 6px;
}
.task-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.output-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
font-size: 12px;
color: var(--vscode-editor-foreground);
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.editor-frame { .editor-frame {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -129,6 +129,28 @@ document.addEventListener("DOMContentLoaded", () => {
this.syncStoredUiLanguage(); this.syncStoredUiLanguage();
this.destroyOverlaySync = syncTitlebarOverlayInsets(); this.destroyOverlaySync = syncTitlebarOverlayInsets();
this.syncTitlebarMenuAnchor = () => {
const titlebar = this.el.querySelector("[data-testid='window-titlebar']");
const dropdown = this.el.querySelector("[data-testid='window-titlebar-menu-dropdown']");
const openGroup = titlebar?.dataset.openMenuGroup;
if (!dropdown || !titlebar || !openGroup) {
return;
}
const button = this.getTitlebarMenuButtons().find((candidate) => candidate.dataset.menuGroup === openGroup);
if (!button) {
return;
}
const titlebarRect = titlebar.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const left = Math.max(6, Math.round(buttonRect.left - titlebarRect.left));
dropdown.style.setProperty("--bds-titlebar-menu-left", `${left}px`);
};
this.handleMouseDown = (event) => { this.handleMouseDown = (event) => {
const handle = event.target.closest("[data-role='resize-handle']"); const handle = event.target.closest("[data-role='resize-handle']");
@@ -183,27 +205,6 @@ 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) => { this.handleChange = (event) => {
const select = event.target.closest(".status-bar-language-select"); const select = event.target.closest(".status-bar-language-select");
@@ -237,17 +238,14 @@ 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("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true); window.addEventListener("keydown", this.handleShortcutKeyDown, true);
window.addEventListener("keydown", this.handleEscapeKey, true);
window.addEventListener("pointerdown", this.handleTitlebarPointerDown, true);
this.el.addEventListener("change", this.handleChange); this.el.addEventListener("change", this.handleChange);
this.syncTitlebarMenuAnchor();
},
updated() {
this.syncTitlebarMenuAnchor();
}, },
destroyed() { destroyed() {
@@ -255,8 +253,6 @@ document.addEventListener("DOMContentLoaded", () => {
this.el.removeEventListener("change", this.handleChange); this.el.removeEventListener("change", this.handleChange);
window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.removeEventListener("keydown", this.handleShortcutKeyDown, true); window.removeEventListener("keydown", this.handleShortcutKeyDown, true);
window.removeEventListener("keydown", this.handleEscapeKey, true);
window.removeEventListener("pointerdown", this.handleTitlebarPointerDown, true);
if (this.destroyOverlaySync) { if (this.destroyOverlaySync) {
this.destroyOverlaySync(); this.destroyOverlaySync();
} }

View File

@@ -176,6 +176,35 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
end end
test "titlebar menu keyboard navigation is owned by liveview on windows and linux" do
Application.put_env(:bds, :shell_platform, {:unix, :linux})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
view
|> element("[data-testid='window-titlebar-menu-button'][data-menu-group='file']")
|> render_click()
assert html =~ ~s(data-open-menu-group="file")
html = render_keydown(view, "titlebar_menu_keydown", %{key: "ArrowRight"})
assert html =~ ~s(data-open-menu-group="edit")
assert html =~ ~s(data-menu-action="edit_preferences")
html = render_keydown(view, "titlebar_menu_keydown", %{key: "End"})
assert html =~ ~s(class="window-titlebar-menu-item is-keyboard-active")
assert html =~ ~s(data-menu-action="edit_preferences")
html = render_keydown(view, "titlebar_menu_keydown", %{key: "Enter"})
assert html =~ ~s(data-tab-type="settings")
assert html =~ ">Settings<"
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
end
test "shell live renders the legacy git activity badge from remote behind count" do test "shell live renders the legacy git activity badge from remote behind count" do
Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts -> Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts ->
{:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}} {:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}}

View File

@@ -164,6 +164,58 @@ defmodule BDS.UI.ShellTest do
assert template =~ "tab-dirty-indicator" assert template =~ "tab-dirty-indicator"
end end
test "desktop shell assets keep legacy titlebar menu keyboard and anchoring behavior" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert css =~ "left: var(--bds-titlebar-menu-left, 6px);"
assert live_js =~ "--bds-titlebar-menu-left"
refute live_js =~ "handleTitlebarMenuKeyDown"
refute live_js =~ "keyboardMenuIndex"
assert template =~ "phx-window-keydown={if(@titlebar_menu_group, do: \"titlebar_menu_keydown\")}"
assert live_ex =~ ~s(def handle_event("titlebar_menu_keydown")
assert live_ex =~ "titlebar_menu_item_index"
end
test "desktop shell status task area keeps the compact running-task markup" do
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert template =~ "task-message-text"
assert template =~ "task-spinner"
assert template =~ "status-bar-count"
end
test "desktop shell css keeps old panel and output density" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
assert css =~ ".panel-content {"
assert css =~ "padding: 8px;"
assert css =~ ".task-spinner {"
assert css =~ ".task-message-text"
assert css =~ ".output-entry {"
assert css =~ "background-color: var(--vscode-sideBar-background);"
assert css =~ "border-radius: 4px;"
assert css =~ ".task-entry {"
assert css =~ "background-color: var(--vscode-sideBar-background);"
end
test "desktop shell css keeps legacy sidebar header and post list layout" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
assert css =~ ".sidebar-section {"
assert css =~ "margin-bottom: 4px;"
assert css =~ "border-left: 2px solid transparent;"
assert css =~ "border-left-color: var(--vscode-focusBorder);"
assert css =~ ".sidebar-section-header {"
assert css =~ "justify-content: space-between;"
assert css =~ "font-weight: 600;"
assert css =~ ".sidebar-item {"
assert css =~ "align-items: flex-start;"
assert css =~ "gap: 8px;"
end
test "desktop shell assets keep the assistant sidebar chat surface contract" do test "desktop shell assets keep the assistant sidebar chat surface contract" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
@@ -178,4 +230,18 @@ defmodule BDS.UI.ShellTest do
assert template =~ "data-testid=\"assistant-start-button\"" assert template =~ "data-testid=\"assistant-start-button\""
assert template =~ "assistant-sidebar-transcript" assert template =~ "assistant-sidebar-transcript"
end end
test "desktop shell css keeps the old assistant sidebar panel styling" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
assert css =~ ".assistant-content {"
assert css =~ "padding: 12px;"
assert css =~ ".assistant-sidebar-context {"
assert css =~ "padding: 8px;"
assert css =~ "border-radius: 6px;"
assert css =~ "background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));"
assert css =~ ".assistant-sidebar-prompt {"
assert css =~ "min-height: 120px;"
assert css =~ "padding: 10px;"
end
end end