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

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