From fd1b8e7bd43adcf81bb7088c0c025ad6980728d3 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 26 Apr 2026 07:23:48 +0200 Subject: [PATCH] fix: hopefully shell now parity with old Co-authored-by: Copilot --- PLAN.md | 6 +- lib/bds/desktop/automation.ex | 9 + lib/bds/desktop/shell_data.ex | 29 +- lib/bds/desktop/shell_live.ex | 697 ++++++++++++++++++++- lib/bds/desktop/shell_live/index.html.heex | 62 +- lib/bds/git.ex | 11 + priv/ui/live.js | 31 + scripts/desktop_automation_runner.mjs | 9 + test/bds/desktop/automation_test.exs | 22 + test/bds/desktop/shell_live_test.exs | 236 +++++++ 10 files changed, 1079 insertions(+), 33 deletions(-) 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 %> + + +
+ <% end %> + + <%= if @filter_panel_visible do %> + <%= if Enum.any?(Map.get(@sidebar_filters_config, :year_month_counts, [])) do %> +
+
+ <%= translated(@sidebar_filters_config.archive_label) %> + <%= if Map.get(@selected_filters, :year) do %> + + <% end %> +
+
+ <%= for entry <- Map.get(@sidebar_filters_config, :year_month_counts, []) do %> + + <% end %> +
+
+ <% end %> + +
+ <%= if Enum.any?(Map.get(@sidebar_filters_config, :available_tags, [])) do %> +
+
<%= translated(@sidebar_filters_config.tags_label) %>
+
+ <%= for tag <- Map.get(@sidebar_filters_config, :available_tags, []) do %> + + <% end %> +
+
+ <% end %> + + <%= if Enum.any?(Map.get(@sidebar_filters_config, :available_categories, [])) do %> +
+
<%= translated(@sidebar_filters_config.categories_label) %>
+
+ <%= for category <- Map.get(@sidebar_filters_config, :available_categories, []) do %> + + <% 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 %> + + <% end %> + <% end %> + + <%= if Enum.any?(@outlinks) do %> +
<%= translated("Links To") %>
+ <%= for entry <- @outlinks do %> + + <% 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 %> - + + <%= if @project_menu_open do %> +
+
+ <%= translated("Projects") %> +
+
+ <%= for project <- @projects.projects do %> + + <% end %> +
+ +
+ <% end %>
- -