feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34)
This commit is contained in:
273
lib/bds/tui.ex
273
lib/bds/tui.ex
@@ -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] || "?"
|
||||
|
||||
Reference in New Issue
Block a user