fix: issue #20 cmd-J panel tab strip visibility and output panel parity
This commit is contained in:
@@ -555,7 +555,8 @@ defmodule BDS.CLI.Commands do
|
||||
script.entrypoint || "main",
|
||||
args,
|
||||
timeout: Keyword.get(scripting_config, :job_timeout, :infinity),
|
||||
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none)
|
||||
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none),
|
||||
on_output: fn text -> IO.puts(text) end
|
||||
) do
|
||||
{:ok, nil} -> {:ok, "Script finished"}
|
||||
{:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"}
|
||||
|
||||
@@ -182,6 +182,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> assign(:git_run, nil)
|
||||
|> assign(:output_entries, [])
|
||||
|> assign(:collapsed_task_groups, MapSet.new())
|
||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||
|> assign(:panel_git_entries, [])
|
||||
|> assign(:auto_save_timers, %{})
|
||||
@@ -226,6 +227,38 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, refresh_layout(socket, workbench)}
|
||||
end
|
||||
|
||||
# Copy-all for the Output panel: entries are stored newest-first, so flip
|
||||
# them back to chronological order before joining (old-app behavior).
|
||||
def handle_event("copy_output", _params, socket) do
|
||||
text =
|
||||
socket.assigns.output_entries
|
||||
|> Enum.reverse()
|
||||
|> Enum.map_join("\n\n", & &1.message)
|
||||
|
||||
{:noreply, push_event(socket, "clipboard", %{text: text})}
|
||||
end
|
||||
|
||||
def handle_event("toggle_task_group", %{"group" => group_id}, socket) do
|
||||
collapsed = socket.assigns[:collapsed_task_groups] || MapSet.new()
|
||||
|
||||
collapsed =
|
||||
if MapSet.member?(collapsed, group_id),
|
||||
do: MapSet.delete(collapsed, group_id),
|
||||
else: MapSet.put(collapsed, group_id)
|
||||
|
||||
{:noreply, assign(socket, :collapsed_task_groups, collapsed)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_task", %{"id" => task_id}, socket) do
|
||||
_ = BDS.Tasks.cancel_task(task_id)
|
||||
|
||||
task_status =
|
||||
BDS.Tasks.status_snapshot()
|
||||
|> BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(socket.assigns.page_language)
|
||||
|
||||
{:noreply, assign(socket, :task_status, task_status)}
|
||||
end
|
||||
|
||||
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
||||
{:noreply, open_sidebar_item(socket, params, :preview)}
|
||||
end
|
||||
@@ -860,33 +893,66 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
defp import_blogmark(socket, project_id, url, title) do
|
||||
case Blogmark.receive_deep_link(project_id, url) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
||||
socket
|
||||
|> reload_shell(socket.assigns.workbench)
|
||||
|> open_sidebar_item(
|
||||
%{
|
||||
"route" => "post",
|
||||
"id" => post.id,
|
||||
"title" => post.title,
|
||||
"subtitle" => post.slug
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|> append_blogmark_toasts(title, toasts)
|
||||
|> append_blogmark_errors(title, errors)
|
||||
# Transform scripts run in the sandbox process; collect anything they
|
||||
# print/log so it lands in the Output panel like in the old app.
|
||||
{:ok, output_sink} = Agent.start_link(fn -> [] end)
|
||||
|
||||
{:error, reason} ->
|
||||
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||
sink = fn text -> Agent.update(output_sink, fn lines -> [text | lines] end) end
|
||||
|
||||
try do
|
||||
case Blogmark.receive_deep_link(project_id, url, on_output: sink) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
||||
script_output = Agent.get(output_sink, &Enum.reverse/1)
|
||||
|
||||
socket
|
||||
|> reload_shell(socket.assigns.workbench)
|
||||
|> open_sidebar_item(
|
||||
%{
|
||||
"route" => "post",
|
||||
"id" => post.id,
|
||||
"title" => post.title,
|
||||
"subtitle" => post.slug
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|> append_blogmark_script_output(title, script_output)
|
||||
|> append_blogmark_toasts(title, toasts)
|
||||
|> append_blogmark_errors(title, errors)
|
||||
|> open_output_panel_when(script_output != [] or errors != [])
|
||||
|
||||
{:error, reason} ->
|
||||
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||
end
|
||||
after
|
||||
Agent.stop(output_sink)
|
||||
end
|
||||
end
|
||||
|
||||
defp append_blogmark_script_output(socket, title, lines) do
|
||||
Enum.reduce(lines, socket, fn line, acc ->
|
||||
append_output_entry(acc, title, line, nil, "info")
|
||||
end)
|
||||
end
|
||||
|
||||
defp append_blogmark_toasts(socket, title, toasts) do
|
||||
Enum.reduce(toasts, socket, fn message, acc ->
|
||||
append_output_entry(acc, title, message, nil, "info")
|
||||
end)
|
||||
end
|
||||
|
||||
# Old-app parity: transform errors or streamed script output open the
|
||||
# bottom panel on the Output tab so the user actually sees them.
|
||||
defp open_output_panel_when(socket, true) do
|
||||
workbench =
|
||||
socket.assigns.workbench
|
||||
|> Workbench.set_panel_visible(true)
|
||||
|> Workbench.set_panel_tab(:output)
|
||||
|
||||
refresh_layout(socket, workbench)
|
||||
end
|
||||
|
||||
defp open_output_panel_when(socket, false), do: socket
|
||||
|
||||
defp append_blogmark_errors(socket, title, errors) do
|
||||
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
|
||||
append_output_entry(acc, title, inspect(reason), slug, "error")
|
||||
|
||||
@@ -20,6 +20,17 @@ defmodule BDS.Desktop.ShellLive.Notify do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends an output entry to a specific LiveView process. Needed when the
|
||||
caller runs in a different process than the shell — e.g. script output
|
||||
callbacks invoked inside the sandboxed script process.
|
||||
"""
|
||||
@spec output_to(pid(), String.t(), String.t(), String.t() | nil, String.t()) :: :ok
|
||||
def output_to(pid, title, message, detail, level) when is_pid(pid) do
|
||||
send(pid, {:editor_output, title, message, detail, level})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec alert(String.t(), String.t()) :: :ok
|
||||
def alert(title, message) do
|
||||
send(self(), {:shell_alert, title, message})
|
||||
|
||||
@@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
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."
|
||||
@@ -48,34 +49,122 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
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?(Map.get(@task_status, :tasks, [])) do %>
|
||||
<%= 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 task <- Map.get(@task_status, :tasks, []) do %>
|
||||
<div class="panel-entry ui-panel-entry task-entry flex flex-col gap-2">
|
||||
<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 %>
|
||||
</div>
|
||||
<%= 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 %>
|
||||
@@ -84,6 +173,16 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
<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={[
|
||||
|
||||
@@ -206,11 +206,17 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
|
||||
%Script{} = script ->
|
||||
draft = current_draft(socket.assigns, script)
|
||||
|
||||
# The sandbox runs in its own process, so streamed script output is
|
||||
# routed back to this LiveView explicitly.
|
||||
live_view = self()
|
||||
title = dgettext("ui", "Scripts")
|
||||
|
||||
case Scripting.execute_project_script(
|
||||
script.project_id,
|
||||
draft["content"] || "",
|
||||
draft["entrypoint"] || "main",
|
||||
[]
|
||||
[],
|
||||
on_output: fn text -> Notify.output_to(live_view, title, text, nil, "info") end
|
||||
) do
|
||||
{:ok, result} ->
|
||||
notify_output(socket, dgettext("ui", "Scripts"), inspect(result))
|
||||
|
||||
@@ -50,6 +50,14 @@ defmodule BDS.Scripting.ApiDocs do
|
||||
params: [],
|
||||
returns: "table | nil"
|
||||
},
|
||||
%{
|
||||
module: "app",
|
||||
name: "log",
|
||||
description:
|
||||
"Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.",
|
||||
params: [%{name: "text", type: "string", required: true}],
|
||||
returns: "boolean"
|
||||
},
|
||||
%{
|
||||
module: "app",
|
||||
name: "notify_renderer_ready",
|
||||
|
||||
@@ -49,6 +49,7 @@ defmodule BDS.Scripting.Capabilities do
|
||||
get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end),
|
||||
get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end),
|
||||
get_title_bar_metrics: zero_or_one_arg(fn _args -> title_bar_metrics(opts) end),
|
||||
log: BDS.Scripting.Lua.output_function(opts, [true]),
|
||||
notify_renderer_ready: zero_or_one_arg(fn _args -> notify_renderer_ready(opts) end),
|
||||
open_folder: one_arg(fn folder_path -> open_folder(folder_path, opts) end),
|
||||
read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end),
|
||||
|
||||
@@ -6,16 +6,20 @@ defmodule BDS.Scripting.Lua do
|
||||
and opt-in.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@type source :: String.t()
|
||||
@type entrypoint :: String.t()
|
||||
@type args :: [term()]
|
||||
@type progress_event :: map()
|
||||
@type progress_callback :: (progress_event() -> any())
|
||||
@type output_callback :: (String.t() -> any())
|
||||
@type execution_option ::
|
||||
{:timeout, non_neg_integer() | :infinity}
|
||||
| {:max_reductions, pos_integer() | :none}
|
||||
| {:spawn_opts, [term()]}
|
||||
| {:on_progress, progress_callback()}
|
||||
| {:on_output, output_callback()}
|
||||
| {:capabilities, map()}
|
||||
|
||||
@callback validate(source()) :: :ok | {:error, term()}
|
||||
@@ -48,12 +52,65 @@ defmodule BDS.Scripting.Lua do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the sink script output (`print`, `bds.app.log`) is routed to: the
|
||||
`:on_output` callback when given, otherwise a Logger-based default so script
|
||||
output never leaks onto the console (where it would corrupt the TUI).
|
||||
"""
|
||||
@spec output_sink(keyword()) :: output_callback()
|
||||
def output_sink(opts) when is_list(opts) do
|
||||
case Keyword.get(opts, :on_output) do
|
||||
callback when is_function(callback, 1) ->
|
||||
callback
|
||||
|
||||
_other ->
|
||||
fn text -> Logger.debug("script output: #{text}") end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds the Lua-callable function shared by the global `print` override and
|
||||
the `bds.app.log` capability: joins all arguments with spaces and forwards
|
||||
the line to the output sink.
|
||||
"""
|
||||
@spec output_function(keyword(), [term()]) :: (list(), lua_state() -> {list(), lua_state()})
|
||||
def output_function(opts, return_values \\ []) when is_list(opts) and is_list(return_values) do
|
||||
sink = output_sink(opts)
|
||||
|
||||
fn args, current_state ->
|
||||
line =
|
||||
args
|
||||
|> :luerl.decode_list(current_state)
|
||||
|> Enum.map_join(" ", &format_lua_value/1)
|
||||
|
||||
sink.(line)
|
||||
:luerl.encode_list(return_values, current_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Formats a decoded Lua value the way Lua's `tostring` would present it."
|
||||
@spec format_lua_value(term()) :: String.t()
|
||||
def format_lua_value(nil), do: "nil"
|
||||
def format_lua_value(true), do: "true"
|
||||
def format_lua_value(false), do: "false"
|
||||
def format_lua_value(value) when is_binary(value), do: value
|
||||
|
||||
def format_lua_value(value) when is_float(value) do
|
||||
if Float.round(value, 0) == value,
|
||||
do: "#{trunc(value)}.0",
|
||||
else: to_string(value)
|
||||
end
|
||||
|
||||
def format_lua_value(value) when is_integer(value), do: Integer.to_string(value)
|
||||
def format_lua_value(value), do: inspect(value)
|
||||
|
||||
defp initial_state(opts) do
|
||||
state = :luerl_sandbox.init()
|
||||
capabilities = Keyword.get(opts, :capabilities, %{})
|
||||
|
||||
with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state),
|
||||
{:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)),
|
||||
{:ok, state} <- install_print_override(state, opts),
|
||||
{:ok, state} <- install_capabilities(state, capabilities) do
|
||||
{:ok, state}
|
||||
end
|
||||
@@ -85,6 +142,15 @@ defmodule BDS.Scripting.Lua do
|
||||
defp install_progress_callback(_state, callback),
|
||||
do: {:error, {:invalid_progress_callback, callback}}
|
||||
|
||||
# Replaces the built-in `print` (which would write straight to the BEAM
|
||||
# console) with a function routed to the output sink.
|
||||
defp install_print_override(state, opts) do
|
||||
case :luerl.set_table_keys_dec(["print"], output_function(opts), state) do
|
||||
{:ok, next_state} -> {:ok, next_state}
|
||||
error -> {:error, {:print_override_install_failed, error}}
|
||||
end
|
||||
end
|
||||
|
||||
defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state}
|
||||
|
||||
defp install_capabilities(state, capabilities) when is_map(capabilities) do
|
||||
|
||||
91
lib/bds/ui/task_grouping.ex
Normal file
91
lib/bds/ui/task_grouping.ex
Normal file
@@ -0,0 +1,91 @@
|
||||
defmodule BDS.UI.TaskGrouping do
|
||||
@moduledoc """
|
||||
Groups task snapshot entries for the bottom panel's Tasks tab, mirroring the
|
||||
old app's `taskGrouping.ts`: tasks sharing a `group_id` collapse into one
|
||||
group entry (ordered by first appearance), everything else stays a single
|
||||
entry. Groups summarize to per-status counts plus an aggregate progress in
|
||||
the 0.0..1.0 range.
|
||||
"""
|
||||
|
||||
@type task :: map()
|
||||
@type entry :: {:single, task()} | {:group, String.t(), String.t(), [task()]}
|
||||
@type summary :: %{
|
||||
total: non_neg_integer(),
|
||||
running: non_neg_integer(),
|
||||
pending: non_neg_integer(),
|
||||
completed: non_neg_integer(),
|
||||
failed: non_neg_integer(),
|
||||
cancelled: non_neg_integer(),
|
||||
progress: float()
|
||||
}
|
||||
|
||||
@spec build_task_entries([task()]) :: [entry()]
|
||||
def build_task_entries(tasks) when is_list(tasks) do
|
||||
{singles, groups} =
|
||||
tasks
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce({[], %{}}, fn {task, index}, {singles, groups} ->
|
||||
case Map.get(task, :group_id) do
|
||||
nil ->
|
||||
{[{index, task} | singles], groups}
|
||||
|
||||
group_id ->
|
||||
groups =
|
||||
Map.update(groups, group_id, {index, Map.get(task, :group_name) || group_id, [task]}, fn {first_index, name, grouped} ->
|
||||
{first_index, name, [task | grouped]}
|
||||
end)
|
||||
|
||||
{singles, groups}
|
||||
end
|
||||
end)
|
||||
|
||||
single_entries = Enum.map(singles, fn {index, task} -> {index, {:single, task}} end)
|
||||
|
||||
group_entries =
|
||||
Enum.map(groups, fn {group_id, {index, name, grouped}} ->
|
||||
{index, {:group, group_id, name, Enum.reverse(grouped)}}
|
||||
end)
|
||||
|
||||
(single_entries ++ group_entries)
|
||||
|> Enum.sort_by(fn {index, _entry} -> index end)
|
||||
|> Enum.map(fn {_index, entry} -> entry end)
|
||||
end
|
||||
|
||||
@spec summarize_task_group([task()]) :: summary()
|
||||
def summarize_task_group(tasks) when is_list(tasks) do
|
||||
base = %{total: 0, running: 0, pending: 0, completed: 0, failed: 0, cancelled: 0, progress: 0.0}
|
||||
|
||||
summary =
|
||||
Enum.reduce(tasks, base, fn task, acc ->
|
||||
status = Map.get(task, :status)
|
||||
|
||||
acc
|
||||
|> Map.update!(:total, &(&1 + 1))
|
||||
|> count_status(status)
|
||||
|> Map.update!(:progress, &(&1 + progress_contribution(task)))
|
||||
end)
|
||||
|
||||
if summary.total > 0 do
|
||||
%{summary | progress: summary.progress / summary.total}
|
||||
else
|
||||
summary
|
||||
end
|
||||
end
|
||||
|
||||
defp count_status(acc, status) when status in [:running, :pending, :completed, :failed, :cancelled],
|
||||
do: Map.update!(acc, status, &(&1 + 1))
|
||||
|
||||
defp count_status(acc, _status), do: acc
|
||||
|
||||
# Finished tasks count as fully done no matter their outcome, pending tasks
|
||||
# contribute nothing yet (old-app getProgressContribution).
|
||||
defp progress_contribution(%{status: status}) when status in [:completed, :failed, :cancelled],
|
||||
do: 1.0
|
||||
|
||||
defp progress_contribution(%{status: :pending}), do: 0.0
|
||||
|
||||
defp progress_contribution(%{progress: progress}) when is_number(progress),
|
||||
do: progress |> max(0.0) |> min(1.0)
|
||||
|
||||
defp progress_contribution(_task), do: 0.0
|
||||
end
|
||||
Reference in New Issue
Block a user