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

@@ -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] || "?"