diff --git a/PLAN.md b/PLAN.md
index 44d2aa4..ea850fa 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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.
diff --git a/lib/bds/desktop/automation.ex b/lib/bds/desktop/automation.ex
index 97ce83f..fdce0a4 100644
--- a/lib/bds/desktop/automation.ex
+++ b/lib/bds/desktop/automation.ex
@@ -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}
diff --git a/lib/bds/desktop/shell_data.ex b/lib/bds/desktop/shell_data.ex
index 8ffce8f..f1dd64c 100644
--- a/lib/bds/desktop/shell_data.ex
+++ b/lib/bds/desktop/shell_data.ex
@@ -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",
diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex
index 4015490..ace2c65 100644
--- a/lib/bds/desktop/shell_live.ex
+++ b/lib/bds/desktop/shell_live.ex
@@ -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, ¬ &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"""
+
+
+ <%= if Map.get(@sidebar_filters_config, :has_active_filters) do %>
+
+
+ <%= translated(@sidebar_filters_config.results_label) %>: <%= @sidebar_filters_config.loaded_count %>/<%= @sidebar_filters_config.total_count %>
+
+
+ <%= translated(@sidebar_filters_config.clear_filters_label) %>
+
+
+ <% end %>
+
+ <%= if @filter_panel_visible do %>
+ <%= if Enum.any?(Map.get(@sidebar_filters_config, :year_month_counts, [])) do %>
+
+
+
+ <%= for entry <- Map.get(@sidebar_filters_config, :year_month_counts, []) do %>
+
+ <%= ShellData.format_dashboard_month(entry.year, entry.month) %> <%= entry.year %>
+ <%= entry.count %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= if Enum.any?(Map.get(@sidebar_filters_config, :available_tags, [])) do %>
+
+
+
+ <%= for tag <- Map.get(@sidebar_filters_config, :available_tags, []) do %>
+
+ <%= tag %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if Enum.any?(Map.get(@sidebar_filters_config, :available_categories, [])) do %>
+
+
+
+ <%= for category <- Map.get(@sidebar_filters_config, :available_categories, []) do %>
+
+ <%= category %>
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+ <%= if Map.get(@sidebar_filters_config, :has_more) do %>
+
+ <% 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"""
-
- <%= translated("Output") %>
- <%= translated("No shell output yet") %>
-
+ <%= if Enum.empty?(@output_entries) do %>
+
+ <%= translated("Output") %>
+ <%= translated("No shell output yet") %>
+
+ <% else %>
+
+ <%= for entry <- @output_entries do %>
+
+ <%= entry.title %>
+ <%= entry.message %>
+ <%= if present?(entry.details) do %>
+ <%= entry.details %>
+ <% end %>
+
+ <% end %>
+
+ <% 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 %>
+
+ <%= translated("Post Links") %>
+ <%= translated("No post links yet") %>
+
+ <% else %>
+
+ <%= if Enum.any?(@backlinks) do %>
+
<%= translated("Backlinks") %>
+ <%= for entry <- @backlinks do %>
+
+ <%= entry.title %>
+ <%= entry.text %>
+
+ <% end %>
+ <% end %>
+
+ <%= if Enum.any?(@outlinks) do %>
+
<%= translated("Links To") %>
+ <%= for entry <- @outlinks do %>
+
+ <%= entry.title %>
+ <%= entry.text %>
+
+ <% end %>
+ <% end %>
+
+ <% end %>
"""
end
defp render_git_log(assigns) do
+ entries = git_log_entries(assigns)
+ assigns = assign(assigns, :git_entries, entries)
+
~H"""
-
-
-
<%= translated("Git Log") %>
-
<%= translated("Working tree integration is not wired yet in the shell, but the tab is selectable and ready for command output.") %>
+ <%= if Enum.empty?(@git_entries) do %>
+
+
+ <%= translated("Git Log") %>
+ <%= translated("No git history yet") %>
+
-
+ <% else %>
+
+ <%= for entry <- @git_entries do %>
+
+ <%= short_commit_hash(entry.hash) %> <%= entry.subject || translated("No commit subject") %>
+ <%= entry.hash %>
+
+ <% end %>
+
+ <% 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)
diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex
index 9133e20..f1ca0f8 100644
--- a/lib/bds/desktop/shell_live/index.html.heex
+++ b/lib/bds/desktop/shell_live/index.html.heex
@@ -99,6 +99,7 @@
<%= String.upcase(sidebar_header_label(@sidebar_header)) %>
+ <%= render_sidebar_filters(assigns) %>
<%= render_sidebar_body(assigns) %>
@@ -232,7 +233,17 @@
<%= translated("dashboard.section.recentlyUpdated") %>
<%= for post <- @dashboard_recent_posts do %>
-
+
<%= post.title || "" %>
<%= ShellData.dashboard_status_label(post.status || "draft") %>
<%= ShellData.format_dashboard_date(post.updated_at) %>
@@ -329,7 +340,13 @@
-
+
@@ -338,8 +355,41 @@
+
+ <%= if @project_menu_open do %>
+
+
+
+ <%= for project <- @projects.projects do %>
+
+ <%= project.name %>
+ <%= if project.id == @projects.active_project_id do %>
+ ✓
+ <% end %>
+
+ <% end %>
+
+
+
+ <% end %>
-
+
<%= @status.left.running_task_message || translated("Idle") %>
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
+<%= @status.left.running_task_overflow %>
@@ -351,14 +401,14 @@
<%= @status.right.media_count %>
<%= @status.right.theme_badge %>
✈
-
+
+
<%= @status.right.brand %>
diff --git a/lib/bds/git.ex b/lib/bds/git.ex
index 4d25eaf..01c9619 100644
--- a/lib/bds/git.ex
+++ b/lib/bds/git.ex
@@ -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
diff --git a/priv/ui/live.js b/priv/ui/live.js
index 47036bf..2c8b99f 100644
--- a/priv/ui/live.js
+++ b/priv/ui/live.js
@@ -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 });
+ }
}
},
diff --git a/scripts/desktop_automation_runner.mjs b/scripts/desktop_automation_runner.mjs
index ae20397..5124b32 100644
--- a/scripts/desktop_automation_runner.mjs
+++ b/scripts/desktop_automation_runner.mjs
@@ -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();
diff --git a/test/bds/desktop/automation_test.exs b/test/bds/desktop/automation_test.exs
index 018b8b0..af004cd 100644
--- a/test/bds/desktop/automation_test.exs
+++ b/test/bds/desktop/automation_test.exs
@@ -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
diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs
index 61bf442..0c25b13 100644
--- a/test/bds/desktop/shell_live_test.exs
+++ b/test/bds/desktop/shell_live_test.exs
@@ -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