feat: step 11 done

This commit is contained in:
2026-04-29 19:24:45 +02:00
parent 4ae6c55e83
commit 155fda8b81
6 changed files with 284 additions and 9 deletions

15
PLAN.md
View File

@@ -4,10 +4,10 @@ This document tracks the current implementation state of bDS2 against the Allium
## Open Work Summary ## Open Work Summary
- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10. - Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11.
- Open plan steps: 11, 12. - Open plan steps: 12.
- Next actionable step: 11. The remaining open parity backlog now starts with desktop-side CLI mutation watching. - Next actionable step: 12. The remaining open parity backlog is now import execution/editor parity.
- Scheduled after the current parity pass: 12 import execution/editor parity. - Scheduled after the current parity pass: none.
## Current State ## Current State
@@ -20,7 +20,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, 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, browser-native menu bridging, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions. - 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, browser-native menu bridging, desktop-side CLI mutation watching/broadcasting, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions.
- Dedicated editor surfaces now exist for posts, media, settings, style, tags, scripts, templates, chat, and the misc maintenance views, with focused shell-live tests covering real rendering and core save/preview/publish flows. - Dedicated editor surfaces now exist for posts, media, settings, style, tags, scripts, templates, chat, and the misc maintenance views, with focused shell-live tests covering real rendering and core save/preview/publish flows.
### Implemented But Not Yet At Parity ### Implemented But Not Yet At Parity
@@ -31,7 +31,6 @@ The rewrite already implements most of the backend and compatibility-critical su
### Missing Or Materially Incomplete ### Missing Or Materially Incomplete
- Import remains definition-only: stored import definitions exist, but the old WXR analysis/execution pipeline and its dedicated editor surface are not present. - Import remains definition-only: stored import definitions exist, but the old WXR analysis/execution pipeline and its dedicated editor surface are not present.
- CLI sync notification persistence exists, but old-app parity for desktop-side watching/broadcasting of external DB mutations is not yet proven.
- The remaining parity write-up gap is now keeping the chat row and the final minimal backlog in sync with the current implementation. - The remaining parity write-up gap is now keeping the chat row and the final minimal backlog in sync with the current implementation.
## Spec Coverage Snapshot ## Spec Coverage Snapshot
@@ -81,8 +80,8 @@ Only these two audit tracks matter for the current pass. The follow-on missing-f
10. Restore menu editor parity on the implemented data model. Completed 2026-04-29. 10. Restore menu editor parity on the implemented data model. Completed 2026-04-29.
The shell now renders a dedicated menu editor with old-app-inspired structure, toolbar flow, inline page/category insertion, drag/drop plus move/indent controls, localized copy, and focused shell-live coverage on the existing menu persistence model. The shell now renders a dedicated menu editor with old-app-inspired structure, toolbar flow, inline page/category insertion, drag/drop plus move/indent controls, localized copy, and focused shell-live coverage on the existing menu persistence model.
11. Restore desktop-side CLI mutation watching parity. 11. Restore desktop-side CLI mutation watching parity. Completed 2026-04-29.
Add the old desktop-side watching and invalidation behavior for external database mutations so CLI sync notifications propagate through the shell with the same timing and UX expectations as the old app. A supervised CLI-sync watcher now polls the persisted notification store on the old-app timing budget, broadcasts `entity:changed` events through PubSub, and the LiveView shell refreshes sidebar/editor state while closing stale post/media tabs on external deletes.
12. Restore import execution and editor parity. 12. Restore import execution and editor parity.
Extend the existing stored import definitions into the old WXR analysis/execution pipeline and add the dedicated editor surface so import behavior, workflow, and look and feel match the old app. Extend the existing stored import definitions into the old WXR analysis/execution pipeline and add the dedicated editor surface so import behavior, workflow, and look and feel match the old app.

View File

@@ -17,7 +17,7 @@ defmodule BDS.Application do
def desktop_children(_env) do def desktop_children(_env) do
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
[{BDS.Desktop.Server, []} | desktop_window_children()] [{BDS.Desktop.Server, []}, BDS.CliSync.Watcher | desktop_window_children()]
else else
[] []
end end

View File

