fix: hopefully shell now parity with old
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
6
PLAN.md
6
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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
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.
|
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.
|
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, 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.
|
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.
|
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.
|
Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows.
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ defmodule BDS.Desktop.Automation do
|
|||||||
GenServer.call(session, {:press, shortcut}, @request_timeout)
|
GenServer.call(session, {:press, shortcut}, @request_timeout)
|
||||||
end
|
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
|
def reload(session) do
|
||||||
GenServer.call(session, :reload, @request_timeout)
|
GenServer.call(session, :reload, @request_timeout)
|
||||||
end
|
end
|
||||||
@@ -102,6 +106,11 @@ defmodule BDS.Desktop.Automation do
|
|||||||
{:reply, normalize_simple_reply(reply), state}
|
{:reply, normalize_simple_reply(reply), state}
|
||||||
end
|
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
|
def handle_call(:reload, _from, state) do
|
||||||
{reply, state} = driver_request(state, %{"command" => "reload"})
|
{reply, state} = driver_request(state, %{"command" => "reload"})
|
||||||
{:reply, normalize_simple_reply(reply), state}
|
{:reply, normalize_simple_reply(reply), state}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
I18n.current_ui_locale()
|
I18n.current_ui_locale()
|
||||||
end
|
end
|
||||||
|
|
||||||
def translations do
|
def translations(locale \\ nil) do
|
||||||
I18n.get_ui_translations(ui_language())
|
I18n.get_ui_translations(effective_ui_language(locale))
|
||||||
end
|
end
|
||||||
|
|
||||||
def supported_ui_languages do
|
def supported_ui_languages do
|
||||||
@@ -25,8 +25,8 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def translate(key, bindings \\ %{}) do
|
def translate(key, bindings \\ %{}, locale \\ nil) do
|
||||||
text = Map.get(translations(), to_string(key), to_string(key))
|
text = Map.get(translations(locale), to_string(key), to_string(key))
|
||||||
|
|
||||||
Enum.reduce(bindings, text, fn {binding, value}, acc ->
|
Enum.reduce(bindings, text, fn {binding, value}, acc ->
|
||||||
String.replace(acc, "%{#{binding}}", to_string(value))
|
String.replace(acc, "%{#{binding}}", to_string(value))
|
||||||
@@ -60,15 +60,15 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
Dashboard.empty_snapshot()
|
Dashboard.empty_snapshot()
|
||||||
end
|
end
|
||||||
|
|
||||||
def sidebar_view(project_id, view_id) do
|
def sidebar_view(project_id, view_id, params \\ %{}) do
|
||||||
Sidebar.view(project_id, view_id, %{})
|
Sidebar.view(project_id, view_id, params)
|
||||||
rescue
|
rescue
|
||||||
error in [Exqlite.Error, DBConnection.OwnershipError] ->
|
error in [Exqlite.Error, DBConnection.OwnershipError] ->
|
||||||
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
|
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
|
||||||
reraise error, __STACKTRACE__
|
reraise error, __STACKTRACE__
|
||||||
end
|
end
|
||||||
|
|
||||||
Sidebar.view(nil, view_id, %{})
|
Sidebar.view(nil, view_id, params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def assistant_cards do
|
def assistant_cards do
|
||||||
@@ -101,7 +101,10 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def panel_tabs(workbench) do
|
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()
|
|> Enum.uniq()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -209,6 +212,16 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
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
|
defp default_project_snapshot do
|
||||||
%{
|
%{
|
||||||
active_project_id: "default",
|
active_project_id: "default",
|
||||||
|
|||||||
@@ -5,12 +5,30 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
import Phoenix.HTML
|
import Phoenix.HTML
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.{FolderPicker, ShellCommands, ShellData}
|
||||||
alias BDS.UI.Commands
|
alias BDS.Git
|
||||||
alias BDS.UI.Registry
|
alias BDS.Media.Media
|
||||||
alias BDS.UI.Workbench
|
alias BDS.PostLinks
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Projects
|
||||||
|
alias BDS.Repo
|
||||||
|
alias BDS.UI.{Commands, MenuBar, Registry, Workbench}
|
||||||
|
|
||||||
@refresh_interval 1_500
|
@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/*"
|
embed_templates "shell_live/*"
|
||||||
|
|
||||||
@@ -28,6 +46,10 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:page_language, ShellData.ui_language())
|
|> assign(:page_language, ShellData.ui_language())
|
||||||
|> assign(:offline_mode, true)
|
|> assign(:offline_mode, true)
|
||||||
|> assign(:tab_meta, %{})
|
|> assign(:tab_meta, %{})
|
||||||
|
|> assign(:project_menu_open, false)
|
||||||
|
|> assign(:sidebar_filters_by_view, %{})
|
||||||
|
|> assign(:sidebar_filter_panels, %{})
|
||||||
|
|> assign(:output_entries, [])
|
||||||
|> reload_shell(workbench)}
|
|> reload_shell(workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -74,6 +96,88 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, reload_shell(socket, resize_panel(socket.assigns.workbench, target, width))}
|
{:noreply, reload_shell(socket, resize_panel(socket.assigns.workbench, target, width))}
|
||||||
end
|
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
|
def handle_event("shortcut", params, socket) do
|
||||||
if ignore_shortcut?(params) do
|
if ignore_shortcut?(params) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
@@ -105,6 +209,72 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, reload_shell(socket, socket.assigns.workbench)}
|
{:noreply, reload_shell(socket, socket.assigns.workbench)}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_info(:refresh_task_status, socket) do
|
def handle_info(:refresh_task_status, socket) do
|
||||||
task_status = BDS.Tasks.status_snapshot()
|
task_status = BDS.Tasks.status_snapshot()
|
||||||
@@ -123,12 +293,17 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
defp reload_shell(socket, workbench) do
|
||||||
projects = ShellData.project_snapshot()
|
projects = ShellData.project_snapshot()
|
||||||
dashboard = ShellData.dashboard(projects.active_project_id)
|
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()
|
task_status = BDS.Tasks.status_snapshot()
|
||||||
activity_buttons = Workbench.activity_buttons(workbench, 0)
|
activity_buttons = Workbench.activity_buttons(workbench, 0)
|
||||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||||
@@ -161,6 +336,150 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:current_tab, current_tab(workbench))
|
|> assign(:current_tab, current_tab(workbench))
|
||||||
end
|
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
|
defp render_sidebar_body(assigns) do
|
||||||
case assigns.sidebar_data.layout do
|
case assigns.sidebar_data.layout do
|
||||||
"post_list" -> render_post_sidebar(assigns)
|
"post_list" -> render_post_sidebar(assigns)
|
||||||
@@ -340,6 +659,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
case assigns.workbench.panel.active_tab do
|
case assigns.workbench.panel.active_tab do
|
||||||
:tasks -> render_task_entries(assigns)
|
:tasks -> render_task_entries(assigns)
|
||||||
:output -> render_output_entries(assigns)
|
:output -> render_output_entries(assigns)
|
||||||
|
:post_links -> render_post_links(assigns)
|
||||||
:git_log -> render_git_log(assigns)
|
:git_log -> render_git_log(assigns)
|
||||||
other -> render_generic_panel(assigns, other)
|
other -> render_generic_panel(assigns, other)
|
||||||
end
|
end
|
||||||
@@ -370,21 +690,109 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp render_output_entries(assigns) do
|
defp render_output_entries(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
<%= if Enum.empty?(@output_entries) do %>
|
||||||
<div class="panel-entry panel-empty-state output-list">
|
<div class="panel-entry panel-empty-state output-list">
|
||||||
<strong><%= translated("Output") %></strong>
|
<strong><%= translated("Output") %></strong>
|
||||||
<span><%= translated("No shell output yet") %></span>
|
<span><%= translated("No shell output yet") %></span>
|
||||||
</div>
|
</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
|
end
|
||||||
|
|
||||||
defp render_git_log(assigns) do
|
defp render_git_log(assigns) do
|
||||||
|
entries = git_log_entries(assigns)
|
||||||
|
assigns = assign(assigns, :git_entries, entries)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
|
<%= if Enum.empty?(@git_entries) do %>
|
||||||
<div class="git-log-list">
|
<div class="git-log-list">
|
||||||
<div class="panel-entry">
|
<div class="panel-entry panel-empty-state">
|
||||||
<strong><%= translated("Git Log") %></strong>
|
<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>
|
<span><%= translated("No git history yet") %></span>
|
||||||
</div>
|
</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
|
end
|
||||||
|
|
||||||
@@ -399,7 +807,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
"""
|
"""
|
||||||
end
|
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(:tasks), do: translated("Tasks")
|
||||||
defp panel_tab_label(:output), do: translated("Output")
|
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 sidebar_header_label(label), do: translated(label)
|
||||||
|
|
||||||
|
defp present?(value), do: value not in [nil, ""]
|
||||||
|
|
||||||
defp timeline_height(entry, entries) do
|
defp timeline_height(entry, entries) do
|
||||||
max_count =
|
max_count =
|
||||||
entries
|
entries
|
||||||
@@ -511,6 +921,261 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> reload_shell(workbench)
|
|> reload_shell(workbench)
|
||||||
end
|
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_atom(route), do: route
|
||||||
defp sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route)
|
defp sidebar_route_atom(route) when is_binary(route), do: String.to_existing_atom(route)
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
|
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<%= render_sidebar_filters(assigns) %>
|
||||||
<%= render_sidebar_body(assigns) %>
|
<%= render_sidebar_body(assigns) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +233,17 @@
|
|||||||
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
|
<h4><%= translated("dashboard.section.recentlyUpdated") %></h4>
|
||||||
<div class="recent-posts-list">
|
<div class="recent-posts-list">
|
||||||
<%= for post <- @dashboard_recent_posts do %>
|
<%= 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-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-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>
|
<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">
|
<footer class="status-bar" data-region="status-bar" data-testid="status-bar">
|
||||||
<div class="status-bar-left">
|
<div class="status-bar-left">
|
||||||
<div class="project-selector">
|
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
@@ -338,8 +355,41 @@
|
|||||||
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
|
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="toggle_panel">
|
<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="open_tasks_panel">
|
||||||
<span><%= @status.left.running_task_message || translated("Idle") %></span>
|
<span><%= @status.left.running_task_message || translated("Idle") %></span>
|
||||||
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
|
<%= if (@status.left.running_task_overflow || 0) > 0 do %>
|
||||||
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
|
<span class="status-bar-count">+<%= @status.left.running_task_overflow %></span>
|
||||||
@@ -351,14 +401,14 @@
|
|||||||
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
<span class="status-bar-item"><%= @status.right.media_count %></span>
|
||||||
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></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>
|
<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>
|
<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 %>
|
<%= for language <- @supported_ui_languages do %>
|
||||||
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
|
<option selected={language.code == @page_language} value={language.code}><%= language.flag %></option>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</form>
|
||||||
<span class="status-bar-item brand"><%= @status.right.brand %></span>
|
<span class="status-bar-item brand"><%= @status.right.brand %></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -112,6 +112,17 @@ defmodule BDS.Git do
|
|||||||
end
|
end
|
||||||
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
|
def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
|
||||||
with {:ok, project_dir} <- project_dir(project_id) do
|
with {:ok, project_dir} <- project_dir(project_id) do
|
||||||
case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do
|
case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
|
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
|
||||||
const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-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));
|
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
AppShell: {
|
AppShell: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.syncStoredLayout();
|
this.syncStoredLayout();
|
||||||
|
this.syncStoredUiLanguage();
|
||||||
|
|
||||||
this.handleMouseDown = (event) => {
|
this.handleMouseDown = (event) => {
|
||||||
const handle = event.target.closest("[data-role='resize-handle']");
|
const handle = event.target.closest("[data-role='resize-handle']");
|
||||||
@@ -99,10 +101,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.el.addEventListener("mousedown", this.handleMouseDown);
|
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() {
|
destroyed() {
|
||||||
this.el.removeEventListener("mousedown", this.handleMouseDown);
|
this.el.removeEventListener("mousedown", this.handleMouseDown);
|
||||||
|
this.el.removeEventListener("change", this.handleChange);
|
||||||
|
window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
},
|
},
|
||||||
|
|
||||||
syncStoredLayout() {
|
syncStoredLayout() {
|
||||||
@@ -110,6 +133,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500),
|
sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500),
|
||||||
assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640)
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ for await (const line of rl) {
|
|||||||
continue;
|
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") {
|
if (message.command === "drag") {
|
||||||
const locator = page.locator(message.selector);
|
const locator = page.locator(message.selector);
|
||||||
const box = await locator.boundingBox();
|
const box = await locator.boundingBox();
|
||||||
|
|||||||
@@ -120,6 +120,28 @@ defmodule BDS.Desktop.AutomationTest do
|
|||||||
assert automation_process_counts() == baseline
|
assert automation_process_counts() == baseline
|
||||||
end
|
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
|
defp os_pid_alive?(pid) do
|
||||||
case System.cmd("kill", ["-0", Integer.to_string(pid)], stderr_to_stdout: true) do
|
case System.cmd("kill", ["-0", Integer.to_string(pid)], stderr_to_stdout: true) do
|
||||||
{_, 0} -> true
|
{_, 0} -> true
|
||||||
|
|||||||
@@ -4,8 +4,29 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
import Phoenix.ConnTest
|
import Phoenix.ConnTest
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias BDS.Persistence
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Projects
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
@endpoint BDS.Desktop.Endpoint
|
@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
|
test "shell live owns pane visibility and activity selection on the server" do
|
||||||
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
{: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(data-testid="sidebar-shell")
|
||||||
assert html =~ ~s(style="width: 460px;")
|
assert html =~ ~s(style="width: 460px;")
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user