fix: issue #18 more visible git activities

This commit is contained in:
2026-07-06 15:52:28 +02:00
parent 8ee51c6626
commit 10fa174388
16 changed files with 958 additions and 134 deletions

View File

@@ -14,6 +14,7 @@ defmodule BDS.Desktop.ShellLive do
ChatEditor,
GalleryImport,
GitHandler,
GitRun,
ImportEditor,
MediaEditor,
MenuEditor,
@@ -62,6 +63,7 @@ defmodule BDS.Desktop.ShellLive do
@refresh_interval 1_500
def refresh_interval, do: @refresh_interval
@sidebar_filter_events [
"toggle_sidebar_filters",
"toggle_sidebar_archive",
@@ -176,12 +178,13 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:chat_editor_request_refs, %{})
|> assign(:file_picker_task, nil)
|> assign(:shell_overlay, nil)
|> assign(:git_run, nil)
|> assign(:output_entries, [])
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|> assign(:panel_git_entries, [])
|> assign(:auto_save_timers, %{})
|> reload_shell(workbench)
|> UrlState.apply_params(params)
|> UrlState.apply_params(params)
|> tap(&sync_menu_bar_locale/1)}
end
@@ -209,7 +212,7 @@ defmodule BDS.Desktop.ShellLive do
{:noreply,
socket
|> refresh_sidebar(workbench)
|> UrlState.push()}
|> UrlState.push()}
end
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
@@ -242,9 +245,25 @@ defmodule BDS.Desktop.ShellLive do
end
def handle_event(event, _params, socket) when event in @git_action_events do
{:noreply, GitHandler.run_action(socket, event)}
if GitRun.network_event?(event) do
{:noreply, GitRun.start(socket, event, current_project_id(socket))}
else
{:noreply, GitHandler.run_action(socket, event)}
end
end
def handle_event("git_run_close", _params, socket) do
socket = GitRun.close(socket)
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
end
def handle_event("git_run_keydown", %{"key" => "Escape"}, socket) do
socket = GitRun.close(socket)
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
end
def handle_event("git_run_keydown", _params, socket), do: {:noreply, socket}
def handle_event("git_commit", params, socket) do
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
@@ -302,7 +321,7 @@ defmodule BDS.Desktop.ShellLive do
socket
|> assign(:tab_meta, tab_meta)
|> refresh_layout(workbench)
|> UrlState.push()}
|> UrlState.push()}
end
def handle_event(
@@ -691,6 +710,15 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, handle_blogmark_deep_link(socket, url)}
end
def handle_info({:git_output, ref, stream, chunk}, socket) do
{:noreply, GitRun.append(socket, ref, stream, chunk)}
end
def handle_info({:git_done, ref, status}, socket) do
socket = GitRun.done(socket, ref, status)
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
end
def handle_info(message, socket) do
Bridges.handle_info(message, socket, bridges_callbacks())
end
@@ -987,7 +1015,8 @@ defmodule BDS.Desktop.ShellLive do
defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected), do: TabActions.publish_current_tab(socket)
defp handle_socket_menu_action(socket, :publish_selected),
do: TabActions.publish_current_tab(socket)
defp handle_socket_menu_action(socket, :quit) do
Shutdown.request_quit()

View File

