feat: TUI projects overlay to switch projects and open existing blog folders with automatic database rebuild (issue #28)

This commit is contained in:
2026-07-16 17:26:19 +02:00
parent a8ca3b643b
commit 5801e49dc1
9 changed files with 921 additions and 454 deletions

View File

@@ -11,6 +11,8 @@ defmodule BDS.TUI do
Sidebar focus: `↑/↓`/`j/k` navigate, `enter` open, `n` new post,
`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 — opening one queues a full database rebuild),
`:` 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,
@@ -55,6 +57,7 @@ defmodule BDS.TUI do
Keyword.get(opts, :command_executor, &BDS.Desktop.ShellCommands.execute/2),
task_snapshot_fun: Keyword.get(opts, :task_snapshot_fun, &BDS.Tasks.status_snapshot/0),
command: nil,
projects: nil,
report: nil,
handled_task_ids: nil,
task_polling: false,
@@ -110,6 +113,10 @@ defmodule BDS.TUI do
when command != nil,
do: command_key(key, state)
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{projects: projects} = state)
when projects != nil,
do: projects_key(key, state)
def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state)
when image != nil,
do: {:noreply, %{state | image: nil}}
@@ -140,6 +147,14 @@ defmodule BDS.TUI do
defp sidebar_key(%{code: ":"}, state),
do: {:noreply, %{state | command: %{input: "", selected: 0, help?: false}}}
defp sidebar_key(%{code: "p"}, state) do
snapshot = Projects.shell_snapshot()
selected = Enum.find_index(snapshot.projects, & &1.is_active) || 0
{:noreply,
%{state | projects: %{mode: :list, projects: snapshot.projects, selected: selected, input: ""}}}
end
defp sidebar_key(%{code: "n"}, %{project_id: project_id} = state)
when is_binary(project_id) do
case Posts.create_post(%{project_id: project_id, title: "", content: ""}) do
@@ -233,6 +248,103 @@ defmodule BDS.TUI do
defp apply_report(state, _report), do: state
# ── Events: projects overlay (switch / open existing) ────────────────────
defp projects_key(%{code: "esc"}, %{projects: %{mode: :open} = projects} = state),
do: {:noreply, %{state | projects: %{projects | mode: :list, input: ""}}}
defp projects_key(%{code: "esc"}, state), do: {:noreply, %{state | projects: nil}}
defp projects_key(%{code: "enter"}, %{projects: %{mode: :open, input: input}} = state),
do: {:noreply, open_existing_project(state, input)}
defp projects_key(%{code: "backspace"}, %{projects: %{mode: :open} = projects} = state),
do: {:noreply, %{state | projects: %{projects | input: String.slice(projects.input, 0..-2//1)}}}
defp projects_key(%{code: code, modifiers: []}, %{projects: %{mode: :open} = projects} = state)
when byte_size(code) >= 1 do
if String.length(code) == 1 do
{:noreply, %{state | projects: %{projects | input: projects.input <> code}}}
else
{:noreply, state}
end
end
defp projects_key(%{code: "o"}, %{projects: projects} = state),
do: {:noreply, %{state | projects: %{projects | mode: :open}}}
defp projects_key(%{code: code}, %{projects: projects} = state) when code in ["down", "j"] do
count = length(projects.projects)
{:noreply, put_in(state.projects.selected, min(projects.selected + 1, max(count - 1, 0)))}
end
defp projects_key(%{code: code}, %{projects: projects} = state) when code in ["up", "k"],
do: {:noreply, put_in(state.projects.selected, max(projects.selected - 1, 0))}
defp projects_key(%{code: "enter"}, %{projects: projects} = state) do
case Enum.at(projects.projects, projects.selected) do
nil -> {:noreply, %{state | projects: nil}}
summary -> {:noreply, switch_project(%{state | projects: nil}, summary.id)}
end
end
defp projects_key(_key, state), do: {:noreply, state}
defp switch_project(%{project_id: project_id} = state, project_id), do: state
defp switch_project(state, project_id) do
case Projects.set_active_project(project_id) do
{:ok, project} ->
%{
state
| project_id: project.id,
view: "posts",
selected: 0,
focus: :sidebar,
editor: nil,
image: nil
}
|> load_sidebar()
|> toast(
dgettext("ui", "Projects"),
dgettext("ui", "Switched to %{name}.", name: project.name)
)
{:error, reason} ->
toast(state, dgettext("ui", "Projects"), inspect(reason))
end
end
defp open_existing_project(state, input) do
path = input |> String.trim() |> Path.expand()
if File.dir?(path) do
name =
case Path.basename(path) do
"" -> dgettext("ui", "Imported Blog")
value -> value
end
case Projects.create_project(%{name: name, data_path: path}) do
{:ok, project} ->
# The folder's content only exists on disk at this point: activate
# the project, then queue the full database rebuild automatically.
%{state | projects: nil}
|> switch_project(project.id)
|> execute_action(dgettext("ui", "Rebuild Database"), "rebuild_database", %{})
{:error, reason} ->
toast(%{state | projects: nil}, dgettext("ui", "Open Existing Blog"), inspect(reason))
end
else
toast(
state,
dgettext("ui", "Open Existing Blog"),
dgettext("ui", "Not a folder: %{path}", path: path)
)
end
end
# ── Events: command prompt (vi-style) ────────────────────────────────────
defp command_key(%{code: "esc"}, state), do: {:noreply, %{state | command: nil}}
@@ -462,7 +574,8 @@ defmodule BDS.TUI do
status_widgets(state, status_rect) ++
image_widgets(state, body_rect) ++
report_widgets(state, body_rect) ++
command_widgets(state, body_rect)
command_widgets(state, body_rect) ++
projects_widgets(state, body_rect)
end
defp view_tabs(state, rect) do
@@ -633,6 +746,57 @@ defmodule BDS.TUI do
]
end
defp projects_widgets(%{projects: nil}, _rect), do: []
defp projects_widgets(%{projects: %{mode: :open} = projects}, rect) do
overlay = %Rect{
x: rect.x + 4,
y: rect.y + 2,
width: max(rect.width - 8, 20),
height: 3
}
[
{%Clear{}, overlay},
{%Paragraph{
text: projects.input,
block: %Block{
title: dgettext("ui", "Open Existing Blog — type a folder path · enter open · esc back"),
borders: [:all]
}
}, overlay}
]
end
defp projects_widgets(%{projects: projects}, rect) do
overlay = %Rect{
x: rect.x + 4,
y: rect.y + 1,
width: max(rect.width - 8, 20),
height: max(rect.height - 2, 6)
}
items =
Enum.map(projects.projects, fn summary ->
marker = if summary.is_active, do: "", else: " "
path = summary.data_path || ""
marker <> summary.name <> if(path == "", do: "", else: "" <> path)
end)
[
{%Clear{}, overlay},
{%List{
items: items,
selected: list_selected(items, projects.selected),
highlight_style: %Style{fg: :black, bg: :cyan},
block: %Block{
title: dgettext("ui", "Projects — enter switch · o open existing · esc close"),
borders: [:all]
}
}, overlay}
]
end
defp report_widgets(%{report: nil}, _rect), do: []
defp report_widgets(%{report: report}, rect) do
@@ -745,7 +909,7 @@ defmodule BDS.TUI do
do:
dgettext(
"ui",
"enter open · n new post · 1-5 views · : commands (:? help) · r refresh · ctrl+q quit"
"enter open · n new post · 1-5 views · p projects · : commands (:? help) · r refresh · ctrl+q quit"
)
defp default_status(%{focus: :editor}),