defmodule BDS.TUI do @moduledoc """ Terminal UI shell (issue #26, phase 4). An `ExRatatui.App` over the shared UI core: `BDS.UI.Sidebar` for view data, `BDS.UI.PostEditor.*` for the draft/save/publish workflow, and `BDS.Events` for multi-client synchronization. Served per SSH connection by `ExRatatui.SSH.Daemon` in server mode and runnable locally. ## Keys Sidebar focus: `↑/↓`/`j/k` navigate, `enter` open, `n` new post, `1..5` switch view (posts/media/templates/scripts/tags), `6` settings (the GUI preferences sections as sidebar entries; enter opens a section's form — enter edits a text field in a status-line prompt, toggles a boolean or cycles an enum, `ctrl+s` saves through the same backends as the GUI, esc closes), `7` git panel (changed files plus commit history in the sidebar, scrollable whole-folder diff in the main area; `c` commit all, `u` pull, `s` push, enter jumps the diff to the selected file), `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), `/` 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). Posts open in the rendered markdown preview (new empty posts open in the editor). Editor focus: `ctrl+e` toggles preview/editor, text keys edit, `ctrl+s` save, `ctrl+p` publish, `ctrl+t` edit title, `ctrl+l` cycle language, `ctrl+g` AI suggestions, `esc` back to sidebar. `ctrl+q` quits, `esc` also closes a media preview. The UI locale is the server-side UI language setting. """ use ExRatatui.App use Gettext, backend: BDS.Gettext alias BDS.AI alias BDS.Desktop.UILocale alias BDS.Events alias BDS.Git alias BDS.Posts alias BDS.Projects alias BDS.UI.PostEditor.{Draft, Metadata, Persistence} alias BDS.UI.SettingsForm alias BDS.UI.Sidebar alias ExRatatui.Layout alias ExRatatui.Layout.Rect alias ExRatatui.Style alias ExRatatui.Widgets.{Block, Clear, List, Markdown, Paragraph, Tabs, Textarea} @views ~w(posts media templates scripts tags settings git) @git_diff_scroll_step 10 @git_max_diff_lines 5000 # ── Mount ──────────────────────────────────────────────────────────────── @impl true def mount(opts) do UILocale.put(BDS.Desktop.ShellData.ui_language()) :ok = Events.subscribe() project_id = Projects.shell_snapshot().active_project_id state = %{ # Set only for the local BDS_MODE=tui child: quitting the TUI then # shuts the VM down instead of leaving a headless server behind. stop_vm_on_exit: Keyword.get(opts, :stop_vm_on_exit, false), stop_fun: Keyword.get(opts, :stop_fun, &System.stop/0), command_executor: 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, search: nil, filters_by_view: %{}, git: nil, git_commit: nil, settings_form: nil, report: nil, handled_task_ids: nil, task_polling: false, project_id: project_id, view: "posts", sidebar: nil, items: [], selected: 0, focus: :sidebar, editor: nil, preview: nil, image: nil, busy: false, status: nil } # Tasks completed before this session started must never pop their # report panels here — another client already consumed them. initial_snapshot = Keyword.get_lazy(opts, :initial_task_snapshot, state.task_snapshot_fun) state = %{state | handled_task_ids: completed_task_ids(initial_snapshot)} {:ok, load_sidebar(state)} end defp completed_task_ids(snapshot) do snapshot |> Map.get(:tasks, []) |> Enum.filter(&(&1.status == :completed)) |> MapSet.new(& &1.id) end @impl true def terminate(_reason, %{stop_vm_on_exit: true, stop_fun: stop_fun}) do stop_fun.() :ok end def terminate(_reason, _state), do: :ok # ── Events: global keys ────────────────────────────────────────────────── @impl true def handle_event(%ExRatatui.Event.Key{code: "q", modifiers: ["ctrl"]}, state), do: {:stop, state} def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{report: report} = state) when report != nil, do: report_key(key, state) def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{command: command} = state) 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{kind: "press"} = key, %{git_commit: git_commit} = state) when git_commit != nil, do: git_commit_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{kind: "press"} = key, %{settings_form: form} = state) when form != nil, do: settings_form_key(key, state) def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state) when image != nil, do: {:noreply, %{state | image: nil}} def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{focus: :sidebar} = state), do: sidebar_key(key, state) def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{focus: :editor} = state), do: editor_key(key, state) def handle_event(_event, state), do: {:noreply, state} # ── Events: sidebar focus ──────────────────────────────────────────────── defp sidebar_key(%{code: code}, state) when code in ["down", "j"], do: {:noreply, move_selection(state, 1)} defp sidebar_key(%{code: code}, state) when code in ["up", "k"], do: {:noreply, move_selection(state, -1)} # Settings is panel "6" (issue #29) and git panel "7" (issue #30), # matching the GUI panel numbering. defp sidebar_key(%{code: code}, state) when code in ~w(1 2 3 4 5 6 7) do view = Enum.at(@views, String.to_integer(code) - 1) {:noreply, load_sidebar(%{state | view: view, selected: 0, image: nil})} end defp sidebar_key(%{code: "c"}, %{view: "git"} = state) do if state.git do {:noreply, %{state | git_commit: %{input: ""}}} else {:noreply, not_a_repo_toast(state)} end end defp sidebar_key(%{code: "u"}, %{view: "git"} = state), do: run_git_async(state, :pull) defp sidebar_key(%{code: "s"}, %{view: "git"} = state), do: run_git_async(state, :push) defp sidebar_key(%{code: "pagedown"}, %{view: "git", git: git} = state) when git != nil do max_scroll = max(length(git.lines) - 1, 0) {:noreply, put_in(state.git.scroll, min(git.scroll + @git_diff_scroll_step, max_scroll))} end defp sidebar_key(%{code: "pageup"}, %{view: "git", git: git} = state) when git != nil, do: {:noreply, put_in(state.git.scroll, max(git.scroll - @git_diff_scroll_step, 0))} defp sidebar_key(%{code: "r"}, state), do: {:noreply, load_sidebar(state)} 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 {:noreply, %{ state | projects: %{ mode: :list, projects: snapshot.projects, selected: selected, input: "", matches: [] } }} 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 {:ok, post} -> # A fresh, empty post starts in the editor — there is nothing to # preview yet; existing posts open in the preview instead. {:noreply, state |> load_sidebar() |> open_post(post.id, false)} {:error, reason} -> {:noreply, toast(state, dgettext("ui", "New Post"), inspect(reason))} end end defp sidebar_key(%{code: "enter"}, state) do case selected_item(state) do %{route: "post", id: post_id} -> {:noreply, open_post(state, post_id)} %{route: "media", id: media_id} -> {:noreply, open_media(state, media_id)} %{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")} %{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 end defp sidebar_key(%{code: "tab"}, %{editor: editor} = state) when editor != nil, do: {:noreply, %{state | focus: :editor}} defp sidebar_key(_key, state), do: {:noreply, state} # ── Events: report panels (metadata diff / site validation) ────────────── defp report_key(%{code: "esc"}, state), do: {:noreply, %{state | report: nil}} defp report_key(%{code: "enter"}, %{report: report} = state), do: {:noreply, apply_report(%{state | report: nil}, report)} defp report_key(%{code: code}, %{report: report} = state) when code in ["down", "j"] do max_scroll = max(length(report_lines(report)) - 1, 0) {:noreply, put_in(state.report.scroll, min(report.scroll + 1, max_scroll))} end defp report_key(%{code: code}, %{report: report} = state) when code in ["up", "k"], do: {:noreply, put_in(state.report.scroll, max(report.scroll - 1, 0))} defp report_key(_key, state), do: {:noreply, state} # Whole-report application, mirroring the GUI defaults: metadata diffs are # repaired from the filesystem (file → db, all items, orphans imported); # site validation applies the full report incrementally. defp apply_report(state, %{route: "metadata_diff", payload: payload}) do items = BDS.MapUtils.attr(payload, :diff_reports, []) orphans = BDS.MapUtils.attr(payload, :orphan_reports, []) if items == [] and orphans == [] do toast(state, dgettext("ui", "Metadata Diff"), dgettext("ui", "Nothing to repair.")) else state = if items == [] do state else execute_action(state, dgettext("ui", "Metadata Diff"), "repair_metadata_diff", %{ items: items, direction: "file_to_db" }) end if orphans == [] do state else execute_action( state, dgettext("ui", "Metadata Diff"), "import_metadata_diff_orphans", %{orphans: orphans} ) end end end defp apply_report(state, %{route: "site_validation", payload: payload}) do nothing_to_apply? = BDS.MapUtils.attr(payload, :missing_url_paths, []) == [] and BDS.MapUtils.attr(payload, :extra_url_paths, []) == [] and BDS.MapUtils.attr(payload, :updated_post_url_paths, []) == [] and not BDS.MapUtils.attr(payload, :sitemap_changed, false) if nothing_to_apply? do toast(state, dgettext("ui", "Site Validation"), dgettext("ui", "Nothing to apply.")) else execute_action(state, dgettext("ui", "Site Validation"), "apply_site_validation", %{ report: payload }) end end defp apply_report(state, _report), do: state # ── Events: git panel (issue #30) ───────────────────────────────────────── # Commit prompt in the status line, mirroring the search prompt: enter # commits the whole working tree (git add -A + commit), esc cancels. defp git_commit_key(%{code: "esc"}, state), do: {:noreply, %{state | git_commit: nil}} defp git_commit_key(%{code: "enter"}, %{git_commit: %{input: input}} = state) do message = String.trim(input) state = %{state | git_commit: nil} cond do message == "" -> {:noreply, toast(state, dgettext("ui", "Commit"), dgettext("ui", "Commit message required."))} true -> case Git.commit_all(state.project_id, message) do {:ok, _result} -> {:noreply, state |> load_sidebar() |> toast(dgettext("ui", "Commit"), dgettext("ui", "Committed."))} {:error, reason} -> {:noreply, toast(state, dgettext("ui", "Commit"), format_git_error(reason))} end end end defp git_commit_key(%{code: "backspace"}, %{git_commit: git_commit} = state) do {:noreply, %{state | git_commit: %{git_commit | input: String.slice(git_commit.input, 0..-2//1)}}} end defp git_commit_key(%{code: code, modifiers: []}, %{git_commit: git_commit} = state) when byte_size(code) >= 1 do if String.length(code) == 1 do {:noreply, %{state | git_commit: %{git_commit | input: git_commit.input <> code}}} else {:noreply, state} end end defp git_commit_key(_key, state), do: {:noreply, state} # Pull and push hit the network, so they run off the UI process and # report back via {:git_result, op, result}. defp run_git_async(%{git: nil} = state, _operation), do: {:noreply, not_a_repo_toast(state)} defp run_git_async(state, operation) do parent = self() project_id = state.project_id {:ok, _pid} = Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> result = case operation do :pull -> Git.pull(project_id) :push -> Git.push(project_id) end send(parent, {:git_result, operation, result}) end) {:noreply, %{state | busy: true}} end defp not_a_repo_toast(state), do: toast(state, view_label("git"), dgettext("ui", "Not a git repository.")) defp jump_to_file_diff(%{git: nil} = state, _path), do: state defp jump_to_file_diff(%{git: git} = state, path) do index = Enum.find_index(git.lines, fn line -> String.starts_with?(line, "diff --git") and String.contains?(line, " b/" <> path) end) case index do nil -> toast(state, view_label("git"), dgettext("ui", "No diff for this file.")) scroll -> put_in(state.git.scroll, scroll) end end defp format_git_error({:git_failed, output}), do: output defp format_git_error(reason), do: inspect(reason) # ── Events: settings form (issue #29) ───────────────────────────────────── # A settings sidebar entry opens its section as a generic typed-field # form in the main area, backed by BDS.UI.SettingsForm — the same # preferences the GUI settings editor operates on. defp open_settings_form(state, section) do form = SettingsForm.load(section, state.project_id) %{ state | settings_form: %{ section: form.section, title: form.title, fields: form.fields, selected: seek_field(form.fields, 0, 1), input: nil, dirty: false }, image: nil } end # Text fields edit in a status-line prompt (like the commit message): # enter commits the value into the field, esc cancels just the prompt. defp settings_form_key(%{code: "esc"}, %{settings_form: %{input: input}} = state) when input != nil, do: {:noreply, put_in(state.settings_form.input, nil)} defp settings_form_key(%{code: "enter"}, %{settings_form: %{input: input} = form} = state) when input != nil do fields = Enum.map(form.fields, fn field -> if field.key == input.key, do: %{field | value: input.value}, else: field end) {:noreply, %{state | settings_form: %{form | fields: fields, input: nil, dirty: true}}} end defp settings_form_key(%{code: "backspace"}, %{settings_form: %{input: input}} = state) when input != nil do {:noreply, put_in(state.settings_form.input.value, String.slice(input.value, 0..-2//1))} end defp settings_form_key(%{code: code, modifiers: []}, %{settings_form: %{input: input}} = state) when input != nil and byte_size(code) >= 1 do if String.length(code) == 1 do {:noreply, put_in(state.settings_form.input.value, input.value <> code)} else {:noreply, state} end end defp settings_form_key(_key, %{settings_form: %{input: input}} = state) when input != nil, do: {:noreply, state} defp settings_form_key(%{code: "esc"}, state), do: {:noreply, %{state | settings_form: nil}} defp settings_form_key(%{code: "s", modifiers: ["ctrl"]}, state), do: save_settings_form(state) defp settings_form_key(%{code: code}, state) when code in ["down", "j"], do: {:noreply, move_field_selection(state, 1)} defp settings_form_key(%{code: code}, state) when code in ["up", "k"], do: {:noreply, move_field_selection(state, -1)} defp settings_form_key(%{code: "enter"}, %{settings_form: form} = state) do case Enum.at(form.fields, form.selected) do %{type: :text} = field -> {:noreply, put_in(state.settings_form.input, %{key: field.key, label: field.label, value: field.value})} %{type: :bool} = field -> {:noreply, update_field(state, field.key, not field.value)} %{type: :enum, options: options} = field when options != [] -> index = Enum.find_index(options, &(&1 == field.value)) || 0 {:noreply, update_field(state, field.key, Enum.at(options, rem(index + 1, length(options))))} %{type: :action} = field -> message = case SettingsForm.run_action(field.key) do {:ok, message} -> message {:error, message} -> message end {:noreply, toast(state, field.label, message)} _other -> {:noreply, state} end end defp settings_form_key(_key, state), do: {:noreply, state} defp update_field(%{settings_form: form} = state, key, value) do fields = Enum.map(form.fields, fn field -> if field.key == key, do: %{field | value: value}, else: field end) %{state | settings_form: %{form | fields: fields, dirty: true}} end defp save_settings_form(%{settings_form: form} = state) do values = Map.new(form.fields, fn field -> {field.key, field.value} end) case SettingsForm.save(form.section, state.project_id, values) do :ok -> reloaded = SettingsForm.load(form.section, state.project_id) state = %{ state | settings_form: %{ form | fields: reloaded.fields, selected: min(form.selected, max(length(reloaded.fields) - 1, 0)), input: nil, dirty: false } } {:noreply, toast(state, form.title, dgettext("ui", "Saved."))} {:error, reason} -> {:noreply, toast(state, form.title, settings_error(reason))} end end defp settings_error(reason) when is_binary(reason), do: reason defp settings_error(reason), do: inspect(reason) # Selection skips read-only :info rows in the given direction. defp move_field_selection(%{settings_form: form} = state, delta) do count = length(form.fields) if count == 0 do state else next = seek_field(form.fields, clamp(form.selected + delta, count), delta) put_in(state.settings_form.selected, next) end end defp seek_field(fields, index, delta) do case Enum.at(fields, index) do %{type: :info} -> next = clamp(index + sign(delta), length(fields)) if next == index, do: index, else: seek_field(fields, next, delta) _other -> index end end # ── 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), do: {:noreply, %{state | projects: %{projects | mode: :list, input: "", matches: []}}} 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: "tab"}, %{projects: %{mode: :open} = projects} = state) do {input, matches} = complete_path(projects.input) {:noreply, %{state | projects: %{projects | input: input, matches: matches}}} end defp projects_key(%{code: "backspace"}, %{projects: %{mode: :open} = projects} = state) do {:noreply, %{ state | projects: %{projects | input: String.slice(projects.input, 0..-2//1), matches: []} }} end 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, matches: []}}} 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 # Bash-style directory completion for the path prompt: a unique match # completes fully (plus trailing slash), an ambiguous one completes to # the longest common prefix and returns the candidates for display. # Only directories are offered, and dotfolders only when the typed # prefix itself starts with a dot. defp complete_path(""), do: {"", []} defp complete_path(input) do input = expand_tilde(input) {base, prefix} = split_at_last_slash(input) scan_dir = if base == "", do: ".", else: base case completion_candidates(scan_dir, prefix) do [] -> {input, []} [single] -> {base <> single <> "/", []} many -> {base <> longest_common_prefix(many), many} end end defp expand_tilde("~" <> rest), do: System.user_home!() <> rest defp expand_tilde(input), do: input defp split_at_last_slash(input) do case :binary.matches(input, "/") do [] -> {"", input} offsets -> # List is aliased to the ratatui widget here, so no List.last/1. {position, _length} = Enum.at(offsets, -1) String.split_at(input, position + 1) end end defp completion_candidates(scan_dir, prefix) do case File.ls(scan_dir) do {:ok, entries} -> entries |> Enum.filter(&String.starts_with?(&1, prefix)) |> Enum.reject(&(String.starts_with?(&1, ".") and not String.starts_with?(prefix, "."))) |> Enum.filter(&File.dir?(Path.join(scan_dir, &1))) |> Enum.sort() {:error, _reason} -> [] end end defp longest_common_prefix([first | rest]), do: Enum.reduce(rest, first, &grapheme_common_prefix/2) defp grapheme_common_prefix(a, b) do a |> String.graphemes() |> Enum.zip(String.graphemes(b)) |> Enum.take_while(fn {left, right} -> left == right end) |> Enum.map_join("", fn {grapheme, _} -> grapheme 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}} defp command_key(_key, %{command: %{help?: true}} = state), do: {:noreply, %{state | command: nil}} defp command_key(%{code: "enter"}, %{command: command} = state) do case Enum.at(filtered_commands(command.input), command.selected) do nil -> {:noreply, toast(%{state | command: nil}, ":" <> command.input, dgettext("ui", "Unknown command."))} entry -> {:noreply, run_command(%{state | command: nil}, entry)} end end defp command_key(%{code: "down"}, %{command: command} = state) do count = length(filtered_commands(command.input)) {:noreply, put_in(state.command.selected, min(command.selected + 1, max(count - 1, 0)))} end defp command_key(%{code: "up"}, %{command: command} = state), do: {:noreply, put_in(state.command.selected, max(command.selected - 1, 0))} defp command_key(%{code: "backspace"}, %{command: command} = state) do {:noreply, %{state | command: %{command | input: String.slice(command.input, 0..-2//1), selected: 0}}} end # `:?` — the vi way to reach help from the prompt. defp command_key(%{code: "?"}, %{command: %{input: ""}} = state), do: {:noreply, put_in(state.command.help?, true)} defp command_key(%{code: code, modifiers: []}, %{command: command} = state) when byte_size(code) >= 1 do if String.length(code) == 1 do {:noreply, %{state | command: %{command | input: command.input <> code, selected: 0}}} else {:noreply, state} end end defp command_key(_key, state), do: {:noreply, state} # ── Events: editor focus ───────────────────────────────────────────────── defp editor_key(%{code: "esc"}, %{editor: %{editing_title?: true}} = state), do: {:noreply, put_in(state.editor.editing_title?, false)} defp editor_key(%{code: "esc"}, state), do: {:noreply, %{state | focus: :sidebar}} defp editor_key(%{code: "e", modifiers: ["ctrl"]}, state), do: {:noreply, update_in(state.editor.preview?, &(not &1))} defp editor_key(%{code: "s", modifiers: ["ctrl"]}, state), do: {:noreply, persist(state, :save)} defp editor_key(%{code: "p", modifiers: ["ctrl"]}, state), do: {:noreply, persist(state, :publish)} defp editor_key(%{code: "t", modifiers: ["ctrl"]}, state), do: {:noreply, update_in(state.editor.editing_title?, &(not &1))} defp editor_key(%{code: "l", modifiers: ["ctrl"]}, state), do: {:noreply, cycle_language(state)} defp editor_key(%{code: "g", modifiers: ["ctrl"]}, state), do: {:noreply, request_ai_suggestions(state)} defp editor_key(key, %{editor: %{editing_title?: true}} = state), do: {:noreply, title_key(key, state)} # While previewing, text keys must not edit the (invisible) textarea. defp editor_key(_key, %{editor: %{preview?: true}} = state), do: {:noreply, state} defp editor_key(%{code: code, modifiers: modifiers}, %{editor: editor} = state) do :ok = ExRatatui.textarea_handle_key(editor.textarea, code, modifiers) dirty = editor.dirty or code not in ~w(up down left right home end pageup pagedown) {:noreply, put_in(state.editor.dirty, dirty)} end defp title_key(%{code: "enter"}, state), do: put_in(state.editor.editing_title?, false) defp title_key(%{code: "backspace"}, state), do: state |> update_in([:editor, :title], &String.slice(&1, 0..-2//1)) |> mark_dirty() defp title_key(%{code: code, modifiers: modifiers}, state) when byte_size(code) >= 1 and modifiers == [] do if String.length(code) == 1 do state |> update_in([:editor, :title], &(&1 <> code)) |> mark_dirty() else state end end defp title_key(_key, state), do: state defp mark_dirty(state), do: put_in(state.editor.dirty, true) # ── Info: event bus + AI results ───────────────────────────────────────── @impl true def handle_info({:entity_changed, %{entity: entity, entity_id: id, action: action}}, state) do state = load_sidebar(state) state = if state.editor && state.editor.post.id == id && entity == "post" do if action == :deleted, do: %{state | editor: nil, focus: :sidebar}, else: state else state end {:noreply, state} end def handle_info({:settings_changed, "ui.language"}, state) do UILocale.put(BDS.Desktop.ShellData.ui_language()) {:noreply, load_sidebar(state)} end def handle_info({:ai_suggestions_result, :post, post_id, result}, state) do state = %{state | busy: false} if state.editor && state.editor.post.id == post_id do attrs = %{} |> maybe_put_suggestion(:title, Map.get(result, :title)) |> maybe_put_suggestion(:excerpt, Map.get(result, :excerpt)) case attrs == %{} || Posts.update_post(post_id, attrs) do {:ok, _post} -> {:noreply, state |> reload_editor() |> toast(dgettext("ui", "AI Suggestions"), dgettext("ui", "Suggestions applied."))} true -> {:noreply, toast(state, dgettext("ui", "AI Suggestions"), dgettext("ui", "No suggestions returned."))} {:error, reason} -> {:noreply, toast(state, dgettext("ui", "AI Suggestions"), inspect(reason))} end else {:noreply, state} end end def handle_info({:ai_suggestions_error, :post, _post_id, reason}, state) do {:noreply, toast(%{state | busy: false}, dgettext("ui", "AI Suggestions"), inspect(reason))} end def handle_info({:git_result, operation, result}, state) do title = case operation do :pull -> dgettext("ui", "Pull") :push -> dgettext("ui", "Push") end state = %{state | busy: false} case result do {:ok, %{output: output}} -> {:noreply, state |> load_sidebar() |> toast(title, first_output_line(output))} {:error, reason} -> {:noreply, toast(state, title, format_git_error(reason))} end end def handle_info(:poll_tasks, %{task_polling: true} = state) do snapshot = state.task_snapshot_fun.() state = open_completed_reports(state, snapshot) case snapshot do %{running_task_message: message} when is_binary(message) and message != "" -> Process.send_after(self(), :poll_tasks, 1_000) {:noreply, %{state | status: message}} _idle -> {:noreply, %{state | task_polling: false} |> load_sidebar() |> toast(dgettext("ui", "Tasks"), dgettext("ui", "All tasks finished."))} end end def handle_info(_message, state), do: {:noreply, state} # A finished metadata diff / site validation task carries its report as # the task result; unseen ones open the report panel. defp open_completed_reports(state, snapshot) do completed = Map.get(snapshot, :tasks, []) |> Enum.filter(&(&1.status == :completed)) report_task = Enum.find(completed, fn task -> is_map(task.result) and Map.get(task.result, :kind) == "open_editor" and Map.get(task.result, :route) in ["metadata_diff", "site_validation"] and not MapSet.member?(state.handled_task_ids, task.id) end) state = %{ state | handled_task_ids: MapSet.union(state.handled_task_ids, MapSet.new(completed, & &1.id)) } case report_task do nil -> state task -> %{ state | report: %{ route: task.result.route, payload: Map.get(task.result, :payload, %{}), scroll: 0 } } end end # ── Render ─────────────────────────────────────────────────────────────── @impl true def render(state, frame) do area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height} [tabs_rect, body_rect, status_rect] = Layout.split(area, :vertical, [ {:length, 1}, {:min, 3}, {:length, 1} ]) [sidebar_rect, main_rect] = Layout.split(body_rect, :horizontal, [ {:length, min(40, div(frame.width, 3))}, {:min, 20} ]) view_tabs(state, tabs_rect) ++ sidebar_widgets(state, sidebar_rect) ++ main_widgets(state, main_rect) ++ status_widgets(state, status_rect) ++ image_widgets(state, body_rect) ++ report_widgets(state, body_rect) ++ command_widgets(state, body_rect) ++ projects_widgets(state, body_rect) end defp view_tabs(state, rect) do [ {%Tabs{ titles: Enum.map(@views, &view_label/1), selected: Enum.find_index(@views, &(&1 == state.view)), highlight_style: %Style{fg: :cyan, modifiers: [:bold]} }, rect} ] end # The git sidebar mirrors the GUI: changed files on top, commit history # in the lower half (↑ needs push, ↓ remote-only, blank when synced). defp sidebar_widgets(%{view: "git", sidebar: %{git_state: "active"} = sidebar} = state, rect) do [changes_rect, history_rect] = Layout.split(rect, :vertical, [{:percentage, 50}, {:percentage, 50}]) history_items = Enum.map(sidebar.history_entries, &history_line/1) [ sidebar_list_widget(state, changes_rect), {%List{ items: history_items, selected: nil, block: %Block{title: dgettext("ui", "History"), borders: [:all]} }, history_rect} ] end defp sidebar_widgets(state, rect), do: [sidebar_list_widget(state, rect)] defp sidebar_list_widget(state, rect) do items = Enum.map(state.items, fn {:header, title} -> "── #{title}" {:item, item} -> " #{item_label(item)}" end) focused? = state.focus == :sidebar {%List{ items: items, selected: list_selected(items, state.selected), highlight_style: if(focused?, do: %Style{fg: :black, bg: :cyan}, else: %Style{modifiers: [:bold]} ), block: %Block{title: sidebar_title(state), borders: [:all]} }, rect} end defp history_line(entry) do marker = case entry.sync_status do "local_only" -> "↑ " "remote_only" -> "↓ " _synced -> " " end marker <> entry.short_hash <> " " <> to_string(entry.subject || "") end defp sidebar_title(%{view: "git", sidebar: %{git_state: "active"} = sidebar}) do branch = sidebar[:branch] || "?" view_label("git") <> " [#{branch} ↑#{sidebar[:ahead] || 0} ↓#{sidebar[:behind] || 0}]" 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 defp list_selected(items, selected), do: min(selected, length(items) - 1) defp main_widgets(%{settings_form: form}, rect) when form != nil do items = Enum.map(form.fields, &field_line/1) title = form.title <> if(form.dirty, do: " •", else: "") <> " — " <> dgettext("ui", "enter edit · ctrl+s save · esc close") [ {%List{ items: items, selected: list_selected(items, form.selected), scroll_padding: 2, highlight_style: %Style{fg: :black, bg: :cyan}, block: %Block{title: title, borders: [:all]} }, rect} ] end defp main_widgets(%{view: "git", editor: nil, git: nil}, rect) do [ {%Paragraph{ text: dgettext("ui", "Not a git repository."), block: %Block{title: view_label("git"), borders: [:all]} }, rect} ] end defp main_widgets(%{view: "git", editor: nil, git: git}, rect) do [ {%List{ items: git.lines, selected: list_selected(git.lines, git.scroll), scroll_padding: 2, highlight_style: %Style{modifiers: [:bold]}, block: %Block{title: dgettext("ui", "Diff (pgup/pgdn scroll)"), borders: [:all]} }, rect} ] end defp main_widgets(%{editor: nil} = state, rect) do text = case selected_item(state) do %{route: "media"} = item -> dgettext("ui", "Press enter to preview %{name}.", name: item_label(item)) _other -> dgettext("ui", "Select an entry and press enter to open it.") end [{%Paragraph{text: text, block: %Block{title: "bDS2", borders: [:all]}}, rect}] end defp main_widgets(%{editor: editor} = state, rect) do [title_rect, body_rect] = Layout.split(rect, :vertical, [{:length, 3}, {:min, 3}]) title_block_label = if editor.editing_title?, do: dgettext("ui", "Title (editing — enter to confirm)"), else: dgettext("ui", "Title (ctrl+t to edit)") title_widget = {%Paragraph{ text: editor.title, block: %Block{title: title_block_label, borders: [:all]}, style: if(editor.editing_title?, do: %Style{fg: :yellow}, else: %Style{}) }, title_rect} [title_widget, body_widget(state, editor, body_rect)] end # The editing textarea cannot soft-wrap (upstream ratatui-textarea # limitation), so ctrl+e flips to a word-wrapped read-only Markdown # preview of the current draft. Wrap moves into the editor itself with # the planned MarkdownEditor custom widget. defp body_widget(_state, %{preview?: true} = editor, body_rect) do preview_title = "#{dgettext("ui", "Preview (ctrl+e to edit)")} [#{editor.language}]" {%Markdown{ content: ExRatatui.textarea_get_value(editor.textarea), wrap: true, block: %Block{title: preview_title, borders: [:all]} }, body_rect} end defp body_widget(state, editor, body_rect) do body_title = "#{dgettext("ui", "Content")} [#{editor.language}]" <> if(editor.dirty, do: " •", else: "") {%Textarea{ state: editor.textarea, block: %Block{title: body_title, borders: [:all]}, cursor_style: if(state.focus == :editor and not editor.editing_title?, do: %Style{bg: :cyan}, else: %Style{} ) }, body_rect} end defp status_widgets(state, rect) do settings_input = state.settings_form && state.settings_form.input text = cond do settings_input -> settings_input.label <> ": " <> settings_input.value state.git_commit -> dgettext("ui", "Commit message") <> ": " <> state.git_commit.input state.search -> "/" <> state.search.input state.busy -> dgettext("ui", "Working…") state.status -> state.status true -> default_status(state) end [{%Paragraph{text: text, style: %Style{fg: :dark_gray}}, rect}] end defp image_widgets(%{image: nil}, _rect), do: [] defp image_widgets(%{image: %{widget: widget, title: title}}, rect) do overlay = %Rect{ x: rect.x + 2, y: rect.y + 1, width: max(rect.width - 4, 10), height: max(rect.height - 2, 5) } [inner] = Layout.split(overlay, :vertical, [{:min, 3}]) [ {%Clear{}, overlay}, {%Block{title: title <> " — " <> dgettext("ui", "esc to close"), borders: [:all]}, overlay}, {widget, %Rect{x: inner.x + 1, y: inner.y + 1, width: inner.width - 2, height: inner.height - 2}} ] end defp command_widgets(%{command: nil}, _rect), do: [] defp command_widgets(%{command: command}, rect) do overlay = %Rect{ x: rect.x + 4, y: rect.y + 1, width: max(rect.width - 8, 20), height: max(rect.height - 2, 6) } gui_marker = dgettext("ui", "[report/apply in GUI]") items = Enum.map(filtered_commands(command.input), fn entry -> marker = if entry.gui, do: " " <> gui_marker, else: "" "#{entry.action} — #{entry.label}#{marker}" end) title = if command.help? do dgettext("ui", "Commands — esc to close") else ":" <> command.input end [ {%Clear{}, overlay}, {%List{ items: items, selected: if(command.help?, do: nil, else: list_selected(items, command.selected)), highlight_style: %Style{fg: :black, bg: :cyan}, block: %Block{title: title, borders: [:all]} }, overlay} ] 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 } input_widgets = [ {%Clear{}, overlay}, {%Paragraph{ text: projects.input, block: %Block{ title: dgettext( "ui", "Open Existing Blog — type a folder path · tab complete · enter open · esc back" ), borders: [:all] } }, overlay} ] input_widgets ++ completion_widgets(projects.matches, rect, 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 completion_widgets([], _rect, _input_overlay), do: [] defp completion_widgets(matches, rect, input_overlay) do below = input_overlay.y + input_overlay.height available = rect.y + rect.height - below if available < 3 do [] else overlay = %Rect{ x: input_overlay.x, y: below, width: input_overlay.width, height: min(length(matches) + 2, available) } [ {%Clear{}, overlay}, {%List{ items: matches, selected: nil, block: %Block{title: dgettext("ui", "Matching folders"), borders: [:all]} }, overlay} ] end end defp report_widgets(%{report: nil}, _rect), do: [] defp report_widgets(%{report: report}, rect) do overlay = %Rect{ x: rect.x + 2, y: rect.y + 1, width: max(rect.width - 4, 30), height: max(rect.height - 2, 8) } [ {%Clear{}, overlay}, {%List{ items: report_lines(report), selected: list_selected(report_lines(report), report.scroll), scroll_padding: 2, highlight_style: %Style{modifiers: [:bold]}, block: %Block{title: report_title(report), borders: [:all]} }, overlay} ] end defp report_title(%{route: "metadata_diff"}) do dgettext("ui", "Metadata Diff") <> " — " <> dgettext("ui", "enter repair all from files · esc close") end defp report_title(%{route: "site_validation"}) do dgettext("ui", "Site Validation") <> " — " <> dgettext("ui", "enter apply changes · esc close") end defp report_title(_report), do: "" defp report_lines(%{route: "metadata_diff", payload: payload}) do diffs = BDS.MapUtils.attr(payload, :diff_reports, []) orphans = BDS.MapUtils.attr(payload, :orphan_reports, []) summary = dgettext("ui", "%{diffs} differences · %{orphans} orphan files", diffs: length(diffs), orphans: length(orphans) ) diff_lines = Enum.flat_map(diffs, fn item -> entity = "#{BDS.MapUtils.attr(item, :entity_type)} #{BDS.MapUtils.attr(item, :entity_id)}" differences = item |> BDS.MapUtils.attr(:differences, []) |> Enum.map(fn diff -> " #{BDS.MapUtils.attr(diff, :name)}: db=#{format_value(BDS.MapUtils.attr(diff, :db_value))} → file=#{format_value(BDS.MapUtils.attr(diff, :file_value))}" end) [" " <> entity | differences] end) orphan_lines = case orphans do [] -> [] _some -> ["", dgettext("ui", "Orphan files (not in database):")] ++ Enum.map(orphans, &(" " <> to_string(BDS.MapUtils.attr(&1, :file_path)))) end [summary, ""] ++ diff_lines ++ orphan_lines end defp report_lines(%{route: "site_validation", payload: payload}) do summary = dgettext("ui", "Expected %{expected} · Existing %{existing}", expected: BDS.MapUtils.attr(payload, :expected_url_count, 0), existing: BDS.MapUtils.attr(payload, :existing_html_url_count, 0) ) sitemap = if BDS.MapUtils.attr(payload, :sitemap_changed, false), do: [dgettext("ui", "Sitemap changed.")], else: [] [summary, ""] ++ sitemap ++ path_section( dgettext("ui", "Missing pages (will be rendered):"), BDS.MapUtils.attr(payload, :missing_url_paths, []) ) ++ path_section( dgettext("ui", "Extra files (will be removed):"), BDS.MapUtils.attr(payload, :extra_url_paths, []) ) ++ path_section( dgettext("ui", "Updated posts (will be re-rendered):"), BDS.MapUtils.attr(payload, :updated_post_url_paths, []) ) end defp report_lines(_report), do: [] defp path_section(_title, []), do: [] defp path_section(title, paths), do: [title | Enum.map(paths, &(" " <> to_string(&1)))] ++ [""] defp format_value(value) when is_binary(value), do: truncate(value) defp format_value(value), do: truncate(inspect(value)) defp truncate(text) when byte_size(text) > 40, do: String.slice(text, 0, 40) <> "…" defp truncate(text), do: text defp default_status(%{settings_form: form}) when form != nil, do: dgettext( "ui", "enter edit/toggle/cycle · ctrl+s save · esc close · ctrl+q quit" ) defp default_status(%{focus: :sidebar, view: "git"}), do: dgettext( "ui", "c commit · u pull · s push · enter jump to file · pgup/pgdn scroll diff · 1-7 views · ctrl+q quit" ) defp default_status(%{focus: :sidebar}), do: dgettext( "ui", "enter open · n new post · 1-7 views · p projects · / search · : commands (:? help) · r refresh · ctrl+q quit" ) defp default_status(%{focus: :editor}), do: dgettext( "ui", "ctrl+s save · ctrl+p publish · ctrl+e preview · ctrl+t title · ctrl+l language · ctrl+g AI · esc back" ) # ── Sidebar data ───────────────────────────────────────────────────────── defp load_sidebar(state) do view = Sidebar.view(state.project_id, state.view, search_filter_params(state)) items = flatten_items(view, state.view) selected = case items do [] -> 0 _some -> seek_item(items, clamp(state.selected, length(items)), 1) end %{state | sidebar: view, items: items, selected: selected, git: load_git_panel(state, view)} end # The whole-folder diff (staged + unstaged) shown in the git panel's main # area, reloaded together with the sidebar; the scroll position survives # refreshes. Capped so a giant working tree cannot flood the terminal. defp load_git_panel(%{view: "git"} = state, %{git_state: "active"}) do lines = case Git.diff(state.project_id) do {:ok, %{staged_diff: staged, unstaged_diff: unstaged}} -> case String.trim(staged <> "\n" <> unstaged) do "" -> [dgettext("ui", "No changes.")] diff -> diff |> String.split("\n") |> Enum.take(@git_max_diff_lines) end _error -> [] end old_scroll = if state.git, do: state.git.scroll, else: 0 %{lines: lines, scroll: min(old_scroll, max(length(lines) - 1, 0))} end defp load_git_panel(_state, _view), do: nil defp first_output_line(output) do output |> String.split("\n", parts: 2) |> hd() |> String.trim() 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 # Git sidebar entries are the changed files from git status; enter jumps # the diff panel to that file. defp flatten_items(%{git_state: "active"} = view, "git") do Enum.map(view.status_files, fn file -> {:item, %{route: "git_file", id: file.path, label: "#{file.code} #{file.path}"}} end) end defp flatten_items(_view, "git"), do: [] defp flatten_items(view, route) do sections = cond do is_list(view[:sections]) -> view[:sections] is_list(view[:items]) -> [%{title: nil, items: view[:items]}] true -> [] end Enum.flat_map(sections, fn section -> headers = if section[:title], do: [{:header, section.title}], else: [] headers ++ Enum.map(section[:items] || [], fn item -> {:item, Map.put_new(item, :route, route_for(item, route))} end) end) end defp route_for(item, route), do: item[:route] || String.trim_trailing(route, "s") defp move_selection(state, delta) do count = length(state.items) if count == 0 do state else next = seek_item(state.items, clamp(state.selected + delta, count), delta) %{state | selected: next} end end # Selection skips section headers in the given direction; parks on the # boundary when only headers remain that way. defp seek_item(items, index, delta) do case Enum.at(items, index) do {:item, _} -> index {:header, _} -> next = clamp(index + sign(delta), length(items)) if next == index, do: index, else: seek_item(items, next, delta) nil -> index end end defp sign(delta) when delta < 0, do: -1 defp sign(_delta), do: 1 defp clamp(value, count), do: value |> max(0) |> min(count - 1) defp selected_item(state) do case Enum.at(state.items, state.selected) do {:item, item} -> item _other -> nil end end defp field_line(%{type: :bool} = field), do: if(field.value, do: "[x] ", else: "[ ] ") <> field.label defp field_line(%{type: :info, value: ""} = field), do: field.label defp field_line(field), do: "#{field.label}: #{field.value}" defp item_label(item) do title = item[:title] || item[:name] || item[:original_name] || item[:label] || item[:id] || "?" status = item[:status] if status, do: "#{title} (#{status})", else: to_string(title) end defp view_label("posts"), do: dgettext("ui", "Posts") defp view_label("media"), do: dgettext("ui", "Media") defp view_label("templates"), do: dgettext("ui", "Templates") defp view_label("scripts"), do: dgettext("ui", "Scripts") defp view_label("tags"), do: dgettext("ui", "Tags") defp view_label("settings"), do: dgettext("ui", "Settings") defp view_label("git"), do: dgettext("ui", "Git") # ── Post editor ────────────────────────────────────────────────────────── # Posts open in the rendered markdown preview by default — the editor is # one ctrl+e away. New (empty) posts pass preview?: false. defp open_post(state, post_id, preview? \\ true) do case Posts.get_post(post_id) do nil -> toast(state, dgettext("ui", "Posts"), dgettext("ui", "Post not found.")) post -> metadata = Metadata.project_metadata(post.project_id) language = Metadata.canonical_language(post, metadata) %{ state | editor: build_editor(post, metadata, language, preview?), focus: :editor, image: nil } end end defp build_editor(post, metadata, language, preview?) do form = Draft.persisted_form(post, metadata, language) textarea = ExRatatui.textarea_new() :ok = ExRatatui.textarea_insert_str(textarea, form["content"] || "") %{ post: post, metadata: metadata, language: language, form: form, title: form["title"] || "", textarea: textarea, editing_title?: false, preview?: preview?, dirty: false } end defp reload_editor(%{editor: nil} = state), do: state defp reload_editor(%{editor: editor} = state) do case Posts.get_post(editor.post.id) do nil -> %{state | editor: nil, focus: :sidebar} post -> %{state | editor: build_editor(post, editor.metadata, editor.language, editor.preview?)} end end defp persist(%{editor: editor} = state, action) do draft = %{ editor.form | "title" => editor.title, "content" => ExRatatui.textarea_get_value(editor.textarea) } case Persistence.persist(editor.post, draft, editor.language, editor.metadata, action) do {:ok, _result} -> message = case action do :publish -> dgettext("ui", "Published.") _save -> dgettext("ui", "Saved.") end state |> reload_editor() |> load_sidebar() |> toast(editor_title(editor), message) {:error, reason} -> toast(state, editor_title(editor), inspect(reason)) end end defp cycle_language(%{editor: editor} = state) do if editor.dirty do toast(state, editor_title(editor), dgettext("ui", "Save before switching languages.")) else languages = Metadata.languages(editor.metadata) index = Enum.find_index(languages, &(&1 == editor.language)) || 0 next = Enum.at(languages, rem(index + 1, length(languages))) %{state | editor: build_editor(editor.post, editor.metadata, next, editor.preview?)} end end defp request_ai_suggestions(%{editor: editor} = state) do if AI.airplane_mode?() and not AI.airplane_endpoint_configured?() do toast( state, dgettext("ui", "AI Suggestions"), dgettext("ui", "AI is unavailable in airplane mode without a local endpoint.") ) else parent = self() post_id = editor.post.id language = editor.metadata.main_language || "en" {:ok, _pid} = Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> case AI.analyze_post(post_id, language: language) do {:ok, result} -> send(parent, {:ai_suggestions_result, :post, post_id, result}) {:error, reason} -> send(parent, {:ai_suggestions_error, :post, post_id, reason}) end end) %{state | busy: true} end end defp maybe_put_suggestion(attrs, _key, nil), do: attrs defp maybe_put_suggestion(attrs, key, value) do case String.trim(to_string(value)) do "" -> attrs trimmed -> Map.put(attrs, key, trimmed) end end defp editor_title(editor), do: Metadata.display_title(editor.title, editor.post.slug, editor.post.id) # ── Shell commands (main-menu functionality) ───────────────────────────── # The parameterless Blog-menu commands. `gui: true` marks commands whose # report/apply follow-up UI exists only in the GUI shell for now — the # command still runs here, but acting on its report needs the desktop # app. Metadata diff and site validation have TUI report panels with # whole-report apply, so they are not marked. defp commands do [ %{action: "metadata_diff", label: dgettext("ui", "Metadata Diff"), gui: false}, %{action: "validate_site", label: dgettext("ui", "Validate Site"), gui: false}, %{action: "force_render_site", label: dgettext("ui", "Force Render Site"), gui: false}, %{action: "generate_sitemap", label: dgettext("ui", "Generate Site"), gui: false}, %{action: "rebuild_database", label: dgettext("ui", "Rebuild Database"), gui: false}, %{action: "reindex_text", label: dgettext("ui", "Reindex Text"), gui: false}, %{ action: "rebuild_embedding_index", label: dgettext("ui", "Rebuild Embedding Index"), gui: false }, %{action: "regenerate_calendar", label: dgettext("ui", "Regenerate Calendar"), gui: false}, %{ action: "validate_translations", label: dgettext("ui", "Validate Translations"), gui: true }, %{ action: "fill_missing_translations", label: dgettext("ui", "Fill Missing Translations"), gui: false }, %{action: "find_duplicates", label: dgettext("ui", "Find Duplicate Posts"), gui: true}, %{action: "upload_site", label: dgettext("ui", "Upload Site"), gui: false}, %{action: "open_in_browser", label: dgettext("ui", "Open in Browser"), gui: false} ] end defp filtered_commands(""), do: commands() defp filtered_commands(input) do needle = String.downcase(input) Enum.filter(commands(), fn entry -> String.contains?(entry.action, needle) or String.contains?(String.downcase(entry.label), needle) end) end defp run_command(state, entry), do: execute_action(state, entry.label, entry.action, %{}) defp execute_action(state, label, action, params) do case state.command_executor.(action, params) do {:ok, %{kind: "task_queued", title: title, message: message}} -> state |> toast(title, message) |> start_task_polling() {:ok, %{kind: "open_url", title: title, url: url}} -> toast(state, title, url) {:ok, %{kind: kind, title: title, message: message}} when kind in ["output", "open_editor"] -> toast(state, title, message) {:ok, _other} -> toast(state, label, dgettext("ui", "Command completed")) {:error, %{message: message}} -> toast(state, label, message) {:error, reason} -> toast(state, label, inspect(reason)) end end defp start_task_polling(%{task_polling: true} = state), do: state defp start_task_polling(state) do Process.send_after(self(), :poll_tasks, 1_000) %{state | task_polling: true} end # ── Media preview ──────────────────────────────────────────────────────── defp open_media(state, media_id) do with %{} = media <- BDS.Media.get_media(media_id), true <- String.starts_with?(to_string(media.mime_type || ""), "image/"), project when project != nil <- Projects.get_project(media.project_id), path = Path.join(Projects.project_data_dir(project), media.file_path), {:ok, bytes} <- File.read(path), {:ok, widget} <- ExRatatui.Image.new(bytes) do %{state | image: %{title: item_media_title(media), widget: widget}} else false -> toast(state, dgettext("ui", "Media"), dgettext("ui", "Only images can be previewed.")) _other -> toast(state, dgettext("ui", "Media"), dgettext("ui", "Could not load this file.")) end end defp item_media_title(media), do: media.title || media.original_name || media.id # ── Status line ────────────────────────────────────────────────────────── defp toast(state, title, message), do: %{state | status: "#{title}: #{message}"} end