diff --git a/PLAN.md b/PLAN.md index 155ef2b..dc96ffc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. | | 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. | -| 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. | | 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. 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. - 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. +3. Finish the desktop shell primitives. Completed 2026-04-25. + 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. Add the modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery flows. diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex index 9a4382c..64f8b34 100644 --- a/lib/bds/posts.ex +++ b/lib/bds/posts.ex @@ -85,6 +85,12 @@ defmodule BDS.Posts do |> Repo.update() |> case do {: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 = PostLinks.sync_post_links(updated_post) :ok = Search.sync_post(updated_post) @@ -560,7 +566,6 @@ defmodule BDS.Posts do :content, :author, :language, - :template_slug, :tags, :categories, :do_not_translate diff --git a/lib/bds/ui/menu_bar.ex b/lib/bds/ui/menu_bar.ex index 13bc461..4141477 100644 --- a/lib/bds/ui/menu_bar.ex +++ b/lib/bds/ui/menu_bar.ex @@ -1,6 +1,7 @@ defmodule BDS.UI.MenuBar do @moduledoc false + alias BDS.UI.Registry alias BDS.UI.Workbench 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_panel), do: Workbench.toggle_panel(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_media), do: %{state | active_view: :media, sidebar_visible: true} + def execute(state, :view_posts), do: open_sidebar_view(state, :posts) + 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 case state.active_tab do @@ -92,8 +97,48 @@ defmodule BDS.UI.MenuBar do 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 + 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 items = [ %{id: :view_posts}, diff --git a/priv/ui/app.js b/priv/ui/app.js index 95c0d1e..750d20c 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -1657,6 +1657,18 @@ function executeShellCommand(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) { case "toggle_sidebar": 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; persistSessionWidths(); 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": closeActiveTab(); 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) { try { const response = await fetch("/api/commands", { diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs index d5f0fb2..2e868b4 100644 --- a/test/bds/posts_test.exs +++ b/test/bds/posts_test.exs @@ -104,6 +104,35 @@ defmodule BDS.PostsTest do assert reopened.updated_at >= published.updated_at 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 temp_dir = Path.join(System.tmp_dir!(), "bds-post-publish-#{System.unique_integer([:positive])}") diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 179f215..60832b7 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -351,8 +351,11 @@ defmodule BDS.UI.ShellTest do assert js =~ "case \"1\"" assert js =~ "case \"2\"" assert js =~ "case \"\\\\\"" - assert js =~ "case \"view_posts\"" - assert js =~ "case \"view_media\"" + assert js =~ "function isSidebarViewCommand(action)" + 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 =~ "case \"metadata_diff\"" assert js =~ "case \"regenerate_calendar\"" diff --git a/test/bds/ui/workbench_test.exs b/test/bds/ui/workbench_test.exs index 3086406..44bc074 100644 --- a/test/bds/ui/workbench_test.exs +++ b/test/bds/ui/workbench_test.exs @@ -1,6 +1,7 @@ defmodule BDS.UI.WorkbenchTest do use ExUnit.Case, async: true + alias BDS.UI.Registry alias BDS.UI.MenuBar alias BDS.UI.Workbench @@ -187,4 +188,39 @@ defmodule BDS.UI.WorkbenchTest do assert state.panel.visible == true assert state.active_view == :media 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