feat: vi-style / search in the TUI sidebar with tag:, category: and ISO-date filters (issue #31)

This commit is contained in:
2026-07-16 21:42:19 +02:00
parent 3675a26407
commit e027364c0e
17 changed files with 1044 additions and 750 deletions

View File

@@ -13,7 +13,9 @@ defmodule BDS.TUI do
`1..5` switch view (posts/media/templates/scripts/tags), `r` refresh,
`p` projects overlay (switch the active project or open an existing
blog folder by path, with bash-style tab completion — opening one
queues a full database rebuild),
queues a full database rebuild), `/` vi-style search that live-filters
the current view (plain text, `tag:x`, `category:x`, or an ISO date
`yyyy-mm-dd`; enter keeps the filter, esc clears it),
`:` vi-style command prompt over the Blog-menu shell commands, `?`
command help (commands whose report/apply UI is GUI-only are marked).
Editor focus: text keys edit, `ctrl+s` save, `ctrl+p` publish,
@@ -59,6 +61,8 @@ defmodule BDS.TUI do
task_snapshot_fun: Keyword.get(opts, :task_snapshot_fun, &BDS.Tasks.status_snapshot/0),
command: nil,
projects: nil,
search: nil,
filters_by_view: %{},
report: nil,
handled_task_ids: nil,
task_polling: false,
@@ -118,6 +122,10 @@ defmodule BDS.TUI do
when projects != nil,
do: projects_key(key, state)
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{search: search} = state)
when search != nil,
do: search_key(key, state)
def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state)
when image != nil,
do: {:noreply, %{state | image: nil}}
@@ -148,6 +156,9 @@ defmodule BDS.TUI do
defp sidebar_key(%{code: ":"}, state),
do: {:noreply, %{state | command: %{input: "", selected: 0, help?: false}}}
defp sidebar_key(%{code: "/"}, state),
do: {:noreply, %{state | search: %{input: Map.get(state.filters_by_view, state.view, "")}}}
defp sidebar_key(%{code: "p"}, state) do
snapshot = Projects.shell_snapshot()
selected = Enum.find_index(snapshot.projects, & &1.is_active) || 0
@@ -258,6 +269,86 @@ defmodule BDS.TUI do
defp apply_report(state, _report), do: state
# ── Events: sidebar search (vi-style "/") ─────────────────────────────────
# The prompt lives in the status line and filters the current view live
# on every keystroke. Esc clears the view's filter, enter keeps it (the
# active filter stays visible in the sidebar title).
defp search_key(%{code: "esc"}, state) do
state = %{state | search: nil, filters_by_view: Map.delete(state.filters_by_view, state.view)}
{:noreply, load_sidebar(state)}
end
defp search_key(%{code: "enter"}, state), do: {:noreply, %{state | search: nil}}
defp search_key(%{code: "backspace"}, %{search: search} = state),
do: {:noreply, apply_search(state, String.slice(search.input, 0..-2//1))}
defp search_key(%{code: code, modifiers: []}, %{search: search} = state)
when byte_size(code) >= 1 do
if String.length(code) == 1 do
{:noreply, apply_search(state, search.input <> code)}
else
{:noreply, state}
end
end
defp search_key(_key, state), do: {:noreply, state}
defp apply_search(state, input) do
filters_by_view =
case String.trim(input) do
"" -> Map.delete(state.filters_by_view, state.view)
_some -> Map.put(state.filters_by_view, state.view, input)
end
load_sidebar(%{
state
| search: %{input: input},
filters_by_view: filters_by_view,
selected: 0
})
end
# "/" query syntax: plain words are a text search, "tag:x" and
# "category:x" filter exactly like the GUI filter chips, and an ISO date
# (yyyy-mm-dd) narrows to that day. Tokens combine with AND.
defp parse_search_query(query) do
initial = %{words: [], tags: [], categories: [], date: nil}
parsed =
query
|> String.split()
|> Enum.reduce(initial, fn token, acc ->
cond do
String.starts_with?(token, "tag:") ->
add_filter_token(acc, :tags, String.replace_prefix(token, "tag:", ""))
String.starts_with?(token, "category:") ->
add_filter_token(acc, :categories, String.replace_prefix(token, "category:", ""))
match?({:ok, _date}, Date.from_iso8601(token)) ->
{:ok, date} = Date.from_iso8601(token)
%{acc | date: date}
true ->
%{acc | words: acc.words ++ [token]}
end
end)
%{
search: if(parsed.words == [], do: nil, else: Enum.join(parsed.words, " ")),
tags: parsed.tags,
categories: parsed.categories,
year: parsed.date && parsed.date.year,
month: parsed.date && parsed.date.month,
day: parsed.date && parsed.date.day
}
end
defp add_filter_token(acc, _key, ""), do: acc
defp add_filter_token(acc, key, value), do: Map.update!(acc, key, &(&1 ++ [value]))
# ── Events: projects overlay (switch / open existing) ────────────────────
defp projects_key(%{code: "esc"}, %{projects: %{mode: :open} = projects} = state),
@@ -685,11 +776,18 @@ defmodule BDS.TUI do
do: %Style{fg: :black, bg: :cyan},
else: %Style{modifiers: [:bold]}
),
block: %Block{title: view_label(state.view), borders: [:all]}
block: %Block{title: sidebar_title(state), borders: [:all]}
}, rect}
]
end
defp sidebar_title(state) do
case Map.get(state.filters_by_view, state.view) do
nil -> view_label(state.view)
query -> view_label(state.view) <> " /" <> query
end
end
# The List widget requires selected: nil when the collection is empty
# and raises otherwise — clamp every dynamic list through this.
defp list_selected([], _selected), do: nil
@@ -761,6 +859,7 @@ defmodule BDS.TUI do
defp status_widgets(state, rect) do
text =
cond do
state.search -> "/" <> state.search.input
state.busy -> dgettext("ui", "Working…")
state.status -> state.status
true -> default_status(state)
@@ -1021,7 +1120,7 @@ defmodule BDS.TUI do
do:
dgettext(
"ui",
"enter open · n new post · 1-5 views · p projects · : commands (:? help) · r refresh · ctrl+q quit"
"enter open · n new post · 1-5 views · p projects · / search · : commands (:? help) · r refresh · ctrl+q quit"
)
defp default_status(%{focus: :editor}),
@@ -1034,7 +1133,7 @@ defmodule BDS.TUI do
# ── Sidebar data ─────────────────────────────────────────────────────────
defp load_sidebar(state) do
view = Sidebar.view(state.project_id, state.view)
view = Sidebar.view(state.project_id, state.view, search_filter_params(state))
items = flatten_items(view, state.view)
selected =
@@ -1046,6 +1145,13 @@ defmodule BDS.TUI do
%{state | sidebar: view, items: items, selected: selected}
end
defp search_filter_params(state) do
case Map.get(state.filters_by_view, state.view) do
nil -> %{}
query -> parse_search_query(query)
end
end
defp flatten_items(view, route) do
sections =
cond do

