feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34)

This commit is contained in:
2026-07-17 14:50:25 +02:00
parent 0f3f1efa08
commit 2fd132e827
11 changed files with 1987 additions and 661 deletions

View File

@@ -7,7 +7,6 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
alias BDS.{Repo, Tags}
alias BDS.Desktop.ShellLive.Notify
alias BDS.Posts.Post
alias BDS.Tags.Tag
alias BDS.Templates.Template
use Gettext, backend: BDS.Gettext
@@ -16,12 +15,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
@tags_sections ~w(cloud manage merge)
@colour_presets ~w(
#ef4444 #f97316 #f59e0b #eab308 #84cc16
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
# Shared with the TUI tags panel (issue #34) via BDS.UI.TagsPanel.
@colour_presets BDS.UI.TagsPanel.colour_presets()
@spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets
@@ -439,12 +434,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
defp current_tab_meta(_assigns), do: %{}
defp tag_counts(project_id) do
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
end
defp tag_counts(project_id), do: BDS.UI.TagsPanel.tag_counts(project_id)
defp blank_to_nil(nil), do: nil

View File

@@ -45,6 +45,7 @@ defmodule BDS.TUI do
alias BDS.UI.PostEditor.{Draft, Metadata, Persistence}
alias BDS.UI.SettingsForm
alias BDS.UI.Sidebar
alias BDS.UI.TagsPanel
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
@@ -78,6 +79,7 @@ defmodule BDS.TUI do
git: nil,
git_commit: nil,
settings_form: nil,
tags_panel: nil,
report: nil,
handled_task_ids: nil,
task_polling: false,
@@ -149,6 +151,10 @@ defmodule BDS.TUI do
when form != nil,
do: settings_form_key(key, state)
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{tags_panel: panel} = state)
when panel != nil,
do: tags_panel_key(key, state)
def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state)
when image != nil,
do: {:noreply, %{state | image: nil}}
@@ -240,6 +246,7 @@ defmodule BDS.TUI do
%{route: "git_file", id: path} -> {:noreply, jump_to_file_diff(state, path)}
%{route: "settings", id: "settings-" <> section} -> {:noreply, open_settings_form(state, section)}
%{route: "style"} -> {:noreply, open_settings_form(state, "style")}
%{route: "tags", id: "tags-" <> section} -> {:noreply, open_tags_panel(state, section)}
%{id: _id} -> {:noreply, toast(state, view_label(state.view), dgettext("ui", "Editing this item is not available in the terminal UI yet."))}
_other -> {:noreply, state}
end
@@ -563,6 +570,217 @@ defmodule BDS.TUI do
end
end
# ── Events: tags panel (issue #34) ────────────────────────────────────────
# The tags sidebar entries open tag management in the main area — the
# same BDS.Tags operations as the GUI tags editor, shared through
# BDS.UI.TagsPanel: cloud (usage overview), manage (create / rename /
# colour / template / delete / sync-from-posts), merge (mark tags with
# space, merge them into the selected one).
defp open_tags_panel(state, section) when section in ~w(cloud manage merge) do
data = TagsPanel.load(state.project_id)
%{
state
| tags_panel: %{
section: section,
tags: data.tags,
selected: 0,
marked: [],
input: nil,
confirm_delete: nil
},
image: nil
}
end
defp open_tags_panel(state, _section), do: state
defp reload_tags_panel(%{tags_panel: panel} = state) do
data = TagsPanel.load(state.project_id)
names = MapSet.new(data.tags, & &1.name)
panel = %{
panel
| tags: data.tags,
selected: min(panel.selected, max(length(data.tags) - 1, 0)),
marked: Enum.filter(panel.marked, &MapSet.member?(names, &1)),
input: nil,
confirm_delete: nil
}
load_sidebar(%{state | tags_panel: panel})
end
# Text input prompt (new tag name / rename) lives in the status line,
# like the settings form and the commit message prompt.
defp tags_panel_key(%{code: "esc"}, %{tags_panel: %{input: input}} = state) when input != nil,
do: {:noreply, put_in(state.tags_panel.input, nil)}
defp tags_panel_key(%{code: "enter"}, %{tags_panel: %{input: input}} = state)
when input != nil do
result =
case input do
%{kind: :new} -> TagsPanel.create(state.project_id, input.value)
%{kind: :rename, name: name} -> TagsPanel.rename(state.project_id, name, input.value)
end
{:noreply, tags_panel_result(state, result)}
end
defp tags_panel_key(%{code: "backspace"}, %{tags_panel: %{input: input}} = state)
when input != nil do
{:noreply, put_in(state.tags_panel.input.value, String.slice(input.value, 0..-2//1))}
end
defp tags_panel_key(%{code: code, modifiers: []}, %{tags_panel: %{input: input}} = state)
when input != nil and byte_size(code) >= 1 do
if String.length(code) == 1 do
{:noreply, put_in(state.tags_panel.input.value, input.value <> code)}
else
{:noreply, state}
end
end
defp tags_panel_key(_key, %{tags_panel: %{input: input}} = state) when input != nil,
do: {:noreply, state}
# Pending delete confirmation: y deletes, anything else cancels.
defp tags_panel_key(%{code: "y"}, %{tags_panel: %{confirm_delete: name}} = state)
when name != nil do
{:noreply, tags_panel_result(state, TagsPanel.delete(state.project_id, name))}
end
defp tags_panel_key(_key, %{tags_panel: %{confirm_delete: name}} = state) when name != nil do
state = put_in(state.tags_panel.confirm_delete, nil)
{:noreply, toast(state, view_label("tags"), dgettext("ui", "Delete cancelled"))}
end
defp tags_panel_key(%{code: "esc"}, state), do: {:noreply, %{state | tags_panel: nil}}
defp tags_panel_key(%{code: code}, state) when code in ["down", "j"],
do: {:noreply, move_tags_selection(state, 1)}
defp tags_panel_key(%{code: code}, state) when code in ["up", "k"],
do: {:noreply, move_tags_selection(state, -1)}
defp tags_panel_key(%{code: "n"}, %{tags_panel: %{section: "manage"}} = state) do
input = %{kind: :new, label: dgettext("ui", "New tag"), value: ""}
{:noreply, put_in(state.tags_panel.input, input)}
end
defp tags_panel_key(%{code: "enter"}, %{tags_panel: %{section: "manage"}} = state) do
case selected_tag(state.tags_panel) do
nil ->
{:noreply, state}
tag ->
input = %{
kind: :rename,
name: tag.name,
label: dgettext("ui", "Rename tag"),
value: tag.name
}
{:noreply, put_in(state.tags_panel.input, input)}
end
end
defp tags_panel_key(%{code: "c"}, %{tags_panel: %{section: "manage"}} = state),
do: with_selected_tag(state, &TagsPanel.cycle_color(state.project_id, &1.name))
defp tags_panel_key(%{code: "t"}, %{tags_panel: %{section: "manage"}} = state),
do: with_selected_tag(state, &TagsPanel.cycle_template(state.project_id, &1.name))
defp tags_panel_key(%{code: "d"}, %{tags_panel: %{section: "manage"}} = state) do
case selected_tag(state.tags_panel) do
nil ->
{:noreply, state}
tag ->
count = TagsPanel.post_count(state.project_id, tag.name)
state = put_in(state.tags_panel.confirm_delete, tag.name)
{:noreply,
toast(
state,
view_label("tags"),
dgettext("ui", "Delete %{name} (used by %{count} posts)? Press y to confirm.",
name: tag.name,
count: count
)
)}
end
end
defp tags_panel_key(%{code: "s"}, %{tags_panel: %{section: "manage"}} = state),
do: {:noreply, tags_panel_result(state, TagsPanel.sync(state.project_id))}
defp tags_panel_key(%{code: code}, %{tags_panel: %{section: "merge"}} = state)
when code in [" ", "space"] do
case selected_tag(state.tags_panel) do
nil ->
{:noreply, state}
tag ->
marked = state.tags_panel.marked
next =
if tag.name in marked,
do: marked -- [tag.name],
else: marked ++ [tag.name]
{:noreply, put_in(state.tags_panel.marked, next)}
end
end
defp tags_panel_key(%{code: "m"}, %{tags_panel: %{section: "merge"}} = state) do
case selected_tag(state.tags_panel) do
nil ->
{:noreply, state}
target ->
marked = Enum.uniq(state.tags_panel.marked ++ [target.name])
{:noreply, tags_panel_result(state, TagsPanel.merge(state.project_id, marked, target.name))}
end
end
defp tags_panel_key(_key, state), do: {:noreply, state}
defp tags_panel_result(state, {:ok, message}) do
state |> reload_tags_panel() |> toast(view_label("tags"), message)
end
defp tags_panel_result(state, {:error, message}) do
state = put_in(state.tags_panel.input, nil)
state = put_in(state.tags_panel.confirm_delete, nil)
toast(state, view_label("tags"), message)
end
defp with_selected_tag(state, fun) do
case selected_tag(state.tags_panel) do
nil -> {:noreply, state}
tag -> {:noreply, tags_panel_result(state, fun.(tag))}
end
end
# The cloud orders by usage (a terminal tag cloud); manage and merge
# stay alphabetical like the GUI list.
defp panel_tags(%{section: "cloud", tags: tags}), do: Enum.sort_by(tags, &{-&1.count, &1.name})
defp panel_tags(%{tags: tags}), do: tags
defp selected_tag(panel), do: Enum.at(panel_tags(panel), panel.selected)
defp move_tags_selection(%{tags_panel: panel} = state, delta) do
count = length(panel.tags)
if count == 0 do
state
else
put_in(state.tags_panel.selected, clamp(panel.selected + delta, count))
end
end
# ── Events: sidebar search (vi-style "/") ─────────────────────────────────
# The prompt lives in the status line and filters the current view live
@@ -921,6 +1139,14 @@ defmodule BDS.TUI do
state
end
# An open tags panel tracks external tag changes (other clients, CLI).
state =
if state.tags_panel != nil and entity == "tag" do
reload_tags_panel(state)
else
state
end
{:noreply, state}
end
@@ -1155,6 +1381,20 @@ defmodule BDS.TUI do
]
end
defp main_widgets(%{tags_panel: panel}, rect) when panel != nil do
items = Enum.map(panel_tags(panel), &tag_line(panel, &1))
[
{%List{
items: items,
selected: list_selected(items, panel.selected),
scroll_padding: 2,
highlight_style: %Style{fg: :black, bg: :cyan},
block: %Block{title: tags_panel_title(panel), borders: [:all]}
}, rect}
]
end
defp main_widgets(%{view: "git", editor: nil, git: nil}, rect) do
[
{%Paragraph{
@@ -1241,10 +1481,12 @@ defmodule BDS.TUI do
defp status_widgets(state, rect) do
settings_input = state.settings_form && state.settings_form.input
tags_input = state.tags_panel && state.tags_panel.input
text =
cond do
settings_input -> settings_input.label <> ": " <> settings_input.value
tags_input -> tags_input.label <> ": " <> tags_input.value
state.git_commit -> dgettext("ui", "Commit message") <> ": " <> state.git_commit.input
state.search -> "/" <> state.search.input
state.busy -> dgettext("ui", "Working…")
@@ -1654,6 +1896,37 @@ defmodule BDS.TUI do
defp field_line(%{type: :info, value: ""} = field), do: field.label
defp field_line(field), do: "#{field.label}: #{field.value}"
defp tag_line(%{section: "merge", marked: marked}, tag) do
if(tag.name in marked, do: "[x] ", else: "[ ] ") <> tag_line_base(tag)
end
defp tag_line(%{section: "manage"}, tag) do
extras =
[tag.color, tag.post_template_slug]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.map_join("", &(" · " <> &1))
tag_line_base(tag) <> extras
end
defp tag_line(_panel, tag), do: tag_line_base(tag)
defp tag_line_base(tag), do: "#{tag.name} (#{tag.count})"
defp tags_panel_title(%{section: "cloud"}),
do: dgettext("ui", "Tag Cloud") <> "" <> dgettext("ui", "esc close")
defp tags_panel_title(%{section: "manage"}) do
dgettext("ui", "Tags") <>
"" <>
dgettext("ui", "n new · enter rename · c colour · t template · d delete · s sync · esc close")
end
defp tags_panel_title(%{section: "merge"}) do
dgettext("ui", "Merge Tags") <>
"" <> dgettext("ui", "space mark · m merge into selected · esc close")
end
defp item_label(item) do
title =
item[:title] || item[:name] || item[:original_name] || item[:label] || item[:id] || "?"

240
lib/bds/ui/tags_panel.ex Normal file
View File

@@ -0,0 +1,240 @@
defmodule BDS.UI.TagsPanel do
@moduledoc """
Renderer-agnostic tag management core (issue #34).
Exposes the same tag operations the GUI tags editor
(`BDS.Desktop.ShellLive.TagsEditor`) performs — create, rename, colour,
post template, delete, merge, sync-from-posts — as plain functions with
localized result messages, so the TUI tags panel and the GUI operate on
the same `BDS.Tags` backend.
"""
import Ecto.Query
use Gettext, backend: BDS.Gettext
alias BDS.Posts.Post
alias BDS.Repo
alias BDS.Tags
alias BDS.Tags.Tag
alias BDS.Templates.Template
@colour_presets ~w(
#ef4444 #f97316 #f59e0b #eab308 #84cc16
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
@type tag_entry :: %{
id: String.t(),
name: String.t(),
color: String.t() | nil,
post_template_slug: String.t() | nil,
count: non_neg_integer()
}
@spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets
@doc "All project tags with their post usage counts, plus template slugs."
@spec load(String.t()) :: %{tags: [tag_entry()], templates: [String.t()]}
def load(project_id) do
counts = tag_counts(project_id)
tags =
project_id
|> Tags.list_tags()
|> Enum.map(fn tag ->
%{
id: tag.id,
name: tag.name,
color: tag.color,
post_template_slug: tag.post_template_slug,
count: Map.get(counts, tag.name, 0)
}
end)
%{tags: tags, templates: template_slugs(project_id)}
end
@doc "How many posts use each tag name."
@spec tag_counts(String.t()) :: %{String.t() => non_neg_integer()}
def tag_counts(project_id) do
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
end
@spec post_count(String.t(), String.t()) :: non_neg_integer()
def post_count(project_id, tag_name), do: Map.get(tag_counts(project_id), tag_name, 0)
@spec create(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def create(project_id, name) do
case Tags.create_tag(%{project_id: project_id, name: name}) do
{:ok, tag} -> {:ok, dgettext("ui", "Tag %{name} created", name: tag.name)}
{:error, reason} -> {:error, error_message(reason)}
end
end
@spec rename(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def rename(project_id, tag_name, new_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
normalized = String.trim(to_string(new_name || ""))
cond do
normalized == "" ->
{:error, dgettext("ui", "Tag name cannot be empty")}
normalized == tag.name ->
{:ok, dgettext("ui", "Tag unchanged")}
true ->
case Tags.rename_tag(tag.id, normalized) do
{:ok, renamed} ->
{:ok, dgettext("ui", "Tag renamed to %{name}", name: renamed.name)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
end
@doc "Advances the tag colour through the presets (last preset wraps to none)."
@spec cycle_color(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def cycle_color(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
next = next_in_cycle(@colour_presets, tag.color)
case Tags.update_tag(tag.id, %{color: next}) do
{:ok, updated} ->
{:ok,
dgettext("ui", "Colour of %{name}: %{color}",
name: updated.name,
color: updated.color || dgettext("ui", "none")
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@doc "Advances the tag post template through the project templates (wraps to none)."
@spec cycle_template(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def cycle_template(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
next = next_in_cycle(template_slugs(project_id), tag.post_template_slug)
case Tags.update_tag(tag.id, %{post_template_slug: next}) do
{:ok, updated} ->
{:ok,
dgettext("ui", "Template of %{name}: %{template}",
name: updated.name,
template: updated.post_template_slug || dgettext("ui", "default")
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@spec delete(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def delete(project_id, tag_name) do
with {:ok, tag} <- fetch(project_id, tag_name) do
case Tags.delete_tag(tag.id) do
{:ok, _deleted} -> {:ok, dgettext("ui", "Tag %{name} deleted", name: tag_name)}
{:error, reason} -> {:error, error_message(reason)}
end
end
end
@doc "Merges the named tags into `target_name` (which must be one of them)."
@spec merge(String.t(), [String.t()], String.t()) :: {:ok, String.t()} | {:error, String.t()}
def merge(project_id, source_names, target_name) do
tags =
Repo.all(
from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^source_names
)
target = Enum.find(tags, &(&1.name == target_name))
sources = Enum.reject(tags, &(&1.name == target_name))
cond do
target == nil ->
{:error, dgettext("ui", "The merge target must be one of the marked tags")}
sources == [] ->
{:error, dgettext("ui", "Mark at least two tags to merge")}
true ->
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
{:ok, _merged} ->
{:ok,
dgettext("ui", "Merged %{count} tags into %{name}",
count: length(sources),
name: target.name
)}
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
@spec sync(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def sync(project_id) do
case Tags.sync_tags_from_posts(project_id) do
{:ok, tags} ->
{:ok, dgettext("ui", "Tags synced from posts (%{count} total)", count: length(tags))}
{:error, reason} ->
{:error, error_message(reason)}
end
end
defp fetch(project_id, tag_name) do
case Repo.get_by(Tag, project_id: project_id, name: tag_name) do
nil -> {:error, dgettext("ui", "Tag %{name} not found", name: tag_name)}
tag -> {:ok, tag}
end
end
defp template_slugs(project_id) do
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
order_by: [asc: template.slug],
select: template.slug
)
end
# nil → first option → … → last option → nil
defp next_in_cycle([], _current), do: nil
defp next_in_cycle(options, current) do
case Enum.find_index(options, &(&1 == current)) do
nil -> List.first(options)
index when index == length(options) - 1 -> nil
index -> Enum.at(options, index + 1)
end
end
defp error_message(reason) when is_binary(reason), do: reason
defp error_message(:not_found), do: dgettext("ui", "Tag not found")
defp error_message(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
end
defp error_message(reason), do: inspect(reason)
end