fix: issue #20 cmd-J panel tab strip visibility and output panel parity
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user