@@ -0,0 +1,130 @@
defmodule BDS.Desktop.ShellLive.GitRun do
@moduledoc """
Live modal for streaming network git commands (fetch/pull/push).
Starts a `BDS.Git.Stream`, appends its stdout/stderr chunks as they arrive
(stderr rendered in red), shows a spinner while running and a pass/fail banner
from the exit status. Closing the modal cancels a still-running command, which
kills the OS process.
"""
use Phoenix.Component
use Gettext, backend: BDS.Gettext
alias BDS.Git
@operations %{"git_fetch" => :fetch, "git_pull" => :pull, "git_push" => :push}
@doc "True when the shell event is a streaming network git action."
def network_event?(event), do: Map.has_key?(@operations, event)
@doc "Open the modal and start streaming the operation."
def start(socket, event, project_id) do
operation = Map.fetch!(@operations, event)
run =
case start_stream(project_id, operation) do
{:ok, pid, ref} ->
%{operation: operation, pid: pid, ref: ref, lines: [], status: :running}
{:error, reason} ->
%{
operation: operation,
pid: nil,
ref: nil,
lines: [{:stderr, error_text(reason)}],
status: {:done, 1}
}
end
assign(socket, :git_run, run)
end
@doc "Append a streamed chunk to the modal (newest first internally)."
def append(socket, ref, stream, chunk) do
case socket.assigns[:git_run] do
%{ref: ^ref, lines: lines} = run ->
assign(socket, :git_run, %{run | lines: [{stream, chunk} | lines]})
_other ->
socket
end
end
@doc "Mark the run finished with its exit status."
def done(socket, ref, status) do
case socket.assigns[:git_run] do
%{ref: ^ref} = run ->
assign(socket, :git_run, %{run | status: {:done, status}, pid: nil})
_other ->
socket
end
end
@doc "Close the modal, cancelling (killing) a still-running command."
def close(socket) do
case socket.assigns[:git_run] do
%{status: :running, pid: pid} when is_pid(pid) -> Git.Stream.cancel(pid)
_other -> :ok
end
assign(socket, :git_run, nil)
end
defp start_stream(nil, _operation), do: {:error, :no_project}
defp start_stream("default", _operation), do: {:error, :no_project}
defp start_stream(project_id, operation) when is_binary(project_id),
do: Git.stream(project_id, operation, self())
defp error_text(reason) when reason in [:no_project, :not_found],
do: dgettext("ui", "No active project") <> "\n"
defp error_text(reason), do: inspect(reason) <> "\n"
def git_run_modal(assigns) do
~H"""
<%= if @git_run do %>
<div class="shell-overlay-backdrop git-run-backdrop" data-testid="git-run-backdrop" phx-window-keydown="git_run_keydown">
<button class="shell-overlay-dismiss" type="button" phx-click="git_run_close" aria-label={dgettext("ui", "Close")}></button>
<div class="git-run-modal" role="dialog" aria-modal="true">
<div class="git-run-header">
<h2 class="git-run-title">{title(@git_run.operation)}</h2>
<span class={["git-run-status", status_class(@git_run.status)]} data-testid="git-run-status">
{status_label(@git_run.status)}
</span>
</div>
<pre class="git-run-output" data-testid="git-run-output"><%= for {stream, text} <- Enum.reverse(@git_run.lines) do %><span class={line_class(stream)}>{text}</span><% end %></pre>
<div class="git-run-footer">
<button class="git-action-button" type="button" phx-click="git_run_close" data-testid="git-run-close">
{close_label(@git_run.status)}
</button>
</div>
</div>
</div>
<% end %>
"""
end
defp title(:fetch), do: dgettext("ui", "git fetch")
defp title(:pull), do: dgettext("ui", "git pull")
defp title(:push), do: dgettext("ui", "git push")
defp line_class(:stderr), do: "git-run-line git-run-stderr"
defp line_class(:stdout), do: "git-run-line git-run-stdout"
defp status_class(:running), do: "running"
defp status_class({:done, 0}), do: "ok"
defp status_class({:done, _status}), do: "fail"
defp status_label(:running), do: dgettext("ui", "Running…")
defp status_label({:done, 0}), do: dgettext("ui", "Done")
defp status_label({:done, status}),
do: dgettext("ui", "Failed (exit %{status})", status: status)
defp close_label(:running), do: dgettext("ui", "Cancel")
defp close_label({:done, _status}), do: dgettext("ui", "Close")
end

View File

@@ -678,4 +678,5 @@
</footer>
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
<GitRun.git_run_modal git_run={@git_run} />
</div>

View File

@@ -133,7 +133,6 @@ defmodule BDS.Git do
opts
),
{:ok, remote_log} <- remote_history_log(project_dir, branch, opts) do
local_commits = parse_history_log(local_log)
remote_hashes = MapSet.new(parse_remote_history(remote_log))
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
@@ -194,6 +193,30 @@ defmodule BDS.Git do
end
end
@doc """
Start a streaming network git command (`:fetch | :pull | :push`), forwarding
output to `subscriber` (see `BDS.Git.Stream`) so the UI can show it live and
cancel a hanging run. Returns `{:ok, pid, ref}`, or `{:error, reason}` when the
project can't be resolved.
"""
def stream(project_id, operation, subscriber, opts \\ [])
when is_binary(project_id) and operation in [:fetch, :pull, :push] and is_pid(subscriber) do
with {:ok, project_dir} <- project_dir(project_id) do
env = command_opts(project_dir, 0)[:env]
BDS.Git.Stream.start(
subscriber,
project_dir,
stream_args(operation),
Keyword.put(opts, :env, env)
)
end
end
defp stream_args(:fetch), do: ["fetch", "--all", "--prune", "--progress"]
defp stream_args(:pull), do: ["pull", "--ff-only", "--progress"]
defp stream_args(:push), do: ["push", "--progress"]
def commit_all(project_id, message, opts \\ [])
when is_binary(project_id) and is_binary(message) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id),

