feat: PLAN step 3 done
This commit is contained in:
8
PLAN.md
8
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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])}")
|
||||
|
||||
@@ -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\""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user