feat: TUI settings panel on key 6 with per-section preference editors backed by the same GUI settings backends (issue #29)

This commit is contained in:
2026-07-17 08:38:26 +02:00
parent b6b1d16e54
commit 505527ae4f
13 changed files with 2572 additions and 672 deletions

View File

@@ -862,6 +862,110 @@ defmodule BDS.TUITest do
end
end
describe "settings panel (issue #29)" do
defp open_section(state, title) do
index =
Enum.find_index(state.items, fn
{:item, item} -> item.title == title
_other -> false
end)
moves = index - state.selected
direction = if moves < 0, do: "up", else: "down"
1..abs(moves)//1
|> Enum.reduce(state, fn _step, acc -> press(acc, direction) end)
|> press("enter")
end
defp select_field(state, key) do
index = Enum.find_index(state.settings_form.fields, &(&1.key == key))
moves = index - state.settings_form.selected
direction = if moves < 0, do: "up", else: "down"
Enum.reduce(1..abs(moves)//1, state, fn _step, acc -> press(acc, direction) end)
end
test "6 opens the settings view and lists the sections" do
state = mount!() |> press("6")
assert state.view == "settings"
text = screen_text(state)
assert text =~ "Project"
assert text =~ "Publishing"
assert text =~ "Style"
end
test "enter opens a section form; editing a text field persists on ctrl+s", %{
project: project
} do
state = mount!() |> press("6") |> open_section("Project")
assert state.settings_form != nil
assert state.settings_form.section == "project"
assert screen_text(state, 140, 40) =~ "Posts per Page"
state = select_field(state, "name") |> press("enter")
assert state.settings_form.input != nil
# The prompt is seeded with the current value; replace it.
state =
Enum.reduce(1..3, state, fn _n, acc -> press(acc, "backspace") end)
|> type("TUI Prefs")
|> press("enter")
assert state.settings_form.input == nil
assert state.settings_form.dirty
state = press(state, "s", ["ctrl"])
refute state.settings_form.dirty
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.name == "TUI Prefs"
end
test "enter toggles booleans and cycles enums", %{project: project} do
state = mount!() |> press("6") |> open_section("Publishing")
state = select_field(state, "ssh_mode")
assert current_field(state).value == "scp"
state = press(state, "enter")
assert current_field(state).value == "rsync"
state = state |> select_field("ssh_host") |> press("enter") |> type("example.org") |> press("enter")
state = press(state, "s", ["ctrl"])
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.publishing_preferences["ssh_mode"] == "rsync"
assert metadata.publishing_preferences["ssh_host"] == "example.org"
# Booleans toggle in place.
state = state |> press("esc") |> open_section("Technology")
state = select_field(state, "semantic_similarity_enabled")
refute current_field(state).value
state = press(state, "enter")
assert current_field(state).value
end
test "esc in a prompt cancels only the prompt; esc again closes the form" do
state = mount!() |> press("6") |> open_section("Publishing")
state = select_field(state, "ssh_host") |> press("enter")
assert state.settings_form.input != nil
state = press(state, "esc")
assert state.settings_form != nil
assert state.settings_form.input == nil
state = press(state, "esc")
assert state.settings_form == nil
assert state.view == "settings"
end
defp current_field(state),
do: Enum.at(state.settings_form.fields, state.settings_form.selected)
end
test "local tui mode stops the VM when the app exits" do
parent = self()
state = mount!(stop_vm_on_exit: true, stop_fun: fn -> send(parent, :vm_stopped) end)

View File

@@ -0,0 +1,200 @@
defmodule BDS.UI.SettingsFormTest do
use ExUnit.Case, async: false
alias BDS.UI.SettingsForm
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual) end)
temp_dir = Path.join(System.tmp_dir!(), "bds-settings-form-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Prefs", data_path: temp_dir})
{:ok, _} = BDS.Projects.set_active_project(project.id)
%{project: project}
end
defp field(form, key), do: Enum.find(form.fields, &(&1.key == key))
defp values(form, overrides) do
form.fields
|> Map.new(fn f -> {f.key, f.value} end)
|> Map.merge(overrides)
end
describe "project" do
test "load exposes the project metadata as typed fields", %{project: project} do
form = SettingsForm.load("project", project.id)
assert field(form, "name").value == "Prefs"
assert field(form, "name").type == :text
assert field(form, "main_language").type == :enum
assert "de" in field(form, "main_language").options
assert field(form, "semantic_similarity_enabled").type == :bool
end
test "save writes through Metadata and keeps untouched values", %{project: project} do
{:ok, _} = BDS.Metadata.update_project_metadata(project.id, %{pico_theme: "jade"})
form = SettingsForm.load("project", project.id)
:ok =
SettingsForm.save(
"project",
project.id,
values(form, %{
"name" => "Renamed",
"max_posts_per_page" => "25",
"blog_languages" => "en, de",
"semantic_similarity_enabled" => true
})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.name == "Renamed"
assert metadata.max_posts_per_page == 25
assert metadata.blog_languages == ["en", "de"]
assert metadata.semantic_similarity_enabled
assert metadata.pico_theme == "jade"
end
end
describe "editor" do
test "round-trips the global editor settings", %{project: project} do
form = SettingsForm.load("editor", project.id)
assert field(form, "default_mode").type == :enum
:ok =
SettingsForm.save(
"editor",
project.id,
values(form, %{"default_mode" => "preview", "wrap_long_lines" => true})
)
assert BDS.Settings.get_global_setting("ui.preferred_editor_mode") == "preview"
assert BDS.Settings.get_global_setting("ui.git_diff_word_wrap") == "true"
reloaded = SettingsForm.load("editor", project.id)
assert field(reloaded, "default_mode").value == "preview"
assert field(reloaded, "wrap_long_lines").value == true
end
end
describe "content" do
test "edits category settings and adds new categories", %{project: project} do
form = SettingsForm.load("content", project.id)
assert field(form, "cat:aside:show_title").type == :bool
:ok =
SettingsForm.save(
"content",
project.id,
values(form, %{
"new_category" => "linkroll",
"cat:article:render_in_lists" => false,
"cat:article:title" => "Articles"
})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert "linkroll" in metadata.categories
assert metadata.category_settings["article"]["render_in_lists"] == false
assert metadata.category_settings["article"]["title"] == "Articles"
end
end
describe "technology" do
test "toggles semantic similarity without wiping the project", %{project: project} do
form = SettingsForm.load("technology", project.id)
refute field(form, "semantic_similarity_enabled").value
:ok =
SettingsForm.save(
"technology",
project.id,
values(form, %{"semantic_similarity_enabled" => true})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.semantic_similarity_enabled
assert metadata.name == "Prefs"
end
end
describe "publishing" do
test "saves the SSH preferences", %{project: project} do
form = SettingsForm.load("publishing", project.id)
assert field(form, "ssh_mode").options == ["scp", "rsync"]
:ok =
SettingsForm.save(
"publishing",
project.id,
values(form, %{"ssh_host" => "example.org", "ssh_mode" => "rsync"})
)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.publishing_preferences["ssh_host"] == "example.org"
assert metadata.publishing_preferences["ssh_mode"] == "rsync"
end
end
describe "ai" do
test "loads the AI form and saves prompt plus airplane mode", %{project: project} do
form = SettingsForm.load("ai", project.id)
assert field(form, "offline_mode").type == :bool
assert field(form, "system_prompt").type == :text
:ok =
SettingsForm.save(
"ai",
project.id,
values(form, %{"system_prompt" => "be terse", "offline_mode" => true})
)
assert BDS.Settings.get_global_setting("ai.system_prompt") == "be terse"
assert BDS.AI.airplane_mode?()
end
end
describe "style" do
test "applies a theme without wiping other metadata", %{project: project} do
form = SettingsForm.load("style", project.id)
assert "jade" in field(form, "pico_theme").options
:ok = SettingsForm.save("style", project.id, values(form, %{"pico_theme" => "jade"}))
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
assert metadata.pico_theme == "jade"
assert metadata.name == "Prefs"
end
end
describe "data" do
test "shows the data folder read-only and save is a no-op", %{project: project} do
form = SettingsForm.load("data", project.id)
data_field = field(form, "data_path")
assert data_field.type == :info
assert data_field.value =~ "bds-settings-form"
assert SettingsForm.save("data", project.id, %{}) == :ok
end
end
describe "mcp" do
test "lists the agents with supported ones as toggles", %{project: project} do
form = SettingsForm.load("mcp", project.id)
claude = field(form, "mcp:claude_code")
assert claude.type == :bool
assert is_boolean(claude.value)
gemini = field(form, "mcp:gemini_cli")
assert gemini.type == :info
end
end
end