@@ -0,0 +1,73 @@
defmodule BDS.CliSync.Watcher do
@moduledoc false
use GenServer
alias BDS.CliSync
@topic "entity:changed"
@default_poll_interval_ms 100
def start_link(opts \\ []) do
case Keyword.pop(opts, :name, __MODULE__) do
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
end
end
def topic, do: @topic
def poll_now(server \\ __MODULE__) do
GenServer.call(server, :poll_now)
end
@impl true
def init(opts) do
state = %{
poll_interval_ms: normalize_positive_integer(Keyword.get(opts, :poll_interval_ms), @default_poll_interval_ms),
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
}
{:ok, schedule_poll(state)}
end
@impl true
def handle_call(:poll_now, _from, state) do
{:reply, :ok, process_notifications(state)}
end
@impl true
def handle_info(:poll, state) do
{:noreply,
state
|> process_notifications()
|> schedule_poll()}
end
defp process_notifications(state) do
{:ok, notifications} = CliSync.db_file_change_detected()
{:ok, _pruned} = CliSync.prune_notifications()
Enum.each(notifications, fn notification ->
Phoenix.PubSub.broadcast(state.pubsub, topic(), {:entity_changed, notification_payload(notification)})
end)
state
end
defp notification_payload(notification) do
%{
entity: notification.entity_type,
entity_id: notification.entity_id,
action: notification.action
}
end
defp schedule_poll(state) do
Process.send_after(self(), :poll, state.poll_interval_ms)
state
end
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
defp normalize_positive_integer(_value, default), do: default
end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML import Phoenix.HTML
alias BDS.AI alias BDS.AI
alias BDS.CliSync.Watcher
alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
@@ -47,6 +48,7 @@ defmodule BDS.Desktop.ShellLive do
connected = connected?(socket) connected = connected?(socket)
if connected do if connected do
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
:timer.send_interval(@refresh_interval, :refresh_task_status) :timer.send_interval(@refresh_interval, :refresh_task_status)
end end
@@ -1163,6 +1165,10 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)} {:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
end end
def handle_info({:entity_changed, payload}, socket) when is_map(payload) do
{:noreply, apply_cli_entity_change(socket, payload)}
end
def handle_info(:refresh_task_status, socket) do def handle_info(:refresh_task_status, socket) do
raw_task_status = BDS.Tasks.status_snapshot() raw_task_status = BDS.Tasks.status_snapshot()
@@ -1252,6 +1258,117 @@ defmodule BDS.Desktop.ShellLive do
|> assign_misc_editor() |> assign_misc_editor()
end end
defp apply_cli_entity_change(socket, payload) do
entity = Map.get(payload, :entity) || Map.get(payload, "entity") || Map.get(payload, :entity_type) || Map.get(payload, "entity_type")
entity_id = Map.get(payload, :entity_id) || Map.get(payload, "entity_id") || Map.get(payload, :entityId) || Map.get(payload, "entityId")
action = normalize_cli_entity_action(Map.get(payload, :action) || Map.get(payload, "action"))
if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and action in [:created, :updated, :deleted] do
{socket, workbench} = maybe_close_deleted_cli_tab(socket, entity, entity_id, action)
socket
|> maybe_refresh_cli_tab_meta(entity, entity_id, action)
|> reload_shell(workbench)
else
socket
end
end
defp maybe_close_deleted_cli_tab(socket, "post", post_id, :deleted) do
workbench = Workbench.close_tab(socket.assigns.workbench, :post, post_id)
socket =
socket
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id))
|> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id))
|> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id))
|> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id))
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id))
{socket, workbench}
end
defp maybe_close_deleted_cli_tab(socket, "media", media_id, :deleted) do
workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id)
socket =
socket
|> assign(:workbench, workbench)
|> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id))
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id))
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id))
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id))
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
{socket, workbench}
end
defp maybe_close_deleted_cli_tab(socket, _entity, _entity_id, _action), do: {socket, socket.assigns.workbench}
defp maybe_refresh_cli_tab_meta(socket, "post", post_id, action) when action in [:created, :updated] do
maybe_put_cli_tab_meta(socket, :post, post_id, fn ->
case Repo.get(Post, post_id) do
%Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status || :draft)}
_other -> nil
end
end)
end
defp maybe_refresh_cli_tab_meta(socket, "media", media_id, action) when action in [:created, :updated] do
maybe_put_cli_tab_meta(socket, :media, media_id, fn ->
case Repo.get(Media, media_id) do
%Media{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"}
_other -> nil
end
end)
end
defp maybe_refresh_cli_tab_meta(socket, _entity, _entity_id, _action), do: socket
defp maybe_put_cli_tab_meta(socket, route, entity_id, meta_fun) do
key = {route, entity_id}
if cli_tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do
case meta_fun.() do
%{} = fresh_meta ->
updated_meta = Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta))
assign(socket, :tab_meta, updated_meta)
_other ->
socket
end
else
socket
end
end
defp cli_tab_present?(%{tabs: tabs}, {route, entity_id}) do
Enum.any?(tabs, &(&1.type == route and &1.id == entity_id))
end
defp normalize_cli_entity_action(action) when action in [:created, :updated, :deleted], do: action
defp normalize_cli_entity_action(action) do
action
|> to_string()
|> String.downcase()
|> case do
"created" -> :created
"updated" -> :updated
"deleted" -> :deleted
_other -> :unknown
end
end
defp render_panel_body(assigns) do defp render_panel_body(assigns) 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)

