fix: issue #20 cmd-J panel tab strip visibility and output panel parity

This commit is contained in:
2026-07-17 20:51:42 +02:00
parent 6c9dd9605b
commit dd53ca3fbc
28 changed files with 1451 additions and 666 deletions

View File

@@ -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)}"}

View File

@@ -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")

View File

@@ -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})

View File

@@ -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={[

View File

@@ -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))

View File

@@ -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",

View File

@@ -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),

View File

@@ -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

View 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