chore: refactored the UI locale handling

This commit is contained in:
2026-05-01 15:58:55 +02:00
parent 62e44150b3
commit 296a57814f
36 changed files with 114 additions and 40 deletions

View File

@@ -37,11 +37,15 @@ _None._ All modules previously on the queue have been split; refresh the queue i
## 2. Process Dictionary for i18n State ## 2. Process Dictionary for i18n State
**Status:** open. `Process.put(:bds_ui_locale, )` + `Process.get(:bds_ui_locale)` in `render/1` of `BDS.Desktop.ShellLive` and every `*_editor.ex` (15 sites total). **Status:** ✅ encapsulated (2026-05-10). Raw `Process.put(:bds_ui_locale, _)` / `Process.get(:bds_ui_locale)` no longer appears outside `BDS.Desktop.UILocale`. The two render boundaries (`BDS.Desktop.ShellLive.render/1` and `BDS.Desktop.ShellLive.SidebarComponents.sidebar_content/1`) now call `UILocale.put/1`; the ~30 helper read sites call `BDS.Desktop.UILocale.current/0`. The full thread-locale-through-assigns rewrite (~733 HEEx call sites) was rejected as too invasive for the marginal benefit; the encapsulation removes the implicit-global smell while keeping the LiveView lazy-render path intact.
**Why it matters:** implicit global state; complicates per-process isolation in tests and risks leaks between concurrent operations in the same process. **Why the helper does not restore on render exit:** Phoenix's `~H` returns a `Phoenix.LiveView.Rendered` whose `dynamic` function is invoked lazily by LiveView *after* `render/1` returns. A `try/after Process.delete` wrapper around `render/1` would clear the binding before child components materialize, so render boundaries use `UILocale.put/1` (set-only). `UILocale.with_locale/2` is provided for short-lived non-LiveView contexts (background tasks, scripts) that consume the binding eagerly inside the closure.
**Plan:** thread `locale` through `assigns`/render args; replace `translated/1,2` with a 3-arity helper that takes the locale explicitly; ban `Process.put` via Credo afterwards. **Module rules:**
- Only `lib/bds/desktop/ui_locale.ex` may touch the `:bds_ui_locale` process key.
- New code that needs the active UI locale must call `BDS.Desktop.UILocale.current/0` (or take it as an argument).
- New render entry points that change the locale must call `BDS.Desktop.UILocale.put/1` at the top of `render/1`.
--- ---
@@ -165,6 +169,10 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`
## Changelog ## Changelog
### 2026-05-10
- **Process dictionary for i18n state (Section 2)**: encapsulated behind `BDS.Desktop.UILocale` (`lib/bds/desktop/ui_locale.ex`, ~50 lines). Public surface: `put/1` (set without restore, for LV render boundaries that return lazy `Rendered`), `with_locale/2` (set + try/after restore, for short-lived eager contexts), `current/0` (read, returns `nil` when unset). The two raw `Process.put(:bds_ui_locale, _)` sites (`BDS.Desktop.ShellLive.render/1` and `BDS.Desktop.ShellLive.SidebarComponents.sidebar_content/1`) now call `UILocale.put/1`; the ~30 raw `Process.get(:bds_ui_locale)` reads (every editor `translated/1,2` helper plus `BDS.Desktop.ShellData.effective_ui_language/1`) now call `BDS.Desktop.UILocale.current/0`. The full thread-locale-through-assigns rewrite (~733 HEEx call sites) was deliberately rejected as too invasive; the encapsulation removes the implicit-global smell while preserving Phoenix's lazy `Rendered` evaluation. The render path uses `put/1` (not `with_locale/2`) because Phoenix `~H` returns a `Rendered` whose `dynamic` is invoked by LiveView *after* `render/1` returns; a `try/after Process.delete` would clear the binding before child components materialize. Only `BDS.Desktop.UILocale` is allowed to touch the `:bds_ui_locale` process key. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped, three consecutive runs).
### 2026-05-09 ### 2026-05-09
- **God modules**: - **God modules**:

View File

@@ -249,7 +249,7 @@ defmodule BDS.Desktop.ShellData do
end end
defp effective_ui_language(nil) do defp effective_ui_language(nil) do
Process.get(:bds_ui_locale) || ui_language() BDS.Desktop.UILocale.current() || ui_language()
end end
defp effective_ui_language(locale), do: locale defp effective_ui_language(locale), do: locale

View File

@@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.AI alias BDS.AI
alias BDS.CliSync.Watcher alias BDS.CliSync.Watcher
alias BDS.Desktop.{FolderPicker, Overlay, ShellData} alias BDS.Desktop.{FolderPicker, Overlay, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, ImportEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, ImportEditor, MediaEditor, MenuEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.PostEditor
@@ -1285,7 +1285,7 @@ defmodule BDS.Desktop.ShellLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
Process.put(:bds_ui_locale, assigns.page_language) UILocale.put(assigns.page_language)
index(assigns) index(assigns)
end end
@@ -1346,7 +1346,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign_misc_editor() |> assign_misc_editor()
end end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, UILocale.current())
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts) defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)

View File

@@ -572,5 +572,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp format_error(reason), do: inspect(reason) defp format_error(reason), do: inspect(reason)
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -114,5 +114,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
defp streaming_content(_request), do: "" defp streaming_content(_request), do: ""
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -76,5 +76,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
defp blank?(nil), do: true defp blank?(nil), do: true
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -270,5 +270,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp truthy?(_value), do: false defp truthy?(_value), do: false
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -229,5 +229,5 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
end end
end end
defp translated(text), do: ShellData.translate(text, %{}, Process.get(:bds_ui_locale)) defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current())
end end

View File

@@ -190,7 +190,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_template(_assigns), do: nil def build_template(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def format_timestamp(nil), do: "" def format_timestamp(nil), do: ""
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp) def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)

View File

@@ -770,7 +770,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end end
end end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: value in [nil, ""]
end end

View File

@@ -243,6 +243,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
def translate_phase(other), do: other def translate_phase(other), do: other
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
end end

View File

@@ -242,5 +242,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
def translate_execution_phase(other), do: other def translate_execution_phase(other), do: other
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -199,7 +199,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
def maybe_put_option(opts, _key, nil), do: opts def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value) def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
defp blank_to_nil(""), do: nil defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value defp blank_to_nil(value), do: value

View File

@@ -415,7 +415,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def media_editor_save_state_label(:dirty), do: translated("Unsaved") def media_editor_save_state_label(:dirty), do: translated("Unsaved")
def media_editor_save_state_label(:saved), do: translated("Saved") def media_editor_save_state_label(:saved), do: translated("Saved")

View File

@@ -307,7 +307,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def row_label(item, category_titles) do def row_label(item, category_titles) do
if item.kind == :category_archive do if item.kind == :category_archive do

View File

@@ -128,5 +128,5 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -81,5 +81,5 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -214,7 +214,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def misc_class(:site_validation), do: "site-validation-view" def misc_class(:site_validation), do: "site-validation-view"
def misc_class(:metadata_diff), do: "metadata-diff-view" def misc_class(:metadata_diff), do: "metadata-diff-view"

View File

@@ -59,7 +59,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
def markdown_link(text, url), do: "[#{text}](#{url})" def markdown_link(text, url), do: "[#{text}](#{url})"
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def project_metadata(nil), do: %{main_language: "en", blog_languages: []} def project_metadata(nil), do: %{main_language: "en", blog_languages: []}

View File

@@ -286,5 +286,5 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -500,7 +500,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
def post_editor_mode_label(:preview), do: translated("Preview") def post_editor_mode_label(:preview), do: translated("Preview")
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{}) defp assigned_project_metadata(assigns), do: Map.get(assigns, :project_metadata, %{})
end end

View File

@@ -101,5 +101,5 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -186,5 +186,5 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -143,7 +143,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
end end
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp current_settings_section(assigns) do defp current_settings_section(assigns) do
meta = current_tab_meta(assigns) meta = current_tab_meta(assigns)

View File

@@ -199,5 +199,5 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -89,5 +89,5 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
defp boolean_string(false), do: "false" defp boolean_string(false), do: "false"
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -190,5 +190,5 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -96,5 +96,5 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -108,5 +108,5 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -85,5 +85,5 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -99,5 +99,5 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
end end
defp translated(text, bindings \\ %{}), defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -4,10 +4,11 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
use Phoenix.Component use Phoenix.Component
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.UILocale
alias BDS.UI.Registry alias BDS.UI.Registry
def sidebar_content(assigns) do def sidebar_content(assigns) do
Process.put(:bds_ui_locale, assigns.page_language) UILocale.put(assigns.page_language)
assigns = prepare_filter_assigns(assigns) assigns = prepare_filter_assigns(assigns)
~H""" ~H"""
@@ -462,7 +463,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
""" """
end end
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp template_sidebar?(sidebar_data), do: Map.get(sidebar_data, :title) == "Templates" defp template_sidebar?(sidebar_data), do: Map.get(sidebar_data, :title) == "Templates"

View File

@@ -127,5 +127,5 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
def action(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"} def action(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"}
def action(_view), do: nil def action(_view), do: nil
defp translated(text), do: ShellData.translate(text, %{}, Process.get(:bds_ui_locale)) defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current())
end end

View File

@@ -95,5 +95,5 @@ defmodule BDS.Desktop.ShellLive.TabHelpers do
end end
end end
defp translated(text), do: ShellData.translate(text, %{}, Process.get(:bds_ui_locale)) defp translated(text), do: ShellData.translate(text, %{}, BDS.Desktop.UILocale.current())
end end

View File

@@ -187,7 +187,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def tag_font_size(count, counts) do def tag_font_size(count, counts) do
max_count = Enum.max([1 | Enum.map(counts, & &1.count)]) max_count = Enum.max([1 | Enum.map(counts, & &1.count)])

View File

@@ -0,0 +1,65 @@
defmodule BDS.Desktop.UILocale do
@moduledoc """
Per-render UI locale binding for the desktop LiveView shell.
The shell renders the UI in the user's selected language, which can differ
from the OS locale and from the project's `mainLanguage`. Phoenix HEEx
templates call `translated/1,2` helpers without an explicit locale, so we
bind the active locale once per `render/1` and the helpers read it back.
This module encapsulates that binding so call sites do not touch the raw
process dictionary directly. Use `with_locale/2` around any render or
component that needs a locale binding; use `current/0` to read it.
Direct use of `Process.put(:bds_ui_locale, _)` or
`Process.get(:bds_ui_locale)` is forbidden outside this module.
"""
@key :bds_ui_locale
@typedoc "A normalized UI locale code such as `\"en\"` or `\"de\"`."
@type locale :: String.t() | nil
@doc """
Bind `locale` for any subsequent reads of `current/0` in this process.
Used at LiveView render boundaries. The binding persists past the call
(mirroring per-process locale state) so that lazily evaluated child
components see the active locale. Each render boundary overwrites it.
"""
@spec put(locale()) :: :ok
def put(locale) do
Process.put(@key, locale)
:ok
end
@doc """
Set the UI locale for the duration of `fun`, then restore the prior value.
Safe under exceptions: the prior binding is restored in an `after` clause.
Use this for short-lived non-LiveView contexts (background tasks, scripts)
where eager evaluation guarantees the binding is consumed before `fun`
returns. Do not use around LiveView `render/1` because the returned
`Phoenix.LiveView.Rendered` struct evaluates its dynamic parts lazily.
"""
@spec with_locale(locale(), (-> result)) :: result when result: var
def with_locale(locale, fun) when is_function(fun, 0) do
previous = Process.get(@key)
Process.put(@key, locale)
try do
fun.()
after
restore(previous)
end
end
@doc """
Read the active UI locale binding for this process, or `nil` when unset.
"""
@spec current() :: locale()
def current, do: Process.get(@key)
defp restore(nil), do: Process.delete(@key)
defp restore(value), do: Process.put(@key, value)
end