View File

@@ -4,6 +4,7 @@ defmodule BDS.CliSyncTest do
import Ecto.Query import Ecto.Query
alias BDS.CliSync alias BDS.CliSync
alias BDS.CliSync.Watcher
alias BDS.Repo alias BDS.Repo
setup do setup do
@@ -24,6 +25,25 @@ defmodule BDS.CliSyncTest do
assert is_integer(seen_notification.seen_at) assert is_integer(seen_notification.seen_at)
end end
test "watcher broadcasts entity change events after database mutations are detected" do
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
watcher =
start_supervised!({Watcher, poll_interval_ms: 60_000, debounce_ms: 0})
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), watcher)
assert {:ok, notification} = CliSync.cli_mutation_performed("post", "post-1", :updated)
:ok = Watcher.poll_now(watcher)
assert_receive {:entity_changed, %{entity: "post", entity_id: "post-1", action: :updated}}, 500
seen_notification = Repo.get!(BDS.CliSync.Notification, notification.id)
assert is_integer(seen_notification.seen_at)
end
test "processed notifications are pruned after one hour and unprocessed notifications after one day" do test "processed notifications are pruned after one hour and unprocessed notifications after one day" do
now = BDS.Persistence.now_ms() now = BDS.Persistence.now_ms()

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLiveTest do
alias BDS.Persistence alias BDS.Persistence
alias BDS.AI alias BDS.AI
alias BDS.CliSync.Watcher
alias BDS.Menu alias BDS.Menu
alias BDS.Media alias BDS.Media
alias BDS.Metadata alias BDS.Metadata
@@ -203,6 +204,71 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="#{created_definition.id}") assert html =~ ~s(data-tab-id="#{created_definition.id}")
end end
test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change", %{project: project} do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
refute html =~ "CLI Added Post"
assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Added Post"})
Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :created}})
assert render(view) =~ "CLI Added Post"
end
test "shell live closes stale post and media tabs when the CLI watcher broadcasts deletions", %{project: project, temp_dir: temp_dir} do
assert {:ok, post} = Posts.create_post(%{project_id: project.id, title: "CLI Delete Post"})
source_path = Path.join(temp_dir, "cli-delete-media.txt")
File.write!(source_path, "media body")
assert {:ok, media} =
Media.import_media(%{
project_id: project.id,
source_path: source_path,
title: "CLI Delete Media"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='#{post.id}']")
|> render_click()
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="#{post.id}")
assert {:ok, :deleted} = Posts.delete_post(post.id)
Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "post", entity_id: post.id, action: :deleted}})
html = render(view)
refute html =~ ~s(data-tab-type="post")
refute html =~ "CLI Delete Post"
_html =
view
|> element("[data-testid='activity-button'][data-view='media']")
|> render_click()
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='#{media.id}']")
|> render_click()
assert html =~ ~s(data-tab-type="media")
assert html =~ ~s(data-tab-id="#{media.id}")
assert {:ok, :deleted} = Media.delete_media(media.id)
Phoenix.PubSub.broadcast(BDS.PubSub, Watcher.topic(), {:entity_changed, %{entity: "media", entity_id: media.id, action: :deleted}})
html = render(view)
refute html =~ ~s(data-tab-type="media")
refute html =~ "CLI Delete Media"
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)