404 lines
13 KiB
Elixir
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
|