fix: hopefully shell now parity with old

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-26 07:23:48 +02:00
parent 3556ab45b3
commit fd1b8e7bd4
10 changed files with 1079 additions and 33 deletions

View File

@@ -13,7 +13,7 @@ The rewrite already implements most of the backend and compatibility-critical su
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, and template-backed shell rendering.
- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, and browser-native menu bridging.
### Implemented But Not Yet At Parity
@@ -52,8 +52,8 @@ The remaining work needs to proceed from base contracts upward. Later phases sho
2. Close engine-level behavior gaps. Completed 2026-04-25.
Save/publish/delete side-effects, published-post `templateSlug` frontmatter rewrites, manual-translation source-post reopening, post-to-media sidecar cleanup, auto-translation task cascades, linked-media translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications are now implemented and covered at the backend layer independent of UI.
3. Finish the desktop shell primitives. Completed 2026-04-25.
Route state, registry-backed shell command coverage, panel fallback integration, menu/native-command wiring, template-backed LiveView rendering, and sidebar-to-tab/status-bar interactions now cover every sidebar view and singleton editor route while preserving the old app shell frame and styling.
3. Finish the desktop shell primitives. Completed 2026-04-26.
Route state, registry-backed shell command coverage, panel fallback integration, menu/native-command wiring, template-backed LiveView rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, project dropdown actions, UI language switching, real output/post-link/git lower-panel content, and native-menu event bridging now cover the old shell frame behavior while preserving the legacy layout and styling.
4. Implement the shared modal and confirmation layer.
Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows.

View File

@@ -32,6 +32,10 @@ defmodule BDS.Desktop.Automation do
GenServer.call(session, {:press, shortcut}, @request_timeout)
end
def native_menu_action(session, action) when is_binary(action) do
GenServer.call(session, {:native_menu_action, action}, @request_timeout)
end
def reload(session) do
GenServer.call(session, :reload, @request_timeout)
end
@@ -102,6 +106,11 @@ defmodule BDS.Desktop.Automation do
{:reply, normalize_simple_reply(reply), state}
end
def handle_call({:native_menu_action, action}, _from, state) do
{reply, state} = driver_request(state, %{"command" => "native_menu_action", "action" => action})
{:reply, normalize_simple_reply(reply), state}
end
def handle_call(:reload, _from, state) do
{reply, state} = driver_request(state, %{"command" => "reload"})
{:reply, normalize_simple_reply(reply), state}

View File

