Files
bDS2/lib/bds/desktop/shell_live/panel_renderer.ex

404 lines
13 KiB
Elixir

defmodule BDS.Desktop.ShellLive.PanelRenderer do
@moduledoc false
use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.Git
alias BDS.Media
alias BDS.Media.Media, as: MediaRecord
alias BDS.PostLinks
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.UI.TaskGrouping
use Gettext, backend: BDS.Gettext
@doc "Render the active panel tab body."
def render_panel_body(assigns) do
case assigns.workbench.panel.active_tab do
:tasks -> render_task_entries(assigns)
:output -> render_output_entries(assigns)
:post_links -> render_post_links(assigns)
:git_log -> render_git_log(assigns)
other -> render_generic_panel(assigns, other)
end
end
@doc "Render the editor toolbar for the current tab."
def render_editor_toolbar(assigns) do
buttons = editor_toolbar_buttons(assigns.current_tab)
assigns = assign(assigns, :editor_toolbar_buttons, buttons)
~H"""
<%= if Enum.any?(@editor_toolbar_buttons) do %>
<div class="editor-toolbar flex items-center gap-2">
<%= for button <- @editor_toolbar_buttons do %>
<button
class={["editor-toolbar-button inline-flex items-center justify-center", if(button.destructive, do: "is-destructive")]}
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
phx-value-kind={button.kind}
>
<%= button.label %>
</button>
<% end %>
</div>
<% end %>
"""
end
defp render_task_entries(assigns) do
entries = TaskGrouping.build_task_entries(Map.get(assigns.task_status, :tasks, []))
collapsed = Map.get(assigns, :collapsed_task_groups) || MapSet.new()
assigns =
assigns
|> assign(:task_entries, entries)
|> assign(:collapsed_task_groups, collapsed)
~H"""
<%= if Enum.empty?(@task_entries) do %>
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Tasks") %></strong>
<span><%= dgettext("ui", "No background tasks running") %></span>
</div>
<% else %>
<div class="task-list flex flex-col gap-2">
<%= for entry <- @task_entries do %>
<%= case entry do %>
<% {:single, task} -> %>
{render_task_row(assigns, task, false)}
<% {:group, group_id, group_name, tasks} -> %>
{render_task_group(assigns, group_id, group_name, tasks)}
<% end %>
<% end %>
</div>
<% end %>
"""
end
defp render_task_group(assigns, group_id, group_name, tasks) do
summary = TaskGrouping.summarize_task_group(tasks)
expanded = not MapSet.member?(assigns.collapsed_task_groups, group_id)
assigns =
assigns
|> assign(:group_id, group_id)
|> assign(:group_name, group_name)
|> assign(:group_tasks, tasks)
|> assign(:group_expanded, expanded)
|> assign(:group_meta, task_group_meta(summary))
~H"""
<div class="task-group flex flex-col">
<button
class="panel-entry ui-panel-entry task-entry task-group-toggle flex w-full items-center gap-2 text-left"
type="button"
phx-click="toggle_task_group"
phx-value-group={@group_id}
aria-expanded={to_string(@group_expanded)}
>
<span class="task-group-chevron">{if @group_expanded, do: "▾", else: "▸"}</span>
<strong class="task-group-title"><%= @group_name %> (<%= length(@group_tasks) %>)</strong>
<span class="task-group-meta text-muted text-xs"><%= @group_meta %></span>
</button>
<%= if @group_expanded do %>
<%= for task <- @group_tasks do %>
{render_task_row(assigns, task, true)}
<% end %>
<% end %>
</div>
"""
end
defp render_task_row(assigns, task, is_child) do
assigns =
assigns
|> assign(:task, task)
|> assign(:task_child?, is_child)
~H"""
<div class={["panel-entry", "ui-panel-entry", "task-entry", "flex flex-col gap-2", if(@task_child?, do: "ml-4")]}>
<div class="task-entry-header flex items-center justify-between gap-2">
<strong><%= @task.name %></strong>
<span class={"task-status task-status-#{@task.status}"}><%= Map.get(@task, :status_label, @task.status |> to_string() |> String.capitalize()) %></span>
</div>
<span><%= @task.message || @task.group_name || "" %></span>
<%= if is_number(@task.progress) do %>
<div class="task-progress-row">
<progress max="1" value={@task.progress}></progress>
<span><%= Map.get(@task, :progress_label, progress_percent(@task.progress)) %></span>
</div>
<% end %>
<%= if @task.status == :running do %>
<div class="task-entry-actions flex justify-end">
<button
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
data-testid="cancel-task-button"
type="button"
phx-click="cancel_task"
phx-value-id={@task.id}
>
<%= dgettext("ui", "Cancel") %>
</button>
</div>
<% end %>
</div>
"""
end
# "NN% · X Running · Y Pending" — the breakdown only lists non-zero counts,
# like the old app's summarizeTaskGroup rendering.
defp task_group_meta(summary) do
parts =
[]
|> maybe_append_breakdown(summary.running, dgettext("ui", "Running"))
|> maybe_append_breakdown(summary.pending, dgettext("ui", "Pending"))
suffix = if parts == [], do: "", else: " · " <> Enum.join(parts, " · ")
progress_percent(summary.progress) <> suffix
end
defp maybe_append_breakdown(parts, count, _label) when count <= 0, do: parts
defp maybe_append_breakdown(parts, count, label),
do: parts ++ ["#{count} #{label}"]
defp render_output_entries(assigns) do
~H"""
<%= if Enum.empty?(@output_entries) do %>
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state output-list">
<strong><%= dgettext("ui", "Output") %></strong>
<span><%= dgettext("ui", "No shell output yet") %></span>
</div>
<% else %>
<div class="output-toolbar flex shrink-0 justify-end">
<button
class="ui-button ui-button-secondary inline-flex items-center px-2 py-1 text-xs"
data-testid="copy-output-button"
type="button"
phx-click="copy_output"
>
<%= dgettext("ui", "Copy") %>
</button>
</div>
<div class="output-list flex flex-col gap-2">
<%= for entry <- @output_entries do %>
<div class={[
"panel-entry",
"ui-panel-entry",
"output-entry",
if(Map.get(entry, :level) == "error", do: "output-entry-error")
]}>
<strong><%= entry.title %></strong>
<span><%= entry.message %></span>
<%= if present?(entry.details) do %>
<span><%= entry.details %></span>
<% end %>
</div>
<% end %>
</div>
<% end %>
"""
end
defp render_post_links(assigns) do
panel_links = assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
assigns =
assigns
|> assign(:backlinks, Map.get(panel_links, :backlinks, []))
|> assign(:outlinks, Map.get(panel_links, :outlinks, []))
~H"""
<%= if Enum.empty?(@backlinks) and Enum.empty?(@outlinks) do %>
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Post Links") %></strong>
<span><%= dgettext("ui", "No post links yet") %></span>
</div>
<% else %>
<div class="git-log-list flex flex-col gap-2">
<%= if Enum.any?(@backlinks) do %>
<div class="panel-entry ui-panel-entry"><strong><%= dgettext("ui", "Backlinks") %></strong></div>
<%= for entry <- @backlinks do %>
<button
class="panel-entry ui-panel-entry task-entry"
type="button"
phx-click="pin_sidebar_item"
phx-value-route="post"
phx-value-id={entry.id}
phx-value-title={entry.title}
phx-value-subtitle="linked post"
>
<strong><%= entry.title %></strong>
<span><%= entry.text %></span>
</button>
<% end %>
<% end %>
<%= if Enum.any?(@outlinks) do %>
<div class="panel-entry ui-panel-entry"><strong><%= dgettext("ui", "Links To") %></strong></div>
<%= for entry <- @outlinks do %>
<button
class="panel-entry ui-panel-entry task-entry"
type="button"
phx-click="pin_sidebar_item"
phx-value-route="post"
phx-value-id={entry.id}
phx-value-title={entry.title}
phx-value-subtitle="linked post"
>
<strong><%= entry.title %></strong>
<span><%= entry.text %></span>
</button>
<% end %>
<% end %>
</div>
<% end %>
"""
end
defp render_git_log(assigns) do
entries = assigns[:panel_git_entries] || []
assigns = assign(assigns, :git_entries, entries)
~H"""
<%= if Enum.empty?(@git_entries) do %>
<div class="git-log-list flex flex-col gap-2">
<div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Git Log") %></strong>
<span><%= dgettext("ui", "No git history yet") %></span>
</div>
</div>
<% else %>
<div class="git-log-list">
<%= for entry <- @git_entries do %>
<div class="panel-entry ui-panel-entry task-entry">
<strong><%= short_commit_hash(entry.hash) %> <%= entry.subject || dgettext("ui", "No commit subject") %></strong>
<span><%= entry.hash %></span>
</div>
<% end %>
</div>
<% end %>
"""
end
defp render_generic_panel(assigns, tab) do
assigns = assign(assigns, :panel_label, ShellData.route_label(tab))
~H"""
<div class="panel-entry ui-panel-entry">
<strong><%= @panel_label %></strong>
<span><%= dgettext("ui", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %></span>
</div>
"""
end
def fetch_post_link_entries(assigns) do
case assigns.current_tab do
%{type: :post, id: post_id} ->
%{
backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id),
outlinks: related_posts(PostLinks.list_outgoing_links(post_id), :target_post_id)
}
_other ->
%{backlinks: [], outlinks: []}
end
end
defp related_posts(links, key) do
Enum.map(links, fn link ->
case Posts.get_post(Map.fetch!(link, key)) do
%Post{} = post ->
%{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
def fetch_git_log_entries(assigns) do
case git_history_target(assigns.current_tab) do
nil ->
[]
{project_id, file_path} ->
case Git.file_history(project_id, file_path) do
{:ok, %{commits: commits}} -> commits
_other -> []
end
end
end
defp git_history_target(%{type: :post, id: post_id}) do
case Posts.get_post(post_id) do
%Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] ->
{project_id, file_path}
_other ->
nil
end
end
defp git_history_target(%{type: :media, id: media_id}) do
case Media.get_media(media_id) do
%MediaRecord{project_id: project_id, file_path: file_path}
when file_path not in [nil, ""] ->
{project_id, file_path}
_other ->
nil
end
end
defp git_history_target(_tab), do: nil
def editor_toolbar_buttons(nil), do: []
def editor_toolbar_buttons(%{type: :post}) do
[
%{kind: "ai_suggestions", label: "AI Suggestions", destructive: false},
%{kind: "insert_link", label: "Insert Link", destructive: false},
%{kind: "insert_media", label: "Insert Media", destructive: false},
%{kind: "language_picker", label: "Translate", destructive: false},
%{kind: "gallery", label: "Gallery", destructive: false}
]
end
def editor_toolbar_buttons(%{type: :media}) do
[
%{kind: "ai_suggestions", label: "AI Suggestions", destructive: false},
%{kind: "language_picker", label: "Translate", destructive: false},
%{kind: "confirm_delete", label: "Delete Media", destructive: true}
]
end
def editor_toolbar_buttons(%{type: :tags}) do
[
%{kind: "confirm_merge", label: "Merge Tags", destructive: false},
%{kind: "confirm_delete", label: "Delete Tag", destructive: true}
]
end
def editor_toolbar_buttons(_tab), do: []
defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7)
defp short_commit_hash(_hash), do: "-------"
# Only called inside the template's `is_number(task.progress)` guard.
defp progress_percent(progress) when is_number(progress) do
rounded = progress |> Kernel.*(100) |> Float.round(0) |> trunc()
"#{rounded}%"
end
defp present?(value), do: BDS.Values.present?(value)
end