feat: PLAN step 3 done

This commit is contained in:
2026-04-25 22:22:27 +02:00
parent 2b1aca4143
commit fac55bfb3b
7 changed files with 154 additions and 17 deletions

View File

@@ -38,7 +38,7 @@ Ordered from base contracts upward:
| Persistence and file contracts | `schema`, `frontmatter`, `project`, `post`, `translation`, `media`, `tag`, `template`, `script`, `menu`, `metadata` | Implemented | Core schemas, file formats, publish flows, sidecars, rebuild, and metadata diff are present and tested. | | Persistence and file contracts | `schema`, `frontmatter`, `project`, `post`, `translation`, `media`, `tag`, `template`, `script`, `menu`, `metadata` | Implemented | Core schemas, file formats, publish flows, sidecars, rebuild, and metadata diff are present and tested. |
| Rendering and output pipelines | `template_context`, `search`, `generation`, `preview`, `publishing`, `task`, `i18n` | Implemented | Rendering, generation, preview, publishing, task tracking, and localization are in place. | | Rendering and output pipelines | `template_context`, `search`, `generation`, `preview`, `publishing`, `task`, `i18n` | Implemented | Rendering, generation, preview, publishing, task tracking, and localization are in place. |
| Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. | | Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. |
| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Partial | Shell frame, sidebar views, tabs, filters, hotkeys, and panel exist, but route content is incomplete. | | Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and shell frame parity are in place; route bodies remain generic until the editor UX phase. |
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. | | Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial to missing | Route registration exists, but feature-complete editors and modal workflows are not done. | | Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals` | Partial to missing | Route registration exists, but feature-complete editors and modal workflows are not done. |
@@ -50,10 +50,10 @@ The remaining work needs to proceed from base contracts upward. Later phases sho
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests. Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
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, 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. 3. Finish the desktop shell primitives. Completed 2026-04-25.
Complete route state, shell command coverage, panel integration, and menu wiring for every sidebar view and editor route so the shell exposes the entire product surface cleanly. Route state, registry-backed shell command coverage, panel fallback integration, and menu/native-command wiring now cover every sidebar view and singleton editor route while preserving the old app shell frame 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.

View File

@@ -85,6 +85,12 @@ defmodule BDS.Posts do
|> Repo.update() |> Repo.update()
|> case do |> case do
{:ok, updated_post} -> {:ok, updated_post} ->
if post.status == :published and updated_post.status == :published and
Map.get(updates, :template_slug) != nil and
updated_post.template_slug != post.template_slug do
:ok = rewrite_published_post(updated_post.id)
end
:ok = Embeddings.sync_post(updated_post) :ok = Embeddings.sync_post(updated_post)
:ok = PostLinks.sync_post_links(updated_post) :ok = PostLinks.sync_post_links(updated_post)
:ok = Search.sync_post(updated_post) :ok = Search.sync_post(updated_post)
@@ -560,7 +566,6 @@ defmodule BDS.Posts do
:content, :content,
:author, :author,
:language, :language,
:template_slug,
:tags, :tags,
:categories, :categories,
:do_not_translate :do_not_translate

View File

@@ -1,6 +1,7 @@
defmodule BDS.UI.MenuBar do defmodule BDS.UI.MenuBar do
@moduledoc false @moduledoc false
alias BDS.UI.Registry
alias BDS.UI.Workbench alias BDS.UI.Workbench
def default_groups(opts \\ []) do def default_groups(opts \\ []) do
@@ -82,8 +83,12 @@ defmodule BDS.UI.MenuBar do
def execute(state, :toggle_sidebar), do: Workbench.toggle_sidebar(state) def execute(state, :toggle_sidebar), do: Workbench.toggle_sidebar(state)
def execute(state, :toggle_panel), do: Workbench.toggle_panel(state) def execute(state, :toggle_panel), do: Workbench.toggle_panel(state)
def execute(state, :toggle_assistant_sidebar), do: Workbench.toggle_assistant_sidebar(state) def execute(state, :toggle_assistant_sidebar), do: Workbench.toggle_assistant_sidebar(state)
def execute(state, :view_posts), do: %{state | active_view: :posts, sidebar_visible: true} def execute(state, :view_posts), do: open_sidebar_view(state, :posts)
def execute(state, :view_media), do: %{state | active_view: :media, sidebar_visible: true} def execute(state, :view_media), do: open_sidebar_view(state, :media)
def execute(state, :edit_preferences), do: open_singleton_editor(state, :settings)
def execute(state, :edit_menu), do: open_singleton_editor(state, :menu_editor)
def execute(state, :documentation), do: open_singleton_editor(state, :documentation)
def execute(state, :api_documentation), do: open_singleton_editor(state, :api_documentation)
def execute(state, :close_tab) do def execute(state, :close_tab) do
case state.active_tab do case state.active_tab do
@@ -92,8 +97,48 @@ defmodule BDS.UI.MenuBar do
end end
end end
def execute(state, command_id) when is_atom(command_id) do
with {:ok, view_id} <- sidebar_view_command(command_id) do
open_sidebar_view(state, view_id)
else
:error ->
case singleton_editor_command(command_id) do
{:ok, route_id} -> open_singleton_editor(state, route_id)
:error -> state
end
end
end
def execute(state, _command_id), do: state def execute(state, _command_id), do: state
defp open_sidebar_view(state, view_id) do
%{state | active_view: view_id, sidebar_visible: true}
end
defp open_singleton_editor(state, route_id) do
Workbench.open_tab(state, route_id, Atom.to_string(route_id), :pin)
end
defp sidebar_view_command(command_id) do
with "view_" <> suffix <- Atom.to_string(command_id),
view_id = String.to_atom(suffix),
%{} <- Registry.sidebar_view(view_id) do
{:ok, view_id}
else
_other -> :error
end
end
defp singleton_editor_command(command_id) do
with "open_" <> suffix <- Atom.to_string(command_id),
route_id = String.to_atom(suffix),
%{singleton: true} <- Registry.editor_route(route_id) do
{:ok, route_id}
else
_other -> :error
end
end
defp view_items(dev_mode?) do defp view_items(dev_mode?) do
items = [ items = [
%{id: :view_posts}, %{id: :view_posts},

View File

@@ -1657,6 +1657,18 @@ function executeShellCommand(action) {
} }
function executeLocalShellCommand(action) { function executeLocalShellCommand(action) {
if (isSidebarViewCommand(action)) {
const viewId = action.slice(5);
state.session.active_view = viewId;
state.session.sidebar_visible = true;
return true;
}
if (isSingletonEditorCommand(action)) {
openSingletonTab(action.slice(5));
return true;
}
switch (action) { switch (action) {
case "toggle_sidebar": case "toggle_sidebar":
state.session.sidebar_visible = !state.session.sidebar_visible; state.session.sidebar_visible = !state.session.sidebar_visible;
@@ -1672,14 +1684,6 @@ function executeLocalShellCommand(action) {
state.session.assistant_sidebar_visible = !state.session.assistant_sidebar_visible; state.session.assistant_sidebar_visible = !state.session.assistant_sidebar_visible;
persistSessionWidths(); persistSessionWidths();
return true; return true;
case "view_posts":
state.session.active_view = "posts";
state.session.sidebar_visible = true;
return true;
case "view_media":
state.session.active_view = "media";
state.session.sidebar_visible = true;
return true;
case "close_tab": case "close_tab":
closeActiveTab(); closeActiveTab();
return true; return true;
@@ -1711,6 +1715,21 @@ function executeLocalShellCommand(action) {
} }
} }
function isSidebarViewCommand(action) {
return typeof action === "string"
&& action.startsWith("view_")
&& sidebarViews().some((view) => view.id === action.slice(5));
}
function isSingletonEditorCommand(action) {
if (typeof action !== "string" || !action.startsWith("open_")) {
return false;
}
const route = bootstrap.registry.editor_routes.find((item) => item.id === action.slice(5));
return Boolean(route?.singleton);
}
async function executeBackendShellCommand(action) { async function executeBackendShellCommand(action) {
try { try {
const response = await fetch("/api/commands", { const response = await fetch("/api/commands", {

View File

@@ -104,6 +104,35 @@ defmodule BDS.PostsTest do
assert reopened.updated_at >= published.updated_at assert reopened.updated_at >= published.updated_at
end end
test "update_post keeps published posts published and rewrites the file when only template_slug changes",
%{project: project, temp_dir: temp_dir} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Template Rewrite",
content: "Body",
template_slug: "article"
})
assert {:ok, published} = BDS.Posts.publish_post(post.id)
full_path = Path.join(temp_dir, published.file_path)
original_contents = File.read!(full_path)
assert original_contents =~ "templateSlug: article\n"
assert {:ok, updated} =
BDS.Posts.update_post(post.id, %{template_slug: "landing-page"})
assert updated.status == :published
assert updated.template_slug == "landing-page"
assert updated.file_path == published.file_path
rewritten_contents = File.read!(full_path)
assert rewritten_contents =~ "templateSlug: landing-page\n"
refute rewritten_contents =~ "templateSlug: article\n"
end
test "publish_post writes frontmatter to the project data directory and clears draft content" do test "publish_post writes frontmatter to the project data directory and clears draft content" do
temp_dir = temp_dir =
Path.join(System.tmp_dir!(), "bds-post-publish-#{System.unique_integer([:positive])}") Path.join(System.tmp_dir!(), "bds-post-publish-#{System.unique_integer([:positive])}")

View File

@@ -351,8 +351,11 @@ defmodule BDS.UI.ShellTest do
assert js =~ "case \"1\"" assert js =~ "case \"1\""
assert js =~ "case \"2\"" assert js =~ "case \"2\""
assert js =~ "case \"\\\\\"" assert js =~ "case \"\\\\\""
assert js =~ "case \"view_posts\"" assert js =~ "function isSidebarViewCommand(action)"
assert js =~ "case \"view_media\"" assert js =~ "function isSingletonEditorCommand(action)"
assert js =~ "action.startsWith(\"view_\")"
assert js =~ "action.startsWith(\"open_\")"
assert js =~ "openSingletonTab(action.slice(5));"
assert js =~ "executeBackendShellCommand(action)" assert js =~ "executeBackendShellCommand(action)"
assert js =~ "case \"metadata_diff\"" assert js =~ "case \"metadata_diff\""
assert js =~ "case \"regenerate_calendar\"" assert js =~ "case \"regenerate_calendar\""

View File

@@ -1,6 +1,7 @@
defmodule BDS.UI.WorkbenchTest do defmodule BDS.UI.WorkbenchTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
alias BDS.UI.Registry
alias BDS.UI.MenuBar alias BDS.UI.MenuBar
alias BDS.UI.Workbench alias BDS.UI.Workbench
@@ -187,4 +188,39 @@ defmodule BDS.UI.WorkbenchTest do
assert state.panel.visible == true assert state.panel.visible == true
assert state.active_view == :media assert state.active_view == :media
end end
test "shared menu command routing covers every sidebar view and singleton editor route" do
sidebar_views = Registry.sidebar_views()
state =
Enum.reduce(sidebar_views, Workbench.new(sidebar_visible: false), fn view, acc ->
command = String.to_atom("view_#{view.id}")
next = MenuBar.execute(acc, command)
assert next.active_view == view.id
assert next.sidebar_visible == true
%{next | sidebar_visible: false}
end)
singleton_routes =
Registry.editor_routes()
|> Enum.filter(& &1.singleton)
|> Enum.reject(&(&1.id == :dashboard))
final_state =
Enum.reduce(singleton_routes, state, fn route, acc ->
command = String.to_atom("open_#{route.id}")
next = MenuBar.execute(acc, command)
assert next.active_tab == {route.id, Atom.to_string(route.id)}
assert next.editor_route == route.id
next
end)
assert Enum.any?(final_state.tabs, &(&1.type == :settings and &1.id == "settings"))
assert Enum.any?(final_state.tabs, &(&1.type == :menu_editor and &1.id == "menu_editor"))
assert Enum.any?(final_state.tabs, &(&1.type == :find_duplicates and &1.id == "find_duplicates"))
end
end end