diff --git a/PLAN.md b/PLAN.md index b6ddb54..f04097b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,10 +4,10 @@ This document tracks the current implementation state of bDS2 against the Allium ## Open Work Summary -- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10. -- Open plan steps: 11, 12. -- Next actionable step: 11. The remaining open parity backlog now starts with desktop-side CLI mutation watching. -- Scheduled after the current parity pass: 12 import execution/editor parity. +- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11. +- Open plan steps: 12. +- Next actionable step: 12. The remaining open parity backlog is now import execution/editor parity. +- Scheduled after the current parity pass: none. ## 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. - 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, 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. ### 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 - 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. ## 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. 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. - 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. +11. Restore desktop-side CLI mutation watching parity. Completed 2026-04-29. + 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. 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. diff --git a/lib/bds/application.ex b/lib/bds/application.ex index 72df72b..35391bb 100644 --- a/lib/bds/application.ex +++ b/lib/bds/application.ex @@ -17,7 +17,7 @@ defmodule BDS.Application do def desktop_children(_env) 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 [] end diff --git a/lib/bds/cli_sync/watcher.ex b/lib/bds/cli_sync/watcher.ex new file mode 100644 index 0000000..a30b600 --- /dev/null +++ b/lib/bds/cli_sync/watcher.ex @@ -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 diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index ca86e8b..0c9d660 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive do import Phoenix.HTML alias BDS.AI + alias BDS.CliSync.Watcher alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents @@ -47,6 +48,7 @@ defmodule BDS.Desktop.ShellLive do connected = connected?(socket) if connected do + Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic()) :timer.send_interval(@refresh_interval, :refresh_task_status) end @@ -1163,6 +1165,10 @@ defmodule BDS.Desktop.ShellLive do {:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)} 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 raw_task_status = BDS.Tasks.status_snapshot() @@ -1252,6 +1258,117 @@ defmodule BDS.Desktop.ShellLive do |> assign_misc_editor() 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 case assigns.workbench.panel.active_tab do :tasks -> render_task_entries(assigns) diff --git a/test/bds/cli_sync_test.exs b/test/bds/cli_sync_test.exs index d05421c..ae159df 100644 --- a/test/bds/cli_sync_test.exs +++ b/test/bds/cli_sync_test.exs @@ -4,6 +4,7 @@ defmodule BDS.CliSyncTest do import Ecto.Query alias BDS.CliSync + alias BDS.CliSync.Watcher alias BDS.Repo setup do @@ -24,6 +25,25 @@ defmodule BDS.CliSyncTest do assert is_integer(seen_notification.seen_at) 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 now = BDS.Persistence.now_ms() diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index a3663d3..4883933 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLiveTest do alias BDS.Persistence alias BDS.AI + alias BDS.CliSync.Watcher alias BDS.Menu alias BDS.Media alias BDS.Metadata @@ -203,6 +204,71 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-tab-id="#{created_definition.id}") 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 {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)