fix: issue #18 more visible git activities
This commit is contained in:
@@ -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()
|
||||
|
||||
130
lib/bds/desktop/shell_live/git_run.ex
Normal file
130
lib/bds/desktop/shell_live/git_run.ex
Normal 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
|
||||
@@ -678,4 +678,5 @@
|
||||
</footer>
|
||||
|
||||
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
|
||||
<GitRun.git_run_modal git_run={@git_run} />
|
||||
</div>
|
||||
|
||||
@@ -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
142
lib/bds/git/stream.ex
Normal 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
|
||||
Reference in New Issue
Block a user