View File

@@ -363,6 +363,7 @@ defmodule BDS.UI.Sidebar do
|> maybe_where_search(filters.search)
|> maybe_where_year(filters.year)
|> maybe_where_month(filters.month)
|> maybe_where_day(filters.day)
|> maybe_where_all_tags(filters.tags)
|> maybe_where_all_categories(filters.categories)
end
@@ -424,6 +425,25 @@ defmodule BDS.UI.Sidebar do
)
end
# Day-of-month filter used by the TUI's ISO-date search (yyyy-mm-dd);
# always combined with year and month by the caller.
defp maybe_where_day(query, nil), do: query
defp maybe_where_day(query, day) do
day_str = String.pad_leading(to_string(day), 2, "0")
where(
query,
[p],
fragment(
"strftime('%d', datetime(COALESCE(?, ?) / 1000, 'unixepoch')) = ?",
p.published_at,
p.updated_at,
^day_str
)
)
end
defp maybe_where_all_tags(query, []), do: query
defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
@@ -609,6 +629,7 @@ defmodule BDS.UI.Sidebar do
|> maybe_where_media_search(filters.search)
|> maybe_where_media_year(filters.year)
|> maybe_where_media_month(filters.month)
|> maybe_where_media_day(filters.day)
|> maybe_where_all_media_tags(filters.tags)
end
@@ -667,6 +688,22 @@ defmodule BDS.UI.Sidebar do
)
end
defp maybe_where_media_day(query, nil), do: query
defp maybe_where_media_day(query, day) do
day_str = String.pad_leading(to_string(day), 2, "0")
where(
query,
[m],
fragment(
"strftime('%d', datetime(? / 1000, 'unixepoch')) = ?",
m.updated_at,
^day_str
)
)
end
defp maybe_where_all_media_tags(query, []), do: query
defp maybe_where_all_media_tags(query, tags),
@@ -1028,6 +1065,7 @@ defmodule BDS.UI.Sidebar do
search: normalize_string(BDS.MapUtils.attr(params, :search)),
year: normalize_integer(BDS.MapUtils.attr(params, :year)),
month: normalize_integer(BDS.MapUtils.attr(params, :month)),
day: normalize_integer(BDS.MapUtils.attr(params, :day)),
tags: normalize_string_list(BDS.MapUtils.attr(params, :tags)),
categories: normalize_string_list(BDS.MapUtils.attr(params, :categories)),
display_limit:
@@ -1045,6 +1083,7 @@ defmodule BDS.UI.Sidebar do
search: nil,
year: nil,
month: nil,
day: nil,
tags: [],
categories: [],
display_limit: @default_page_size
@@ -1052,8 +1091,8 @@ defmodule BDS.UI.Sidebar do
end
defp filter_active?(filters) do
present?(filters.search) or not is_nil(filters.year) or filters.tags != [] or
filters.categories != []
present?(filters.search) or not is_nil(filters.year) or not is_nil(filters.day) or
filters.tags != [] or filters.categories != []
end
defp post_search_blob(post) do