142
lib/bds/git/stream.ex Normal file
View File

@@ -0,0 +1,142 @@
defmodule BDS.Git.Stream do
@moduledoc """
Runs a git command and streams its output to a subscriber process as it
arrives, keeping stdout and stderr on separate channels.
A plain Erlang port merges stderr into stdout, so we redirect the command's
stderr through a FIFO (`sh -c 'exec "$0" "$@" 2>fifo'`) and read that FIFO with
a second `cat` port. `sh`, `cat` and `mkfifo` are system binaries, so no extra
dependency is bundled.
The subscriber receives:
* `{:git_output, ref, :stdout, chunk}`
* `{:git_output, ref, :stderr, chunk}`
* `{:git_done, ref, exit_status}` (`0` on success)
Send `cancel/1` to terminate a hanging command; the OS process is killed and a
`{:git_done, ref, status}` still follows.
"""
@drain_ms 200
@spec start(pid, String.t(), [String.t()], keyword) :: {:ok, pid, reference}
def start(subscriber, cwd, args, opts \\ [])
when is_pid(subscriber) and is_binary(cwd) and is_list(args) do
ref = make_ref()
pid = spawn(fn -> init(subscriber, ref, cwd, args, opts) end)
{:ok, pid, ref}
end
@spec cancel(pid) :: :ok
def cancel(pid) when is_pid(pid) do
send(pid, :cancel)
:ok
end
defp init(subscriber, ref, cwd, args, opts) do
command = Keyword.get(opts, :command, "git")
env = Keyword.get(opts, :env, %{})
case System.find_executable(command) do
nil ->
send(subscriber, {:git_output, ref, :stderr, "missing executable: #{command}\n"})
send(subscriber, {:git_done, ref, 127})
executable ->
run(subscriber, ref, cwd, executable, args, env)
end
end
defp run(subscriber, ref, cwd, executable, args, env) do
fifo_dir = Path.join(System.tmp_dir!(), "bds-git-fifo-#{System.unique_integer([:positive])}")
File.mkdir_p!(fifo_dir)
fifo = Path.join(fifo_dir, "stderr")
try do
{_out, 0} = System.cmd("mkfifo", [fifo])
# exec replaces sh with the target binary, so the OS pid and exit status we
# observe on this port are the command's own.
script = ~s(exec "$0" "$@" 2>'#{fifo}')
out_port =
Port.open(
{:spawn_executable, System.find_executable("sh")},
[
:binary,
:exit_status,
:use_stdio,
:hide,
{:cd, cwd},
{:env, port_env(env)},
{:args, ["-c", script, executable | args]}
]
)
err_port =
Port.open(
{:spawn_executable, System.find_executable("cat")},
[:binary, :exit_status, :use_stdio, :hide, {:args, [fifo]}]
)
loop(subscriber, ref, out_port, err_port)
after
File.rm_rf(fifo_dir)
end
end
defp loop(subscriber, ref, out_port, err_port) do
receive do
{^out_port, {:data, data}} ->
send(subscriber, {:git_output, ref, :stdout, data})
loop(subscriber, ref, out_port, err_port)
{^err_port, {:data, data}} ->
send(subscriber, {:git_output, ref, :stderr, data})
loop(subscriber, ref, out_port, err_port)
{^err_port, {:exit_status, _status}} ->
loop(subscriber, ref, out_port, nil)
{^out_port, {:exit_status, status}} ->
drain(subscriber, ref, err_port)
send(subscriber, {:git_done, ref, status})
:cancel ->
kill(out_port)
loop(subscriber, ref, out_port, err_port)
end
end
defp drain(_subscriber, _ref, nil), do: :ok
defp drain(subscriber, ref, err_port) do
receive do
{^err_port, {:data, data}} ->
send(subscriber, {:git_output, ref, :stderr, data})
drain(subscriber, ref, err_port)
{^err_port, {:exit_status, _status}} ->
:ok
after
@drain_ms -> :ok
end
end
defp kill(port) do
case Port.info(port, :os_pid) do
{:os_pid, os_pid} when is_integer(os_pid) ->
System.cmd("kill", ["-TERM", Integer.to_string(os_pid)], stderr_to_stdout: true)
_other ->
:ok
end
end
defp port_env(env) do
Enum.map(env, fn {key, value} ->
{String.to_charlist(to_string(key)), String.to_charlist(to_string(value))}
end)
end
end