chore: refactored settings_editor.ex

This commit is contained in:
2026-05-01 15:12:57 +02:00
parent f76e48e409
commit cf3c598911
9 changed files with 1010 additions and 757 deletions

View File

@@ -2,7 +2,7 @@
Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`). Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`).
Last refreshed: 2026-05-06. Last refreshed: 2026-05-07.
--- ---
@@ -14,7 +14,6 @@ Last refreshed: 2026-05-06.
| # | Module | Current lines | Target | Strategy | | # | Module | Current lines | Target | Strategy |
|---|---|---|---|---| |---|---|---|---|---|
| 7 | `BDS.Desktop.ShellLive.SettingsEditor` | 872 | ≤ 350 | Extract `ProjectSettings` (~140), `AISettings` (~150), `PublishingSettings` (~80), `ManagedCategories` (~140), `StyleEditor` (~80), `MCPConfig` (~60). |
| 8 | `BDS.Desktop.ShellLive.ChatEditor` | 972 | ≤ 400 | Extract `ToolSurfaces` (~280), `ToolTracking` (~140), `MessageBuild` (~160), `ModelSelection` (~100). Defer — highest internal coupling. | | 8 | `BDS.Desktop.ShellLive.ChatEditor` | 972 | ≤ 400 | Extract `ToolSurfaces` (~280), `ToolTracking` (~140), `MessageBuild` (~160), `ModelSelection` (~100). Defer — highest internal coupling. |
| 9 | `BDS.MCP` | 677 | ≤ 350 | Split tools / resources / proposals / serialization clusters. (Carried over from original priority list.) | | 9 | `BDS.MCP` | 677 | ≤ 350 | Split tools / resources / proposals / serialization clusters. (Carried over from original priority list.) |
@@ -33,6 +32,7 @@ Last refreshed: 2026-05-06.
- `BDS.Rendering` 838 → 33 (96 %) - `BDS.Rendering` 838 → 33 (96 %)
- `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %) - `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %)
- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %) - `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %)
- `BDS.Desktop.ShellLive.SettingsEditor` 872 → 226 (74 %)
--- ---
@@ -166,6 +166,11 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
## Changelog ## Changelog
### 2026-05-07
- **God modules**:
- `BDS.Desktop.ShellLive.SettingsEditor` 872 → 226 (74 %). Submodules under `lib/bds/desktop/shell_live/settings_editor/`: `StyleEditor` (103, build_style + select_style_theme + change_style_preview_mode + apply_style_theme + theme_display_name + current_theme + style_theme + @themes), `MCPConfig` (100, mcp_rows + toggle_mcp_agent + find_mcp_agent + mcp_configured? + mcp_config_path + mcp_server_present? + @mcp_agents), `PublishingSettings` (89, publishing_form + update_publishing_draft + save_publishing + clear_publishing + publishing_attrs + normalize_publishing_params), `EditorSettings` (93, editor_form + update_editor_draft + save_editor + get_global_setting + put_global_setting + editor_attrs + normalize_editor_params + boolean_string), `ProjectSettings` (112, project_metadata + project_form + technology_form + update_project_draft + save_project + project_attrs + normalize_project_params), `ManagedCategories` (194, protected_categories + protected_category? + category_rows + update_new_category + add_category + reset_categories + save_category + remove_category + ensure_default_categories + reset_default_category_settings + @protected_categories + @default_category_settings), `AISettings` (203, ai_form + endpoint_model_options + update_ai_draft + refresh_ai_models + save_ai + reset_ai_prompt + ai_attrs + normalize_ai_params + get_model_preference + maybe_put_model_preference + put_endpoint_preferences + endpoint_refresh_attrs + normalize_endpoint_result). Coordinator keeps `embed_templates "settings_editor_html/*"` (HEEx components `settings_editor` + `style_editor`), `assign_socket/1`, `update_search/3`, the `build_settings/1` aggregator, the `current_settings_section`/`current_tab_meta`/`visible_settings_sections`/`section_matches?`/`template_options` helpers, the `translated/1,2` HEEx render-time helper, and `defdelegate` entries for the 21 public event handlers + the HEEx-callable `theme_display_name/1` (delegated to `StyleEditor`) and `protected_category?/1` (delegated to `ManagedCategories`). Cross-submodule deps: `AISettings` calls `EditorSettings.{get_global_setting/1, put_global_setting/2}` (the only intra-submodule dependency); all other submodules are leaves. Each submodule duplicates the small `translated/2`, `truthy?/1`, `blank_to_nil/1`, `parse_integer/2`, `boolean_string/1` helpers locally per the established convention. Submodules use `Phoenix.Component.assign/3` directly via `use Phoenix.Component`. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped).
### 2026-05-06 ### 2026-05-06
- **God modules**: - **God modules**:

View File

@@ -5,59 +5,45 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
import Ecto.Query import Ecto.Query
alias BDS.AI
alias BDS.Metadata
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.MCP.AgentConfig
alias BDS.Persistence
alias BDS.Repo alias BDS.Repo
alias BDS.Settings.Setting
alias BDS.Templates.Template alias BDS.Templates.Template
alias BDS.Desktop.ShellLive.SettingsEditor.AISettings
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
alias BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings
alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings
alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor
embed_templates "settings_editor_html/*" embed_templates "settings_editor_html/*"
@settings_sections ~w(project editor content ai technology publishing data mcp) @settings_sections ~w(project editor content ai technology publishing data mcp)
@themes [
"default",
"amber",
"blue",
"cyan",
"fuchsia",
"green",
"grey",
"indigo",
"jade",
"lime",
"orange",
"pink",
"pumpkin",
"purple",
"red",
"sand",
"slate",
"violet",
"yellow",
"zinc"
]
@supported_languages ["en", "de", "fr", "it", "es"] @supported_languages ["en", "de", "fr", "it", "es"]
@protected_categories MapSet.new(["article", "aside", "page", "picture"])
@default_category_settings %{ defdelegate update_project_draft(socket, params, reload), to: ProjectSettings
"article" => %{title: "article", render_in_lists: true, show_title: true}, defdelegate save_project(socket, reload, append_output), to: ProjectSettings
"picture" => %{title: "picture", render_in_lists: true, show_title: true}, defdelegate update_editor_draft(socket, params, reload), to: EditorSettings
"aside" => %{title: "aside", render_in_lists: true, show_title: false}, defdelegate save_editor(socket, reload, append_output), to: EditorSettings
"page" => %{title: "page", render_in_lists: false, show_title: true} defdelegate update_publishing_draft(socket, params, reload), to: PublishingSettings
} defdelegate save_publishing(socket, reload, append_output), to: PublishingSettings
@mcp_agents [ defdelegate clear_publishing(socket, reload, append_output), to: PublishingSettings
%{id: :claude_code, label: "Claude Code", supported?: true}, defdelegate update_ai_draft(socket, params, reload), to: AISettings
%{id: :claude_desktop, label: "Claude Desktop", supported?: false}, defdelegate refresh_ai_models(socket, endpoint_key, reload, append_output), to: AISettings
%{id: :github_copilot, label: "GitHub Copilot", supported?: true}, defdelegate save_ai(socket, reload, append_output), to: AISettings
%{id: :gemini_cli, label: "Gemini CLI", supported?: false}, defdelegate reset_ai_prompt(socket, reload, append_output), to: AISettings
%{id: :opencode, label: "OpenCode", supported?: false}, defdelegate update_new_category(socket, name, reload), to: ManagedCategories
%{id: :mistral_vibe, label: "Mistral Vibe", supported?: false}, defdelegate add_category(socket, reload, append_output), to: ManagedCategories
%{id: :openai_codex, label: "OpenAI Codex", supported?: false} defdelegate reset_categories(socket, reload, append_output), to: ManagedCategories
] defdelegate save_category(socket, params, reload, append_output), to: ManagedCategories
defdelegate remove_category(socket, category, reload, append_output), to: ManagedCategories
defdelegate toggle_mcp_agent(socket, agent, reload, append_output), to: MCPConfig
defdelegate select_style_theme(socket, theme, reload), to: StyleEditor
defdelegate change_style_preview_mode(socket, mode, reload), to: StyleEditor
defdelegate apply_style_theme(socket, reload, append_output), to: StyleEditor
defdelegate theme_display_name(theme), to: StyleEditor
defdelegate protected_category?(category), to: ManagedCategories
def assign_socket(socket) do def assign_socket(socket) do
case socket.assigns[:current_tab] do case socket.assigns[:current_tab] do
@@ -69,7 +55,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
%{type: :style} -> %{type: :style} ->
socket socket
|> assign(:settings_editor, nil) |> assign(:settings_editor, nil)
|> assign(:style_editor, build_style(socket.assigns)) |> assign(:style_editor, StyleEditor.build_style(socket.assigns))
_other -> _other ->
socket socket
@@ -84,305 +70,29 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
def update_project_draft(socket, params, reload) do
socket
|> assign(:settings_editor_project_draft, normalize_project_params(params))
|> reload.(socket.assigns.workbench)
end
def update_editor_draft(socket, params, reload) do
socket
|> assign(:settings_editor_editor_draft, normalize_editor_params(params))
|> reload.(socket.assigns.workbench)
end
def save_editor(socket, reload, append_output) do
attrs = editor_attrs(socket.assigns)
with :ok <- put_global_setting("ui.preferred_editor_mode", attrs.default_mode),
:ok <- put_global_setting("ui.git_diff_view_style", attrs.diff_view_style),
:ok <- put_global_setting("ui.git_diff_word_wrap", boolean_string(attrs.wrap_long_lines)),
:ok <- put_global_setting("ui.git_diff_hide_unchanged_regions", boolean_string(attrs.hide_unchanged_regions)) do
socket
|> assign(:settings_editor_editor_draft, %{})
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("Editor Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_project(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
case Metadata.update_project_metadata(project_id, project_attrs(socket.assigns)) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_project_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def update_publishing_draft(socket, params, reload) do
socket
|> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
|> reload.(socket.assigns.workbench)
end
def update_ai_draft(socket, params, reload) do
socket
|> assign(:settings_editor_ai_draft, normalize_ai_params(params))
|> reload.(socket.assigns.workbench)
end
def refresh_ai_models(socket, endpoint_key, reload, append_output) do
attrs = ai_attrs(socket.assigns)
with {:ok, endpoint} <- endpoint_refresh_attrs(endpoint_key, attrs),
{:ok, models} <- AI.list_endpoint_models(endpoint) do
socket
|> assign(:settings_editor_endpoint_models, Map.put(socket.assigns[:settings_editor_endpoint_models] || %{}, endpoint_key, models))
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns)
with :ok <- put_endpoint_preferences(:online, attrs.online_url, attrs.online_api_key, attrs.online_chat_model),
:ok <- put_endpoint_preferences(:airplane, attrs.offline_url, attrs.offline_api_key, attrs.offline_chat_model),
:ok <- AI.delete_endpoint(:mistral),
:ok <- AI.set_airplane_mode(attrs.offline_mode),
:ok <- maybe_put_model_preference(:chat, attrs.online_chat_model),
:ok <- maybe_put_model_preference(:title, attrs.online_title_model),
:ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model),
:ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model),
:ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
:ok <- maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model),
:ok <- put_global_setting("ai.system_prompt", attrs.system_prompt) do
socket
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def reset_ai_prompt(socket, reload, append_output) do
case put_global_setting("ai.system_prompt", "") do
:ok ->
socket
|> assign(:settings_editor_ai_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
case Metadata.set_publishing_preferences(project_id, publishing_attrs(socket.assigns)) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_publishing_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Publishing"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def clear_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
case Metadata.set_publishing_preferences(project_id, %{}) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_publishing_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Publishing"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def update_new_category(socket, name, reload) do
socket
|> assign(:settings_editor_new_category, to_string(name || ""))
|> reload.(socket.assigns.workbench)
end
def add_category(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
cond do
name == "" ->
socket
|> append_output.(translated("Categories"), translated("Category name is required"), nil, "error")
|> reload.(socket.assigns.workbench)
true ->
case Metadata.add_category(project_id, name) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_new_category, "")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
def reset_categories(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
result =
Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, _acc ->
if MapSet.member?(@protected_categories, category) do
{:cont, :ok}
else
case Metadata.remove_category(project_id, category) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end
end)
with :ok <- result,
:ok <- ensure_default_categories(project_id),
:ok <- reset_default_category_settings(project_id) do
socket
|> assign(:settings_editor_new_category, "")
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def toggle_mcp_agent(socket, agent, reload, append_output) do
case find_mcp_agent(agent) do
%{id: agent_id, supported?: true} = config ->
if mcp_configured?(config) do
{:ok, _payload} = AgentConfig.remove_from_config(agent_id)
reload.(socket, socket.assigns.workbench)
else
install_root = Application.app_dir(:bds)
{:ok, _payload} = AgentConfig.add_to_config(agent_id, install_root: install_root)
reload.(socket, socket.assigns.workbench)
end
_other ->
socket
|> append_output.(translated("MCP"), translated("This MCP agent is not supported in the rewrite yet"), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_category(socket, params, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
category = Map.get(params, "category", "")
settings = %{
title: blank_to_nil(Map.get(params, "title")),
render_in_lists: truthy?(Map.get(params, "render_in_lists")),
show_title: truthy?(Map.get(params, "show_title")),
post_template_slug: blank_to_nil(Map.get(params, "post_template_slug")),
list_template_slug: blank_to_nil(Map.get(params, "list_template_slug"))
}
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def remove_category(socket, category, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
cond do
MapSet.member?(@protected_categories, category) ->
socket
|> append_output.(translated("Categories"), translated("Protected categories cannot be removed"), nil, "error")
|> reload.(socket.assigns.workbench)
true ->
case Metadata.remove_category(project_id, category) do
{:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
def select_style_theme(socket, theme, reload) do
socket
|> assign(:style_editor_theme, to_string(theme || "default"))
|> reload.(socket.assigns.workbench)
end
def change_style_preview_mode(socket, mode, reload) do
socket
|> assign(:style_editor_preview_mode, to_string(mode || "auto"))
|> reload.(socket.assigns.workbench)
end
def apply_style_theme(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns)
case Metadata.update_project_metadata(project_id, %{pico_theme: theme}) do
{:ok, _metadata} -> reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Style"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def build_settings(%{projects: %{active_project_id: nil}}), do: nil def build_settings(%{projects: %{active_project_id: nil}}), do: nil
def build_settings(assigns) do def build_settings(assigns) do
metadata = project_metadata(assigns) metadata = ProjectSettings.project_metadata(assigns)
project_form = Map.merge(project_form(metadata), Map.get(assigns, :settings_editor_project_draft, %{}))
editor_form = Map.merge(editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{})) project_form =
ai_form = Map.merge(ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{})) Map.merge(
publishing_form = Map.merge(publishing_form(metadata), Map.get(assigns, :settings_editor_publishing_draft, %{})) ProjectSettings.project_form(metadata),
Map.get(assigns, :settings_editor_project_draft, %{})
)
editor_form =
Map.merge(EditorSettings.editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{}))
ai_form =
Map.merge(AISettings.ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
publishing_form =
Map.merge(
PublishingSettings.publishing_form(metadata),
Map.get(assigns, :settings_editor_publishing_draft, %{})
)
query = Map.get(assigns, :settings_editor_search, "") query = Map.get(assigns, :settings_editor_search, "")
selected_section = current_settings_section(assigns) selected_section = current_settings_section(assigns)
visible_sections = visible_settings_sections(query) visible_sections = visible_settings_sections(query)
@@ -393,259 +103,47 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
active_sections: visible_sections, active_sections: visible_sections,
project: project_form, project: project_form,
editor: editor_form, editor: editor_form,
categories: category_rows(metadata), categories: ManagedCategories.category_rows(metadata),
ai: ai_form, ai: ai_form,
technology: technology_form(project_form), technology: ProjectSettings.technology_form(project_form),
publishing: publishing_form, publishing: publishing_form,
mcp: mcp_rows(), mcp: MCPConfig.mcp_rows(),
new_category: Map.get(assigns, :settings_editor_new_category, ""), new_category: Map.get(assigns, :settings_editor_new_category, ""),
project_data_path: Map.get(assigns.current_project || %{}, :data_path) || "", project_data_path: Map.get(assigns.current_project || %{}, :data_path) || "",
project_data_default_path: Map.get(assigns.current_project || %{}, :project_path) || "", project_data_default_path: Map.get(assigns.current_project || %{}, :project_path) || "",
template_options: template_options(assigns.projects.active_project_id), template_options: template_options(assigns.projects.active_project_id),
online_endpoint_models: endpoint_model_options(assigns, :online), online_endpoint_models: AISettings.endpoint_model_options(assigns, :online),
offline_endpoint_models: endpoint_model_options(assigns, :airplane), offline_endpoint_models: AISettings.endpoint_model_options(assigns, :airplane),
project_visible?: section_matches?(query, ~w(project name description url language author category posts bookmarklet)), project_visible?:
editor_visible?: section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)), section_matches?(
query,
~w(project name description url language author category posts bookmarklet)
),
editor_visible?:
section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)),
content_visible?: section_matches?(query, ~w(content categories templates lists blogmark)), content_visible?: section_matches?(query, ~w(content categories templates lists blogmark)),
ai_visible?: section_matches?(query, ~w(ai assistant model prompt airplane offline online endpoint url api key chat title image)), ai_visible?:
technology_visible?: section_matches?(query, ~w(technology runtime semantic similarity embedding scripting)), section_matches?(
publishing_visible?: section_matches?(query, ~w(publishing ssh scp rsync host user remote path)), query,
mcp_visible?: section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server)), ~w(ai assistant model prompt airplane offline online endpoint url api key chat title image)
),
technology_visible?:
section_matches?(query, ~w(technology runtime semantic similarity embedding scripting)),
publishing_visible?:
section_matches?(query, ~w(publishing ssh scp rsync host user remote path)),
mcp_visible?:
section_matches?(
query,
~w(mcp claude copilot gemini opencode mistral codex agent server)
),
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)), data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
supported_languages: @supported_languages, supported_languages: @supported_languages,
protected_categories: @protected_categories protected_categories: ManagedCategories.protected_categories()
} }
end end
def build_style(%{projects: %{active_project_id: nil}}), do: nil def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
def build_style(assigns) do
selected_theme = Map.get(assigns, :style_editor_theme) || current_theme(assigns)
preview_mode = Map.get(assigns, :style_editor_preview_mode, "auto")
%{
themes: Enum.map(@themes, &style_theme/1),
selected_theme: selected_theme,
applied_theme: current_theme(assigns),
preview_mode: preview_mode,
preview_url: "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
}
end
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
def protected_category?(category), do: MapSet.member?(@protected_categories, category)
def theme_display_name(theme) do
theme
|> to_string()
|> String.replace("-", " ")
|> String.capitalize()
end
defp project_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_project_draft, %{})
%{
name: blank_to_nil(Map.get(draft, "name")),
description: blank_to_nil(Map.get(draft, "description")),
public_url: blank_to_nil(Map.get(draft, "public_url")),
main_language: blank_to_nil(Map.get(draft, "main_language")),
default_author: blank_to_nil(Map.get(draft, "default_author")),
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
blog_languages: Map.get(draft, "blog_languages", []),
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
}
end
defp editor_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_editor_draft, %{})
%{
default_mode: Map.get(draft, "default_mode", "markdown"),
diff_view_style: Map.get(draft, "diff_view_style", "inline"),
wrap_long_lines: truthy?(Map.get(draft, "wrap_long_lines")),
hide_unchanged_regions: truthy?(Map.get(draft, "hide_unchanged_regions"))
}
end
defp publishing_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_publishing_draft, %{})
%{
ssh_host: blank_to_nil(Map.get(draft, "ssh_host")),
ssh_user: blank_to_nil(Map.get(draft, "ssh_user")),
ssh_remote_path: blank_to_nil(Map.get(draft, "ssh_remote_path")),
ssh_mode: Map.get(draft, "ssh_mode", "scp")
}
end
defp ai_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_ai_draft, %{})
%{
online_url: blank_to_nil(Map.get(draft, "online_url")),
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
online_chat_model: blank_to_nil(Map.get(draft, "online_chat_model")),
online_title_model: blank_to_nil(Map.get(draft, "online_title_model")),
online_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")),
offline_url: blank_to_nil(Map.get(draft, "offline_url")),
offline_api_key: blank_to_nil(Map.get(draft, "offline_api_key")),
offline_mode: truthy?(Map.get(draft, "offline_mode")),
offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")),
offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")),
offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")),
system_prompt: Map.get(draft, "system_prompt", "")
}
end
defp project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata
end
end
defp project_form(metadata) do
%{
"name" => Map.get(metadata, :name, ""),
"description" => Map.get(metadata, :description, ""),
"public_url" => Map.get(metadata, :public_url, ""),
"main_language" => Map.get(metadata, :main_language) || "en",
"default_author" => Map.get(metadata, :default_author, ""),
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
"blogmark_category" => Map.get(metadata, :blogmark_category) || List.first(Map.get(metadata, :categories, [])) || "article",
"blog_languages" => Map.get(metadata, :blog_languages, []),
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
}
end
defp editor_form do
%{
"default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown",
"diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" => get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
}
end
defp publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{})
%{
"ssh_host" => Map.get(prefs, "ssh_host", ""),
"ssh_user" => Map.get(prefs, "ssh_user", ""),
"ssh_remote_path" => Map.get(prefs, "ssh_remote_path", ""),
"ssh_mode" => Map.get(prefs, "ssh_mode", "scp")
}
end
defp ai_form(assigns) do
{:ok, online_endpoint} = AI.get_endpoint(:online)
{:ok, airplane_endpoint} = AI.get_endpoint(:airplane)
%{
"online_url" => Map.get(online_endpoint || %{}, :url, ""),
"online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""),
"online_chat_model" => get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, ""),
"online_title_model" => get_model_preference(:title),
"online_image_analysis_model" => get_model_preference(:image_analysis),
"offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
"offline_api_key" => Map.get(airplane_endpoint || %{}, :api_key, ""),
"offline_mode" => Map.get(assigns, :offline_mode, AI.airplane_mode?(true)),
"offline_chat_model" => get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, ""),
"offline_title_model" => get_model_preference(:airplane_title),
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
"system_prompt" => get_global_setting("ai.system_prompt") || ""
}
end
defp technology_form(project_form) do
%{
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
}
end
defp current_theme(assigns) do
assigns
|> project_metadata()
|> Map.get(:pico_theme)
|> case do
nil -> "default"
"" -> "default"
theme -> theme
end
end
defp category_rows(metadata) do
categories = Map.get(metadata, :categories, [])
settings = Map.get(metadata, :category_settings, %{})
Enum.map(categories, fn category ->
category_settings = Map.get(settings, category, %{})
%{
name: category,
title: Map.get(category_settings, "title") || category,
render_in_lists: Map.get(category_settings, "render_in_lists", true),
show_title: Map.get(category_settings, "show_title", true),
post_template_slug: Map.get(category_settings, "post_template_slug", ""),
list_template_slug: Map.get(category_settings, "list_template_slug", ""),
protected?: protected_category?(category)
}
end)
end
defp category_names(metadata), do: Map.get(metadata, :categories, [])
defp normalize_project_params(params) do
%{
"name" => Map.get(params, "name", ""),
"description" => Map.get(params, "description", ""),
"public_url" => Map.get(params, "public_url", ""),
"main_language" => Map.get(params, "main_language", "en"),
"default_author" => Map.get(params, "default_author", ""),
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
}
end
defp normalize_editor_params(params) do
%{
"default_mode" => Map.get(params, "default_mode", "markdown"),
"diff_view_style" => Map.get(params, "diff_view_style", "inline"),
"wrap_long_lines" => truthy?(Map.get(params, "wrap_long_lines")),
"hide_unchanged_regions" => truthy?(Map.get(params, "hide_unchanged_regions"))
}
end
defp normalize_publishing_params(params) do
%{
"ssh_host" => Map.get(params, "ssh_host", ""),
"ssh_user" => Map.get(params, "ssh_user", ""),
"ssh_remote_path" => Map.get(params, "ssh_remote_path", ""),
"ssh_mode" => Map.get(params, "ssh_mode", "scp")
}
end
defp normalize_ai_params(params) do
%{
"online_url" => Map.get(params, "online_url", ""),
"online_api_key" => Map.get(params, "online_api_key", ""),
"online_chat_model" => Map.get(params, "online_chat_model", ""),
"online_title_model" => Map.get(params, "online_title_model", ""),
"online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""),
"offline_url" => Map.get(params, "offline_url", ""),
"offline_api_key" => Map.get(params, "offline_api_key", ""),
"offline_mode" => truthy?(Map.get(params, "offline_mode")),
"offline_chat_model" => Map.get(params, "offline_chat_model", ""),
"offline_title_model" => Map.get(params, "offline_title_model", ""),
"offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""),
"system_prompt" => Map.get(params, "system_prompt", "")
}
end
defp current_settings_section(assigns) do defp current_settings_section(assigns) do
meta = current_tab_meta(assigns) meta = current_tab_meta(assigns)
@@ -672,14 +170,32 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
defp visible_settings_sections(query) do defp visible_settings_sections(query) do
Enum.filter(@settings_sections, fn section -> Enum.filter(@settings_sections, fn section ->
case section do case section do
"project" -> section_matches?(query, ~w(project name description data url language author bookmarklet)) "project" ->
"editor" -> section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)) section_matches?(query, ~w(project name description data url language author bookmarklet))
"content" -> section_matches?(query, ~w(content categories templates lists blogmark))
"ai" -> section_matches?(query, ~w(ai assistant model prompt airplane offline online endpoint url api key chat title image)) "editor" ->
"technology" -> section_matches?(query, ~w(technology semantic similarity runtime scripting embedding)) section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged))
"publishing" -> section_matches?(query, ~w(publishing ssh scp rsync host user remote path))
"data" -> section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem)) "content" ->
"mcp" -> section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server)) section_matches?(query, ~w(content categories templates lists blogmark))
"ai" ->
section_matches?(
query,
~w(ai assistant model prompt airplane offline online endpoint url api key chat title image)
)
"technology" ->
section_matches?(query, ~w(technology semantic similarity runtime scripting embedding))
"publishing" ->
section_matches?(query, ~w(publishing ssh scp rsync host user remote path))
"data" ->
section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem))
"mcp" ->
section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server))
end end
end) end)
end end
@@ -703,170 +219,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
} }
end end
defp mcp_rows do
Enum.map(@mcp_agents, fn agent ->
%{
id: agent.id,
label: agent.label,
supported?: agent.supported?,
configured?: mcp_configured?(agent),
config_path: mcp_config_path(agent)
}
end)
end
defp find_mcp_agent(agent) do
normalized =
agent
|> to_string()
|> String.to_existing_atom()
Enum.find(@mcp_agents, &(&1.id == normalized))
rescue
_error -> nil
end
defp mcp_configured?(%{supported?: false}), do: false
defp mcp_configured?(%{id: agent_id}) do
path = AgentConfig.config_path(agent_id, System.user_home!())
if File.exists?(path) do
path
|> File.read!()
|> Jason.decode!()
|> mcp_server_present?(agent_id)
else
false
end
rescue
_error -> false
end
defp mcp_config_path(%{supported?: false}), do: nil
defp mcp_config_path(%{id: agent_id}), do: AgentConfig.config_path(agent_id, System.user_home!())
defp mcp_server_present?(config, :github_copilot) do
config
|> Map.get("servers", %{})
|> Map.has_key?("bDS")
end
defp mcp_server_present?(config, _agent_id) do
config
|> Map.get("mcpServers", %{})
|> Map.has_key?("bDS")
end
defp get_model_preference(key) do
case AI.get_model_preference(key) do
{:ok, value} -> value || ""
_other -> ""
end
end
defp maybe_put_model_preference(_key, nil), do: :ok
defp maybe_put_model_preference(_key, ""), do: :ok
defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value)
defp put_endpoint_preferences(kind, url, api_key, primary_model) do
if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do
AI.delete_endpoint(kind)
else
AI.put_endpoint(kind, %{url: url, api_key: api_key, model: primary_model}) |> normalize_endpoint_result()
end
end
defp endpoint_model_options(assigns, endpoint_key) do
assigns
|> Map.get(:settings_editor_endpoint_models, %{})
|> Map.get(endpoint_key, [])
end
defp endpoint_refresh_attrs(:online, attrs) do
endpoint_refresh_attrs(attrs.online_url, attrs.online_api_key)
end
defp endpoint_refresh_attrs(:airplane, attrs) do
endpoint_refresh_attrs(attrs.offline_url, attrs.offline_api_key)
end
defp endpoint_refresh_attrs(url, api_key) do
case blank_to_nil(url) do
nil -> {:error, :endpoint_not_configured}
loaded_url -> {:ok, %{url: loaded_url, api_key: api_key}}
end
end
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
defp ensure_default_categories(project_id) do
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
case Metadata.add_category(project_id, category) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp reset_default_category_settings(project_id) do
Enum.reduce_while(@default_category_settings, :ok, fn {category, settings}, _acc ->
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp get_global_setting(key) do
case Repo.get(Setting, key) do
%Setting{value: value} -> value
_other -> nil
end
end
defp put_global_setting(key, value) do
setting = Repo.get(Setting, key) || %Setting{}
setting
|> Setting.changeset(%{key: key, value: to_string(value || ""), updated_at: Persistence.now_ms()})
|> Repo.insert_or_update()
|> case do
{:ok, _setting} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp section_matches?("", _keywords), do: true defp section_matches?("", _keywords), do: true
defp section_matches?(query, keywords), do: Enum.any?(keywords, &String.contains?(&1, String.downcase(query)))
defp style_theme(name) do defp section_matches?(query, keywords),
%{ do: Enum.any?(keywords, &String.contains?(&1, String.downcase(query)))
name: name,
accent_color: "#4f46e5",
light_bg_color: "#f8fafc",
dark_bg_color: "#0f172a"
}
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp boolean_string(true), do: "true"
defp boolean_string(false), do: "false"
defp parse_integer(nil, fallback), do: fallback
defp parse_integer(value, _fallback) when is_integer(value), do: value
defp parse_integer(value, fallback) do
case Integer.parse(to_string(value)) do
{parsed, _rest} -> parsed
:error -> fallback
end
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
end end

View File

@@ -0,0 +1,203 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
@moduledoc false
use Phoenix.Component
alias BDS.AI
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
def ai_form(assigns) do
{:ok, online_endpoint} = AI.get_endpoint(:online)
{:ok, airplane_endpoint} = AI.get_endpoint(:airplane)
%{
"online_url" => Map.get(online_endpoint || %{}, :url, ""),
"online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""),
"online_chat_model" =>
get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, ""),
"online_title_model" => get_model_preference(:title),
"online_image_analysis_model" => get_model_preference(:image_analysis),
"offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
"offline_api_key" => Map.get(airplane_endpoint || %{}, :api_key, ""),
"offline_mode" => Map.get(assigns, :offline_mode, AI.airplane_mode?(true)),
"offline_chat_model" =>
get_model_preference(:airplane_chat) ||
Map.get(airplane_endpoint || %{}, :model, ""),
"offline_title_model" => get_model_preference(:airplane_title),
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
"system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || ""
}
end
def endpoint_model_options(assigns, endpoint_key) do
assigns
|> Map.get(:settings_editor_endpoint_models, %{})
|> Map.get(endpoint_key, [])
end
def update_ai_draft(socket, params, reload) do
socket
|> assign(:settings_editor_ai_draft, normalize_ai_params(params))
|> reload.(socket.assigns.workbench)
end
def refresh_ai_models(socket, endpoint_key, reload, append_output) do
attrs = ai_attrs(socket.assigns)
with {:ok, endpoint} <- endpoint_refresh_attrs(endpoint_key, attrs),
{:ok, models} <- AI.list_endpoint_models(endpoint) do
socket
|> assign(
:settings_editor_endpoint_models,
Map.put(
socket.assigns[:settings_editor_endpoint_models] || %{},
endpoint_key,
models
)
)
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns)
with :ok <-
put_endpoint_preferences(:online, attrs.online_url, attrs.online_api_key, attrs.online_chat_model),
:ok <-
put_endpoint_preferences(
:airplane,
attrs.offline_url,
attrs.offline_api_key,
attrs.offline_chat_model
),
:ok <- AI.delete_endpoint(:mistral),
:ok <- AI.set_airplane_mode(attrs.offline_mode),
:ok <- maybe_put_model_preference(:chat, attrs.online_chat_model),
:ok <- maybe_put_model_preference(:title, attrs.online_title_model),
:ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model),
:ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model),
:ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
:ok <-
maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model),
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
socket
|> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def reset_ai_prompt(socket, reload, append_output) do
case EditorSettings.put_global_setting("ai.system_prompt", "") do
:ok ->
socket
|> assign(:settings_editor_ai_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
defp ai_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_ai_draft, %{})
%{
online_url: blank_to_nil(Map.get(draft, "online_url")),
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
online_chat_model: blank_to_nil(Map.get(draft, "online_chat_model")),
online_title_model: blank_to_nil(Map.get(draft, "online_title_model")),
online_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")),
offline_url: blank_to_nil(Map.get(draft, "offline_url")),
offline_api_key: blank_to_nil(Map.get(draft, "offline_api_key")),
offline_mode: truthy?(Map.get(draft, "offline_mode")),
offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")),
offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")),
offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")),
system_prompt: Map.get(draft, "system_prompt", "")
}
end
defp normalize_ai_params(params) do
%{
"online_url" => Map.get(params, "online_url", ""),
"online_api_key" => Map.get(params, "online_api_key", ""),
"online_chat_model" => Map.get(params, "online_chat_model", ""),
"online_title_model" => Map.get(params, "online_title_model", ""),
"online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""),
"offline_url" => Map.get(params, "offline_url", ""),
"offline_api_key" => Map.get(params, "offline_api_key", ""),
"offline_mode" => truthy?(Map.get(params, "offline_mode")),
"offline_chat_model" => Map.get(params, "offline_chat_model", ""),
"offline_title_model" => Map.get(params, "offline_title_model", ""),
"offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""),
"system_prompt" => Map.get(params, "system_prompt", "")
}
end
defp get_model_preference(key) do
case AI.get_model_preference(key) do
{:ok, value} -> value || ""
_other -> ""
end
end
defp maybe_put_model_preference(_key, nil), do: :ok
defp maybe_put_model_preference(_key, ""), do: :ok
defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value)
defp put_endpoint_preferences(kind, url, api_key, primary_model) do
if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do
AI.delete_endpoint(kind)
else
AI.put_endpoint(kind, %{url: url, api_key: api_key, model: primary_model})
|> normalize_endpoint_result()
end
end
defp endpoint_refresh_attrs(:online, attrs) do
endpoint_refresh_attrs(attrs.online_url, attrs.online_api_key)
end
defp endpoint_refresh_attrs(:airplane, attrs) do
endpoint_refresh_attrs(attrs.offline_url, attrs.offline_api_key)
end
defp endpoint_refresh_attrs(url, api_key) do
case blank_to_nil(url) do
nil -> {:error, :endpoint_not_configured}
loaded_url -> {:ok, %{url: loaded_url, api_key: api_key}}
end
end
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end

View File

@@ -0,0 +1,93 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
@moduledoc false
use Phoenix.Component
alias BDS.Persistence
alias BDS.Repo
alias BDS.Settings.Setting
alias BDS.Desktop.ShellData
def editor_form do
%{
"default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown",
"diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" => get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
}
end
def update_editor_draft(socket, params, reload) do
socket
|> assign(:settings_editor_editor_draft, normalize_editor_params(params))
|> reload.(socket.assigns.workbench)
end
def save_editor(socket, reload, append_output) do
attrs = editor_attrs(socket.assigns)
with :ok <- put_global_setting("ui.preferred_editor_mode", attrs.default_mode),
:ok <- put_global_setting("ui.git_diff_view_style", attrs.diff_view_style),
:ok <- put_global_setting("ui.git_diff_word_wrap", boolean_string(attrs.wrap_long_lines)),
:ok <-
put_global_setting(
"ui.git_diff_hide_unchanged_regions",
boolean_string(attrs.hide_unchanged_regions)
) do
socket
|> assign(:settings_editor_editor_draft, %{})
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("Editor Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def get_global_setting(key) do
case Repo.get(Setting, key) do
%Setting{value: value} -> value
_other -> nil
end
end
def put_global_setting(key, value) do
setting = Repo.get(Setting, key) || %Setting{}
setting
|> Setting.changeset(%{key: key, value: to_string(value || ""), updated_at: Persistence.now_ms()})
|> Repo.insert_or_update()
|> case do
{:ok, _setting} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp editor_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_editor_draft, %{})
%{
default_mode: Map.get(draft, "default_mode", "markdown"),
diff_view_style: Map.get(draft, "diff_view_style", "inline"),
wrap_long_lines: truthy?(Map.get(draft, "wrap_long_lines")),
hide_unchanged_regions: truthy?(Map.get(draft, "hide_unchanged_regions"))
}
end
defp normalize_editor_params(params) do
%{
"default_mode" => Map.get(params, "default_mode", "markdown"),
"diff_view_style" => Map.get(params, "diff_view_style", "inline"),
"wrap_long_lines" => truthy?(Map.get(params, "wrap_long_lines")),
"hide_unchanged_regions" => truthy?(Map.get(params, "hide_unchanged_regions"))
}
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp boolean_string(true), do: "true"
defp boolean_string(false), do: "false"
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end

View File

@@ -0,0 +1,194 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
@moduledoc false
use Phoenix.Component
alias BDS.Metadata
alias BDS.Desktop.ShellData
@protected_categories MapSet.new(["article", "aside", "page", "picture"])
@default_category_settings %{
"article" => %{title: "article", render_in_lists: true, show_title: true},
"picture" => %{title: "picture", render_in_lists: true, show_title: true},
"aside" => %{title: "aside", render_in_lists: true, show_title: false},
"page" => %{title: "page", render_in_lists: false, show_title: true}
}
def protected_categories, do: @protected_categories
def protected_category?(category), do: MapSet.member?(@protected_categories, category)
def category_rows(metadata) do
categories = Map.get(metadata, :categories, [])
settings = Map.get(metadata, :category_settings, %{})
Enum.map(categories, fn category ->
category_settings = Map.get(settings, category, %{})
%{
name: category,
title: Map.get(category_settings, "title") || category,
render_in_lists: Map.get(category_settings, "render_in_lists", true),
show_title: Map.get(category_settings, "show_title", true),
post_template_slug: Map.get(category_settings, "post_template_slug", ""),
list_template_slug: Map.get(category_settings, "list_template_slug", ""),
protected?: protected_category?(category)
}
end)
end
def update_new_category(socket, name, reload) do
socket
|> assign(:settings_editor_new_category, to_string(name || ""))
|> reload.(socket.assigns.workbench)
end
def add_category(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
cond do
name == "" ->
socket
|> append_output.(
translated("Categories"),
translated("Category name is required"),
nil,
"error"
)
|> reload.(socket.assigns.workbench)
true ->
case Metadata.add_category(project_id, name) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_new_category, "")
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
def reset_categories(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
result =
Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, _acc ->
if MapSet.member?(@protected_categories, category) do
{:cont, :ok}
else
case Metadata.remove_category(project_id, category) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end
end)
with :ok <- result,
:ok <- ensure_default_categories(project_id),
:ok <- reset_default_category_settings(project_id) do
socket
|> assign(:settings_editor_new_category, "")
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_category(socket, params, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
category = Map.get(params, "category", "")
settings = %{
title: blank_to_nil(Map.get(params, "title")),
render_in_lists: truthy?(Map.get(params, "render_in_lists")),
show_title: truthy?(Map.get(params, "show_title")),
post_template_slug: blank_to_nil(Map.get(params, "post_template_slug")),
list_template_slug: blank_to_nil(Map.get(params, "list_template_slug"))
}
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} ->
reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def remove_category(socket, category, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
cond do
MapSet.member?(@protected_categories, category) ->
socket
|> append_output.(
translated("Categories"),
translated("Protected categories cannot be removed"),
nil,
"error"
)
|> reload.(socket.assigns.workbench)
true ->
case Metadata.remove_category(project_id, category) do
{:ok, _metadata} ->
reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
end
defp project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata
end
end
defp category_names(metadata), do: Map.get(metadata, :categories, [])
defp ensure_default_categories(project_id) do
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
case Metadata.add_category(project_id, category) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp reset_default_category_settings(project_id) do
Enum.reduce_while(@default_category_settings, :ok, fn {category, settings}, _acc ->
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end

View File

@@ -0,0 +1,100 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
@moduledoc false
use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.MCP.AgentConfig
@mcp_agents [
%{id: :claude_code, label: "Claude Code", supported?: true},
%{id: :claude_desktop, label: "Claude Desktop", supported?: false},
%{id: :github_copilot, label: "GitHub Copilot", supported?: true},
%{id: :gemini_cli, label: "Gemini CLI", supported?: false},
%{id: :opencode, label: "OpenCode", supported?: false},
%{id: :mistral_vibe, label: "Mistral Vibe", supported?: false},
%{id: :openai_codex, label: "OpenAI Codex", supported?: false}
]
def mcp_rows do
Enum.map(@mcp_agents, fn agent ->
%{
id: agent.id,
label: agent.label,
supported?: agent.supported?,
configured?: mcp_configured?(agent),
config_path: mcp_config_path(agent)
}
end)
end
def toggle_mcp_agent(socket, agent, reload, append_output) do
case find_mcp_agent(agent) do
%{id: agent_id, supported?: true} = config ->
if mcp_configured?(config) do
{:ok, _payload} = AgentConfig.remove_from_config(agent_id)
reload.(socket, socket.assigns.workbench)
else
install_root = Application.app_dir(:bds)
{:ok, _payload} = AgentConfig.add_to_config(agent_id, install_root: install_root)
reload.(socket, socket.assigns.workbench)
end
_other ->
socket
|> append_output.(
translated("MCP"),
translated("This MCP agent is not supported in the rewrite yet"),
nil,
"error"
)
|> reload.(socket.assigns.workbench)
end
end
defp find_mcp_agent(agent) do
normalized =
agent
|> to_string()
|> String.to_existing_atom()
Enum.find(@mcp_agents, &(&1.id == normalized))
rescue
_error -> nil
end
defp mcp_configured?(%{supported?: false}), do: false
defp mcp_configured?(%{id: agent_id}) do
path = AgentConfig.config_path(agent_id, System.user_home!())
if File.exists?(path) do
path
|> File.read!()
|> Jason.decode!()
|> mcp_server_present?(agent_id)
else
false
end
rescue
_error -> false
end
defp mcp_config_path(%{supported?: false}), do: nil
defp mcp_config_path(%{id: agent_id}), do: AgentConfig.config_path(agent_id, System.user_home!())
defp mcp_server_present?(config, :github_copilot) do
config
|> Map.get("servers", %{})
|> Map.has_key?("bDS")
end
defp mcp_server_present?(config, _agent_id) do
config
|> Map.get("mcpServers", %{})
|> Map.has_key?("bDS")
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end

View File

@@ -0,0 +1,112 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
@moduledoc false
use Phoenix.Component
alias BDS.Metadata
alias BDS.Desktop.ShellData
def project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata
end
end
def project_form(metadata) do
%{
"name" => Map.get(metadata, :name, ""),
"description" => Map.get(metadata, :description, ""),
"public_url" => Map.get(metadata, :public_url, ""),
"main_language" => Map.get(metadata, :main_language) || "en",
"default_author" => Map.get(metadata, :default_author, ""),
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
"blogmark_category" =>
Map.get(metadata, :blogmark_category) ||
List.first(Map.get(metadata, :categories, [])) || "article",
"blog_languages" => Map.get(metadata, :blog_languages, []),
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
}
end
def technology_form(project_form) do
%{
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
}
end
def update_project_draft(socket, params, reload) do
socket
|> assign(:settings_editor_project_draft, normalize_project_params(params))
|> reload.(socket.assigns.workbench)
end
def save_project(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
case Metadata.update_project_metadata(project_id, project_attrs(socket.assigns)) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_project_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
defp project_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_project_draft, %{})
%{
name: blank_to_nil(Map.get(draft, "name")),
description: blank_to_nil(Map.get(draft, "description")),
public_url: blank_to_nil(Map.get(draft, "public_url")),
main_language: blank_to_nil(Map.get(draft, "main_language")),
default_author: blank_to_nil(Map.get(draft, "default_author")),
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
blog_languages: Map.get(draft, "blog_languages", []),
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
}
end
defp normalize_project_params(params) do
%{
"name" => Map.get(params, "name", ""),
"description" => Map.get(params, "description", ""),
"public_url" => Map.get(params, "public_url", ""),
"main_language" => Map.get(params, "main_language", "en"),
"default_author" => Map.get(params, "default_author", ""),
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
}
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp parse_integer(nil, fallback), do: fallback
defp parse_integer(value, _fallback) when is_integer(value), do: value
defp parse_integer(value, fallback) do
case Integer.parse(to_string(value)) do
{parsed, _rest} -> parsed
:error -> fallback
end
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end

View File

@@ -0,0 +1,89 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
@moduledoc false
use Phoenix.Component
alias BDS.Metadata
alias BDS.Desktop.ShellData
def publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{})
%{
"ssh_host" => Map.get(prefs, "ssh_host", ""),
"ssh_user" => Map.get(prefs, "ssh_user", ""),
"ssh_remote_path" => Map.get(prefs, "ssh_remote_path", ""),
"ssh_mode" => Map.get(prefs, "ssh_mode", "scp")
}
end
def update_publishing_draft(socket, params, reload) do
socket
|> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
|> reload.(socket.assigns.workbench)
end
def save_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
case Metadata.set_publishing_preferences(project_id, publishing_attrs(socket.assigns)) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_publishing_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Publishing"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def clear_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
case Metadata.set_publishing_preferences(project_id, %{}) do
{:ok, _metadata} ->
socket
|> assign(:settings_editor_publishing_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Publishing"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
defp publishing_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_publishing_draft, %{})
%{
ssh_host: blank_to_nil(Map.get(draft, "ssh_host")),
ssh_user: blank_to_nil(Map.get(draft, "ssh_user")),
ssh_remote_path: blank_to_nil(Map.get(draft, "ssh_remote_path")),
ssh_mode: Map.get(draft, "ssh_mode", "scp")
}
end
defp normalize_publishing_params(params) do
%{
"ssh_host" => Map.get(params, "ssh_host", ""),
"ssh_user" => Map.get(params, "ssh_user", ""),
"ssh_remote_path" => Map.get(params, "ssh_remote_path", ""),
"ssh_mode" => Map.get(params, "ssh_mode", "scp")
}
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> nil
trimmed -> trimmed
end
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end

View File

@@ -0,0 +1,103 @@
defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
@moduledoc false
use Phoenix.Component
alias BDS.Metadata
alias BDS.Desktop.ShellData
@themes [
"default",
"amber",
"blue",
"cyan",
"fuchsia",
"green",
"grey",
"indigo",
"jade",
"lime",
"orange",
"pink",
"pumpkin",
"purple",
"red",
"sand",
"slate",
"violet",
"yellow",
"zinc"
]
def build_style(%{projects: %{active_project_id: nil}}), do: nil
def build_style(assigns) do
selected_theme = Map.get(assigns, :style_editor_theme) || current_theme(assigns)
preview_mode = Map.get(assigns, :style_editor_preview_mode, "auto")
%{
themes: Enum.map(@themes, &style_theme/1),
selected_theme: selected_theme,
applied_theme: current_theme(assigns),
preview_mode: preview_mode,
preview_url: "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
}
end
def select_style_theme(socket, theme, reload) do
socket
|> assign(:style_editor_theme, to_string(theme || "default"))
|> reload.(socket.assigns.workbench)
end
def change_style_preview_mode(socket, mode, reload) do
socket
|> assign(:style_editor_preview_mode, to_string(mode || "auto"))
|> reload.(socket.assigns.workbench)
end
def apply_style_theme(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns)
case Metadata.update_project_metadata(project_id, %{pico_theme: theme}) do
{:ok, _metadata} ->
reload.(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Style"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def theme_display_name(theme) do
theme
|> to_string()
|> String.replace("-", " ")
|> String.capitalize()
end
def current_theme(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} ->
case Map.get(metadata, :pico_theme) do
nil -> "default"
"" -> "default"
theme -> theme
end
end
end
defp style_theme(name) do
%{
name: name,
accent_color: "#4f46e5",
light_bg_color: "#f8fafc",
dark_bg_color: "#0f172a"
}
end
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end