@@ -15,8 +15,8 @@ defmodule BDS.Desktop.ShellData do
I18n.current_ui_locale()
end
def translations do
I18n.get_ui_translations(ui_language())
def translations(locale \\ nil) do
I18n.get_ui_translations(effective_ui_language(locale))
end
def supported_ui_languages do
@@ -25,8 +25,8 @@ defmodule BDS.Desktop.ShellData do
end)
end
def translate(key, bindings \\ %{}) do
text = Map.get(translations(), to_string(key), to_string(key))
def translate(key, bindings \\ %{}, locale \\ nil) do
text = Map.get(translations(locale), to_string(key), to_string(key))
Enum.reduce(bindings, text, fn {binding, value}, acc ->
String.replace(acc, "%{#{binding}}", to_string(value))
@@ -60,15 +60,15 @@ defmodule BDS.Desktop.ShellData do
Dashboard.empty_snapshot()
end
def sidebar_view(project_id, view_id) do
Sidebar.view(project_id, view_id, %{})
def sidebar_view(project_id, view_id, params \\ %{}) do
Sidebar.view(project_id, view_id, params)
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
Sidebar.view(nil, view_id, %{})
Sidebar.view(nil, view_id, params)
end
def assistant_cards do
@@ -101,7 +101,10 @@ defmodule BDS.Desktop.ShellData do
end
def panel_tabs(workbench) do
[:tasks, :output, :git_log, workbench.panel.active_tab]
[:tasks, :output]
|> maybe_add_panel_tab(workbench.editor_route, :post_links)
|> maybe_add_panel_tab(workbench.editor_route, :git_log)
|> Kernel.++([workbench.panel.active_tab])
|> Enum.uniq()
end
@@ -209,6 +212,16 @@ defmodule BDS.Desktop.ShellData do
end
end
defp effective_ui_language(nil) do
Process.get(:bds_ui_locale) || ui_language()
end
defp effective_ui_language(locale), do: locale
defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links]
defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media], do: tabs ++ [:git_log]
defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs
defp default_project_snapshot do
%{
active_project_id: "default",

View File

@@ -5,12 +5,30 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.Desktop.ShellData
alias BDS.UI.Commands
alias BDS.UI.Registry
alias BDS.UI.Workbench
alias BDS.Desktop.{FolderPicker, ShellCommands, ShellData}
alias BDS.Git
alias BDS.Media.Media
alias BDS.PostLinks
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
alias BDS.UI.{Commands, MenuBar, Registry, Workbench}
@refresh_interval 1_500
@output_entry_limit 20
@default_new_project_name "New Blog"
@local_menu_actions MapSet.new([
:toggle_sidebar,
:toggle_panel,
:toggle_assistant_sidebar,
:view_posts,
:view_media,
:edit_preferences,
:edit_menu,
:documentation,
:api_documentation,
:close_tab
])
embed_templates "shell_live/*"
@@ -28,6 +46,10 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:page_language, ShellData.ui_language())
|> assign(:offline_mode, true)
|> assign(:tab_meta, %{})
|> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{})
|> assign(:sidebar_filter_panels, %{})
|> assign(:output_entries, [])
|> reload_shell(workbench)}
end
@@ -74,6 +96,88 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, resize_panel(socket.assigns.workbench, target, width))}
end
def handle_event("toggle_sidebar_filters", _params, socket) do
view_id = Atom.to_string(socket.assigns.workbench.active_view)
sidebar_filter_panels =
Map.update(socket.assigns.sidebar_filter_panels, view_id, false, &not &1)
{:noreply,
socket
|> assign(:sidebar_filter_panels, sidebar_filter_panels)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("update_sidebar_search", %{"sidebar_filters" => params}, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters -> Map.put(filters, :search, normalize_filter_string(Map.get(params, "search"))) end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("clear_sidebar_search", _params, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters -> Map.put(filters, :search, nil) end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("toggle_sidebar_tag", %{"tag" => tag}, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters -> toggle_filter_value(filters, :tags, tag) end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("toggle_sidebar_category", %{"category" => category}, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters -> toggle_filter_value(filters, :categories, category) end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("select_sidebar_month", %{"year" => year, "month" => month}, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters ->
filters
|> Map.put(:year, parse_optional_integer(year))
|> Map.put(:month, parse_optional_integer(month))
end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("clear_sidebar_month", _params, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters -> filters |> Map.put(:year, nil) |> Map.put(:month, nil) end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("clear_sidebar_filters", _params, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters ->
filters
|> Map.put(:search, nil)
|> Map.put(:year, nil)
|> Map.put(:month, nil)
|> Map.put(:tags, [])
|> Map.put(:categories, [])
|> Map.put(:display_limit, sidebar_page_size(socket.assigns.sidebar_data))
end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("load_more_sidebar", _params, socket) do
{:noreply,
socket
|> put_sidebar_filters(fn filters ->
Map.update(filters, :display_limit, sidebar_page_size(socket.assigns.sidebar_data), &(&1 + sidebar_page_size(socket.assigns.sidebar_data)))
end)
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("shortcut", params, socket) do
if ignore_shortcut?(params) do
{:noreply, socket}
@@ -105,6 +209,72 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, reload_shell(socket, socket.assigns.workbench)}
end
def handle_event("open_tasks_panel", _params, socket) do
workbench =
socket.assigns.workbench
|> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(:tasks)
{:noreply, reload_shell(socket, workbench)}
end
def handle_event("toggle_project_menu", _params, socket) do
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
end
def handle_event("close_project_menu", _params, socket) do
{:noreply, assign(socket, :project_menu_open, false)}
end
def handle_event("select_project", %{"project_id" => project_id}, socket) do
{:noreply, activate_project(socket, project_id, "Select Project", fn project -> "Activated #{project.name}" end)}
end
def handle_event("create_project", _params, socket) do
attrs = %{name: next_project_name(socket.assigns.projects.projects)}
socket =
case Projects.create_project(attrs) do
{:ok, project} -> activate_project(socket, project.id, "New Project", fn created -> "Activated #{created.name}" end)
{:error, reason} -> append_output_entry(socket, "New Project", inspect(reason), nil, "error")
end
{:noreply, socket}
end
def handle_event("import_project", _params, socket) do
socket =
case FolderPicker.choose_directory("Open Existing Blog") do
{:ok, path} ->
name = path |> Path.basename() |> String.trim() |> case do
"" -> "Imported Blog"
value -> value
end
case Projects.create_project(%{name: name, data_path: path}) do
{:ok, project} -> activate_project(socket, project.id, "Open Existing Blog", fn imported -> "Activated #{imported.name}" end)
{:error, reason} -> append_output_entry(socket, "Open Existing Blog", inspect(reason), nil, "error")
end
:cancel -> assign(socket, :project_menu_open, false)
{:error, %{message: message}} -> append_output_entry(socket, "Open Existing Blog", message, nil, "error")
end
{:noreply, socket}
end
def handle_event("change_ui_language", %{"ui_language" => language}, socket) do
{:noreply, set_page_language(socket, language)}
end
def handle_event("sync_ui_language", %{"language" => language}, socket) do
{:noreply, set_page_language(socket, language)}
end
def handle_event("native_menu_action", %{"action" => action}, socket) do
{:noreply, handle_native_menu_action(socket, action)}
end
@impl true
def handle_info(:refresh_task_status, socket) do
task_status = BDS.Tasks.status_snapshot()
@@ -123,12 +293,17 @@ defmodule BDS.Desktop.ShellLive do
end
@impl true
def render(assigns), do: index(assigns)
def render(assigns) do
Process.put(:bds_ui_locale, assigns.page_language)
index(assigns)
end
defp reload_shell(socket, workbench) do
projects = ShellData.project_snapshot()
dashboard = ShellData.dashboard(projects.active_project_id)
sidebar_data = ShellData.sidebar_view(projects.active_project_id, Atom.to_string(workbench.active_view))
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data = ShellData.sidebar_view(projects.active_project_id, active_view_id, current_sidebar_filters(socket, active_view_id))
sidebar_data = merge_sidebar_ui_state(socket, active_view_id, sidebar_data)
task_status = BDS.Tasks.status_snapshot()
activity_buttons = Workbench.activity_buttons(workbench, 0)
page_language = socket.assigns[:page_language] || ShellData.ui_language()
@@ -161,6 +336,150 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:current_tab, current_tab(workbench))
end
defp render_sidebar_filters(assigns) do
filters = Map.get(assigns.sidebar_data, :filters)
if is_map(filters) and Map.get(filters, :enabled) do
selected = Map.get(filters, :selected, %{})
assigns =
assigns
|> assign(:sidebar_filters_config, filters)
|> assign(:selected_filters, selected)
|> assign(:filter_panel_visible, Map.get(filters, :filter_panel_visible, true))
~H"""
<form class="search-box" data-testid="sidebar-search-form" phx-change="update_sidebar_search">
<input
type="text"
name="sidebar_filters[search]"
value={Map.get(@selected_filters, :search) || ""}
placeholder={translated(@sidebar_filters_config.search_placeholder)}
/>
<%= if Map.get(@selected_filters, :search) do %>
<button class="clear-search" data-testid="sidebar-clear-search" type="button" phx-click="clear_sidebar_search">×</button>
<% end %>
<div class="sidebar-actions">
<button
class={[
"sidebar-action",
if(@filter_panel_visible, do: "active")
]}
data-testid="sidebar-filter-toggle"
type="button"
phx-click="toggle_sidebar_filters"
aria-label={translated(@sidebar_filters_config.toggle_filters_label)}
title={translated(@sidebar_filters_config.toggle_filters_label)}
>
</button>
</div>
</form>
<%= if Map.get(@sidebar_filters_config, :has_active_filters) do %>
<div class="filter-status">
<span>
<%= translated(@sidebar_filters_config.results_label) %>: <%= @sidebar_filters_config.loaded_count %>/<%= @sidebar_filters_config.total_count %>
</span>
<button data-testid="sidebar-clear-filters" type="button" phx-click="clear_sidebar_filters">
<%= translated(@sidebar_filters_config.clear_filters_label) %>
</button>
</div>
<% end %>
<%= if @filter_panel_visible do %>
<%= if Enum.any?(Map.get(@sidebar_filters_config, :year_month_counts, [])) do %>
<div class="calendar-view">
<div class="calendar-header">
<span><%= translated(@sidebar_filters_config.archive_label) %></span>
<%= if Map.get(@selected_filters, :year) do %>
<button class="clear-filter" type="button" phx-click="clear_sidebar_month">×</button>
<% end %>
</div>
<div class="calendar-years">
<%= for entry <- Map.get(@sidebar_filters_config, :year_month_counts, []) do %>
<button
class={[
"calendar-month",
if(Map.get(@selected_filters, :year) == entry.year and Map.get(@selected_filters, :month) == entry.month, do: "selected")
]}
data-testid="sidebar-filter-month"
type="button"
phx-click="select_sidebar_month"
phx-value-year={entry.year}
phx-value-month={entry.month}
>
<span class="month-label"><%= ShellData.format_dashboard_month(entry.year, entry.month) %> <%= entry.year %></span>
<span class="month-count"><%= entry.count %></span>
</button>
<% end %>
</div>
</div>
<% end %>
<div class="filter-panel">
<%= if Enum.any?(Map.get(@sidebar_filters_config, :available_tags, [])) do %>
<section class="filter-section">
<div class="filter-header"><%= translated(@sidebar_filters_config.tags_label) %></div>
<div class="filter-chips">
<%= for tag <- Map.get(@sidebar_filters_config, :available_tags, []) do %>
<button
class={[
"filter-chip",
if(tag in Map.get(@selected_filters, :tags, []), do: "active")
]}
data-testid="sidebar-filter-tag"
data-filter-tag={tag}
type="button"
phx-click="toggle_sidebar_tag"
phx-value-tag={tag}
>
<%= tag %>
</button>
<% end %>
</div>
</section>
<% end %>
<%= if Enum.any?(Map.get(@sidebar_filters_config, :available_categories, [])) do %>
<section class="filter-section">
<div class="filter-header"><%= translated(@sidebar_filters_config.categories_label) %></div>
<div class="filter-chips">
<%= for category <- Map.get(@sidebar_filters_config, :available_categories, []) do %>
<button
class={[
"filter-chip",
if(category in Map.get(@selected_filters, :categories, []), do: "active")
]}
data-testid="sidebar-filter-category"
data-filter-category={category}
type="button"
phx-click="toggle_sidebar_category"
phx-value-category={category}
>
<%= category %>
</button>
<% end %>
</div>
</section>
<% end %>
</div>
<% end %>
<%= if Map.get(@sidebar_filters_config, :has_more) do %>
<div class="sidebar-load-more">
<button class="load-more-button" data-testid="sidebar-load-more" type="button" phx-click="load_more_sidebar">
<%= translated("Load more") %>
</button>
</div>
<% end %>
"""
else
~H"""
"""
end
end
defp render_sidebar_body(assigns) do
case assigns.sidebar_data.layout do
"post_list" -> render_post_sidebar(assigns)
@@ -340,6 +659,7 @@ defmodule BDS.Desktop.ShellLive do
case assigns.workbench.panel.active_tab do
:tasks -> render_task_entries(assigns)
:output -> render_output_entries(assigns)
:post_links -> render_post_links(assigns)
:git_log -> render_git_log(assigns)
other -> render_generic_panel(assigns, other)
end
@@ -370,21 +690,109 @@ defmodule BDS.Desktop.ShellLive do
defp render_output_entries(assigns) do
~H"""
<div class="panel-entry panel-empty-state output-list">
<strong><%= translated("Output") %></strong>
<span><%= translated("No shell output yet") %></span>
</div>
<%= if Enum.empty?(@output_entries) do %>
<div class="panel-entry panel-empty-state output-list">
<strong><%= translated("Output") %></strong>
<span><%= translated("No shell output yet") %></span>
</div>
<% else %>
<div class="output-list">
<%= for entry <- @output_entries do %>
<div class={[
"panel-entry",
"output-entry",
if(Map.get(entry, :level) == "error", do: "output-entry-error")
]}>
<strong><%= entry.title %></strong>
<span><%= entry.message %></span>
<%= if present?(entry.details) do %>
<span><%= entry.details %></span>
<% end %>
</div>
<% end %>
</div>
<% end %>
"""
end
defp render_post_links(assigns) do
links = post_link_entries(assigns)
assigns =
assigns
|> assign(:backlinks, Map.get(links, :backlinks, []))
|> assign(:outlinks, Map.get(links, :outlinks, []))
~H"""
<%= if Enum.empty?(@backlinks) and Enum.empty?(@outlinks) do %>
<div class="panel-entry panel-empty-state">
<strong><%= translated("Post Links") %></strong>
<span><%= translated("No post links yet") %></span>
</div>
<% else %>
<div class="git-log-list">
<%= if Enum.any?(@backlinks) do %>
<div class="panel-entry"><strong><%= translated("Backlinks") %></strong></div>
<%= for entry <- @backlinks do %>
<button
class="panel-entry task-entry"
type="button"
phx-click="pin_sidebar_item"
phx-value-route="post"
phx-value-id={entry.id}
phx-value-title={entry.title}
phx-value-subtitle="linked post"
>
<strong><%= entry.title %></strong>
<span><%= entry.text %></span>
</button>
<% end %>
<% end %>
<%= if Enum.any?(@outlinks) do %>
<div class="panel-entry"><strong><%= translated("Links To") %></strong></div>
<%= for entry <- @outlinks do %>
<button
class="panel-entry task-entry"
type="button"
phx-click="pin_sidebar_item"
phx-value-route="post"
phx-value-id={entry.id}
phx-value-title={entry.title}
phx-value-subtitle="linked post"
>
<strong><%= entry.title %></strong>
<span><%= entry.text %></span>
</button>
<% end %>
<% end %>
</div>
<% end %>
"""
end
defp render_git_log(assigns) do
entries = git_log_entries(assigns)
assigns = assign(assigns, :git_entries, entries)
~H"""
<div class="git-log-list">
<div class="panel-entry">
<strong><%= translated("Git Log") %></strong>
<span><%= translated("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.") %></span>
<%= if Enum.empty?(@git_entries) do %>
<div class="git-log-list">
<div class="panel-entry panel-empty-state">
<strong><%= translated("Git Log") %></strong>
<span><%= translated("No git history yet") %></span>
</div>
</div>
</div>
<% else %>
<div class="git-log-list">
<%= for entry <- @git_entries do %>
<div class="panel-entry task-entry">
<strong><%= short_commit_hash(entry.hash) %> <%= entry.subject || translated("No commit subject") %></strong>
<span><%= entry.hash %></span>
</div>
<% end %>
</div>
<% end %>
"""
end
@@ -399,7 +807,7 @@ defmodule BDS.Desktop.ShellLive do
"""
end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings)
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
defp panel_tab_label(:tasks), do: translated("Tasks")
defp panel_tab_label(:output), do: translated("Output")
@@ -418,6 +826,8 @@ defmodule BDS.Desktop.ShellLive do
defp sidebar_header_label(label), do: translated(label)
defp present?(value), do: value not in [nil, ""]
defp timeline_height(entry, entries) do
max_count =
entries
@@ -511,6 +921,261 @@ defmodule BDS.Desktop.ShellLive do
|> reload_shell(workbench)
end
defp merge_sidebar_ui_state(socket, view_id, sidebar_data) do
filters = Map.get(sidebar_data, :filters)
if is_map(filters) and Map.get(filters, :enabled) do
filter_panel_visible = Map.get(socket.assigns.sidebar_filter_panels, view_id, true)
Map.put(sidebar_data, :filters, Map.put(filters, :filter_panel_visible, filter_panel_visible))
else
sidebar_data
end
end
defp current_sidebar_filters(socket, view_id) do
socket.assigns.sidebar_filters_by_view
|> Map.get(view_id, %{})
|> normalize_sidebar_filters(socket.assigns[:sidebar_data])
end
defp normalize_sidebar_filters(filters, sidebar_data) do
max_items = sidebar_page_size(sidebar_data)
%{
search: normalize_filter_string(Map.get(filters, :search)),
year: Map.get(filters, :year),
month: Map.get(filters, :month),
tags: Map.get(filters, :tags, []),
categories: Map.get(filters, :categories, []),
display_limit: max(Map.get(filters, :display_limit, max_items) || max_items, max_items)
}
end
defp put_sidebar_filters(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view)
filters = current_sidebar_filters(socket, view_id) |> updater.() |> normalize_sidebar_filters(socket.assigns.sidebar_data)
assign(socket, :sidebar_filters_by_view, Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters))
end
defp toggle_filter_value(filters, key, value) do
values = Map.get(filters, key, [])
next_values =
if value in values do
List.delete(values, value)
else
values ++ [value]
end
Map.put(filters, key, next_values)
end
defp normalize_filter_string(nil), do: nil
defp normalize_filter_string(value) do
value
|> to_string()
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
defp parse_optional_integer(nil), do: nil
defp parse_optional_integer(value) when is_integer(value), do: value
defp parse_optional_integer(value) when is_binary(value) do
case Integer.parse(value) do
{parsed, _rest} -> parsed
:error -> nil
end
end
defp sidebar_page_size(nil), do: 500
defp sidebar_page_size(sidebar_data) do
sidebar_data
|> Map.get(:filters, %{})
|> Map.get(:max_items, 500)
end
defp set_page_language(socket, language) do
codes = Enum.map(socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(), & &1.code)
normalized =
language
|> to_string()
|> String.trim()
|> case do
value -> if(value in codes, do: value, else: socket.assigns.page_language)
end
if normalized == socket.assigns.page_language do
socket
else
socket
|> assign(:page_language, normalized)
|> reload_shell(socket.assigns.workbench)
end
end
defp activate_project(socket, project_id, title, message_fun) do
cond do
project_id == socket.assigns.projects.active_project_id -> assign(socket, :project_menu_open, false)
true ->
case Projects.set_active_project(project_id) do
{:ok, project} ->
socket
|> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{})
|> append_output_entry(title, message_fun.(project))
|> reload_shell(Workbench.clear_tabs(socket.assigns.workbench))
{:error, reason} ->
socket
|> assign(:project_menu_open, false)
|> append_output_entry(title, inspect(reason), nil, "error")
end
end
end
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
entry = %{title: title, message: message, details: details, level: level}
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
assign(socket, :output_entries, entries)
end
defp next_project_name(projects) do
existing_names = MapSet.new(Enum.map(projects, & &1.name))
Stream.iterate(1, &(&1 + 1))
|> Enum.find_value(fn index ->
candidate = if index == 1, do: @default_new_project_name, else: "#{@default_new_project_name} #{index}"
if MapSet.member?(existing_names, candidate), do: nil, else: candidate
end)
end
defp handle_native_menu_action(socket, action) do
with action_atom when not is_nil(action_atom) <- safe_existing_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")
end
end
defp safe_existing_atom(action) when is_binary(action) do
String.to_existing_atom(action)
rescue
ArgumentError -> nil
end
defp apply_shell_command(socket, action) do
case ShellCommands.execute(action) do
{:ok, result} -> apply_shell_command_result(socket, result)
{:error, %{message: message}} -> append_output_entry(socket, command_title(action), message, nil, "error")
{:error, reason} -> append_output_entry(socket, command_title(action), inspect(reason), nil, "error")
end
end
defp apply_shell_command_result(socket, %{kind: "task_queued", title: title, message: message, panel_tab: panel_tab}) do
workbench =
socket.assigns.workbench
|> Workbench.set_panel_visible(true)
|> Workbench.set_panel_tab(String.to_existing_atom(panel_tab))
socket
|> append_output_entry(title, message)
|> reload_shell(workbench)
end
defp apply_shell_command_result(socket, %{kind: "output", title: title, message: message} = result) do
socket
|> append_output_entry(title, message, Map.get(result, :details), Map.get(result, :level, "info"))
end
defp apply_shell_command_result(socket, %{kind: "open_url", title: title, message: message, url: url}) do
append_output_entry(socket, title, message, url)
end
defp apply_shell_command_result(socket, %{kind: "open_editor", route: route, title: title, subtitle: subtitle}) do
route_atom = String.to_existing_atom(route)
tab_id = tab_id_for_route(route_atom, route)
workbench = Workbench.open_tab(socket.assigns.workbench, route_atom, tab_id, :pin)
tab_meta = Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{title: title, subtitle: subtitle})
socket
|> assign(:tab_meta, tab_meta)
|> reload_shell(workbench)
end
defp apply_shell_command_result(socket, _result), do: socket
defp command_title(action) do
action
|> to_string()
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp post_link_entries(assigns) do
case assigns.current_tab do
%{type: :post, id: post_id} ->
%{
backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id),
outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id)
}
_other ->
%{backlinks: [], outlinks: []}
end
end
defp related_posts(links, key) do
Enum.map(links, fn link ->
case Repo.get(Post, Map.fetch!(link, key)) do
%Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id}
_other -> nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp git_log_entries(assigns) do
case git_history_target(assigns.current_tab) do
nil -> []
{project_id, file_path} ->
case Git.file_history(project_id, file_path) do
{:ok, %{commits: commits}} -> commits
_other -> []
end
end
end
defp git_history_target(%{type: :post, id: post_id}) do
case Repo.get(Post, post_id) do
%Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path}
_other -> nil
end
end
defp git_history_target(%{type: :media, id: media_id}) do
case Repo.get(Media, media_id) do
%Media{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path}
_other -> nil
end
end
defp git_history_target(_tab), do: nil
defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7)
defp short_commit_hash(_hash), do: "-------"
defp sidebar_route_atom(route) when is_atom(route), do: route
defp sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route)

View File

@@ -99,6 +99,7 @@
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
</div>
</div>
<%= render_sidebar_filters(assigns) %>
<%= render_sidebar_body(assigns) %>
</div>
</div>
@@ -232,7 +233,17 @@
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
<div class="recent-posts-list">
<%= for post <- @dashboard_recent_posts do %>
<button class="recent-post-item" type="button">
<button
class="recent-post-item"
data-testid="recent-post-item"
data-post-id={post.id}
type="button"
phx-click="open_sidebar_item"
phx-value-route="post"
phx-value-id={post.id}
phx-value-title={post.title || ""}
phx-value-subtitle={post.status || "draft"}
>
<span class="recent-post-title"><%= post.title || "" %></span>
<span class={"recent-post-status status-#{post.status || "draft"}"}><%= ShellData.dashboard_status_label(post.status || "draft") %></span>
<span class="recent-post-date"><%= ShellData.format_dashboard_date(post.updated_at) %></span>
@@ -329,7 +340,13 @@
<footer class="status-bar" data-region="status-bar" data-testid="status-bar">
<div class="status-bar-left">
<div class="project-selector">
<button class="project-selector-trigger" type="button" title={translated("Switch project")}>
<button
class="project-selector-trigger"
data-testid="project-selector-trigger"
type="button"
title={translated("Switch project")}
phx-click="toggle_project_menu"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
</svg>
@@ -338,8 +355,41 @@
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
</svg>
</button>
<%= if @project_menu_open do %>
<div class="project-dropdown" data-testid="project-dropdown" phx-click-away="close_project_menu">
<div class="project-dropdown-header">
<span><%= translated("Projects") %></span>
</div>
<div class="project-list">
<%= for project <- @projects.projects do %>
<button
class={["project-item", if(project.id == @projects.active_project_id, do: "active")]}
data-testid="project-item"
data-project-id={project.id}
type="button"
phx-click="select_project"
phx-value-project_id={project.id}
>
<span class="project-item-name"><%= project.name %></span>
<%= if project.id == @projects.active_project_id do %>
<span class="project-check-icon">✓</span>
<% end %>
</button>
<% end %>
</div>
<div class="project-dropdown-footer">
<button class="existing-project-btn" type="button" phx-click="import_project">
<span><%= translated("Open Existing Blog") %></span>
</button>
<button class="create-project-btn" type="button" phx-click="create_project">
<span><%= translated("New Project") %></span>
</button>
</div>
</div>
<% end %>
</div>
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="toggle_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_overflow || 0) > 0 do %>
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
@@ -351,14 +401,14 @@
<span class="status-bar-item"><%= @status.right.media_count %></span>
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={translated("Toggle offline mode")}>✈</button>
<label class="status-bar-item language-badge">
<form class="status-bar-item language-badge" data-testid="status-language-form" phx-change="change_ui_language">
<span><%= translated("UI") %></span>
<select class="status-bar-language-select">
<select class="status-bar-language-select" name="ui_language" data-testid="status-language-select">
<%= for language <- @supported_ui_languages do %>
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
<% end %>
</select>
</label>
</form>
<span class="status-bar-item brand"><%= @status.right.brand %></span>
</div>
</footer>

View File

@@ -112,6 +112,17 @@ defmodule BDS.Git do
end
end
def file_history(project_id, file_path, opts \\ [])
when is_binary(project_id) and is_binary(file_path) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id),
{:ok, output} <- run_git(project_dir, ["log", "--follow", "--format=%H%x09%s", "--", file_path], opts) do
{:ok, %{commits: parse_local_history(output) |> Enum.take(50)}}
else
{:error, {:git_failed, _message}} -> {:ok, %{commits: []}}
error -> error
end
end
def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id) do
case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do

View File

@@ -5,6 +5,7 @@ document.addEventListener("DOMContentLoaded", () => {
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
@@ -53,6 +54,7 @@ document.addEventListener("DOMContentLoaded", () => {
AppShell: {
mounted() {
this.syncStoredLayout();
this.syncStoredUiLanguage();
this.handleMouseDown = (event) => {
const handle = event.target.closest("[data-role='resize-handle']");
@@ -99,10 +101,31 @@ document.addEventListener("DOMContentLoaded", () => {
};
this.el.addEventListener("mousedown", this.handleMouseDown);
this.handleNativeMenuAction = (event) => {
const action = event.detail?.action;
if (action) {
this.pushEvent("native_menu_action", { action });
}
};
this.handleChange = (event) => {
const select = event.target.closest(".status-bar-language-select");
if (select && this.el.contains(select)) {
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value);
}
};
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
this.el.addEventListener("change", this.handleChange);
},
destroyed() {
this.el.removeEventListener("mousedown", this.handleMouseDown);
this.el.removeEventListener("change", this.handleChange);
window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction);
},
syncStoredLayout() {
@@ -110,6 +133,14 @@ document.addEventListener("DOMContentLoaded", () => {
sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500),
assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640)
});
},
syncStoredUiLanguage() {
const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY);
if (stored) {
this.pushEvent("sync_ui_language", { language: stored });
}
}
},

View File

@@ -72,6 +72,15 @@ for await (const line of rl) {
continue;
}
if (message.command === "native_menu_action") {
await page.evaluate((action) => {
window.dispatchEvent(new CustomEvent("bds:native-menu-action", { detail: { action } }));
}, message.action);
await page.waitForTimeout(50);
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
continue;
}
if (message.command === "drag") {
const locator = page.locator(message.selector);
const box = await locator.boundingBox();

View File

@@ -120,6 +120,28 @@ defmodule BDS.Desktop.AutomationTest do
assert automation_process_counts() == baseline
end
@tag timeout: 120_000
test "automation dispatches native menu actions into the liveview shell" do
{:ok, session} = Automation.start_session()
on_exit(fn ->
Automation.stop_session(session)
end)
snapshot = Automation.snapshot(session)
assert snapshot.sidebar_visible == true
assert :ok = Automation.native_menu_action(session, "toggle_sidebar")
snapshot = Automation.snapshot(session)
assert snapshot.sidebar_visible == false
assert :ok = Automation.native_menu_action(session, "edit_preferences")
snapshot = Automation.snapshot(session)
assert snapshot.editor_title == "Settings"
end
defp os_pid_alive?(pid) do
case System.cmd("kill", ["-0", Integer.to_string(pid)], stderr_to_stdout: true) do
{_, 0} -> true

View File

@@ -4,8 +4,29 @@ defmodule BDS.Desktop.ShellLiveTest do
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.Persistence
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir = Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = Projects.create_project(%{name: "Shell Project", data_path: temp_dir})
{:ok, _project} = Projects.set_active_project(project.id)
%{project: project, temp_dir: temp_dir}
end
test "shell live owns pane visibility and activity selection on the server" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
@@ -193,4 +214,219 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(style="width: 460px;")
end
test "sidebar filters and load more are server-driven", %{project: project} do
seed_sidebar_posts(project.id)
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-search-form")
assert html =~ ~s(data-testid="sidebar-filter-toggle")
assert html =~ ~s(data-testid="sidebar-filter-tag")
assert html =~ ~s(data-testid="sidebar-load-more")
assert html =~ "Alpha Post"
refute html =~ "Overflow Post"
html =
view
|> form("[data-testid='sidebar-search-form']", %{sidebar_filters: %{search: "Alpha"}})
|> render_change()
assert html =~ "Alpha Post"
refute html =~ ~s(data-open-title="Beta Post")
html =
view
|> element("[data-testid='sidebar-clear-search']")
|> render_click()
assert html =~ "Beta Post"
html =
view
|> element("[data-testid='sidebar-filter-tag'][data-filter-tag='tech']")
|> render_click()
assert html =~ "Alpha Post"
refute html =~ ~s(data-open-title="Beta Post")
html =
view
|> element("[data-testid='sidebar-clear-filters']")
|> render_click()
assert html =~ "Beta Post"
html =
view
|> element("[data-testid='sidebar-load-more']")
|> render_click()
assert html =~ "Overflow Post"
end
test "project switcher, ui language, dashboard recents, and output log are wired", %{temp_dir: temp_dir} do
{:ok, other_project} = Projects.create_project(%{name: "Second Blog", data_path: Path.join(temp_dir, "second")})
{:ok, recent_post} = Posts.create_post(%{project_id: other_project.id, title: "Recent Shell Post", content: "body"})
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ "Shell Project"
refute html =~ "Second Blog"
html =
view
|> element("[data-testid='project-selector-trigger']")
|> render_click()
assert html =~ ~s(data-testid="project-dropdown")
assert html =~ "Second Blog"
html =
view
|> element("[data-testid='project-item'][data-project-id='#{other_project.id}']")
|> render_click()
assert html =~ "Second Blog"
html =
view
|> form("[data-testid='status-language-form']", %{ui_language: "de"})
|> render_change()
assert html =~ "Beiträge durchsuchen..."
html =
view
|> element("[data-testid='recent-post-item'][data-post-id='#{recent_post.id}']")
|> render_click()
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="#{recent_post.id}")
assert html =~ "Recent Shell Post"
html =
render_click(view, "select_panel_tab", %{"tab" => "output"})
assert html =~ "Activated Second Blog"
end
test "task button opens tasks and post panels render real link and git data", %{project: project, temp_dir: temp_dir} do
{:ok, target} = Posts.create_post(%{project_id: project.id, title: "Target Post", content: "target body"})
{:ok, target} = Posts.publish_post(target.id)
target_href = canonical_post_href(target)
{:ok, source} =
Posts.create_post(%{project_id: project.id, title: "Linking Source", content: "See [Target](#{target_href})"})
{:ok, source} = Posts.publish_post(source.id)
:ok = Posts.rebuild_post_links(project.id)
init_git_repo!(temp_dir, "Add published posts")
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => target.id,
"title" => "Target Post",
"subtitle" => "published"
})
assert html =~ "Target Post"
html = render_click(view, "select_panel_tab", %{"tab" => "post_links"})
assert html =~ "Backlinks"
assert html =~ source.title
html = render_click(view, "select_panel_tab", %{"tab" => "git_log"})
assert html =~ "Add published posts"
html = render_click(view, "select_panel_tab", %{"tab" => "output"})
refute html =~ ~s(class="panel-shell is-hidden")
html =
view
|> element("[data-testid='status-task-button']")
|> render_click()
refute html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(class="panel-tab active")
assert html =~ "No background tasks running"
end
defp seed_sidebar_posts(project_id) do
now = Persistence.now_ms()
entries =
[
sidebar_post(project_id, "alpha-post", "Alpha Post", now + 3_000, ["tech"], ["notes"]),
sidebar_post(project_id, "beta-post", "Beta Post", now + 2_000, ["design"], ["guides"])
] ++
Enum.map(1..498, fn index ->
sidebar_post(project_id, "filler-#{index}", "Filler #{index}", now - index, ["filler"], ["archive"])
end) ++
[sidebar_post(project_id, "overflow-post", "Overflow Post", now - 10_000, ["tech"], ["notes"])]
{count, _rows} = Repo.insert_all(Post, entries)
assert count == length(entries)
end
defp sidebar_post(project_id, slug, title, timestamp, tags, categories) do
%{
id: Ecto.UUID.generate(),
project_id: project_id,
title: title,
slug: slug,
excerpt: nil,
content: nil,
status: :published,
author: nil,
created_at: timestamp,
updated_at: timestamp,
published_at: timestamp,
file_path: "posts/#{slug}.md",
checksum: nil,
tags: tags,
categories: categories,
template_slug: nil,
language: "en",
do_not_translate: false,
published_title: nil,
published_content: nil,
published_tags: nil,
published_categories: nil,
published_excerpt: nil
}
end
defp canonical_post_href(post) do
datetime = DateTime.from_unix!(post.created_at, :millisecond)
Path.join([
"",
Integer.to_string(datetime.year),
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
String.pad_leading(Integer.to_string(datetime.day), 2, "0"),
post.slug,
""
])
end
defp init_git_repo!(project_dir, message) do
run_git!(project_dir, ["init", "-b", "master"])
run_git!(project_dir, ["config", "user.name", "bDS Tests"])
run_git!(project_dir, ["config", "user.email", "tests@example.com"])
run_git!(project_dir, ["add", "-A"])
run_git!(project_dir, ["commit", "-m", message])
end
defp run_git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
assert status == 0, output
end
end