diff --git a/lib/bds/desktop/shell_data.ex b/lib/bds/desktop/shell_data.ex index f1dd64c..7f4b420 100644 --- a/lib/bds/desktop/shell_data.ex +++ b/lib/bds/desktop/shell_data.ex @@ -1,6 +1,7 @@ defmodule BDS.Desktop.ShellData do @moduledoc false + alias BDS.Git alias BDS.I18n alias BDS.Projects alias BDS.UI.Dashboard @@ -100,6 +101,30 @@ defmodule BDS.Desktop.ShellData do ) end + def git_badge_count(project_id, opts \\ []) + + def git_badge_count(nil, _opts), do: 0 + def git_badge_count("default", _opts), do: 0 + + def git_badge_count(project_id, opts) when is_binary(project_id) do + provider = Keyword.get(opts, :provider, git_remote_state_provider()) + + try do + case provider.(project_id, []) do + {:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind + {:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind) + _other -> 0 + end + rescue + error in [DBConnection.OwnershipError, Exqlite.Error] -> + if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do + reraise error, __STACKTRACE__ + end + + 0 + end + end + def panel_tabs(workbench) do [:tasks, :output] |> maybe_add_panel_tab(workbench.editor_route, :post_links) @@ -108,6 +133,17 @@ defmodule BDS.Desktop.ShellData do |> Enum.uniq() end + defp git_remote_state_provider do + Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2) + end + + defp parse_positive_count(value) do + case Integer.parse(value) do + {count, _rest} when count > 0 -> count + _other -> 0 + end + end + def activity_icon(id) do case to_string(id) do "posts" -> ~s() diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 7fa13eb..122dd49 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -394,11 +394,12 @@ defmodule BDS.Desktop.ShellLive do defp reload_shell(socket, workbench) do projects = ShellData.project_snapshot() dashboard = ShellData.dashboard(projects.active_project_id) + git_badge_count = ShellData.git_badge_count(projects.active_project_id) active_view_id = Atom.to_string(workbench.active_view) sidebar_data = ShellData.sidebar_view(projects.active_project_id, active_view_id, current_sidebar_filters(socket, active_view_id)) sidebar_data = merge_sidebar_ui_state(socket, active_view_id, sidebar_data) task_status = BDS.Tasks.status_snapshot() - activity_buttons = Workbench.activity_buttons(workbench, 0) + activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) page_language = socket.assigns[:page_language] || ShellData.ui_language() offline_mode = Map.get(socket.assigns, :offline_mode, true) diff --git a/lib/bds/desktop/shell_live/index.html.heex b/lib/bds/desktop/shell_live/index.html.heex index 5c50e0f..ffb8659 100644 --- a/lib/bds/desktop/shell_live/index.html.heex +++ b/lib/bds/desktop/shell_live/index.html.heex @@ -112,6 +112,9 @@ aria-label={activity_label(button.label)} > <%= raw(ShellData.activity_icon(button.id)) %> + <%= if button.badge do %> + <%= button.badge.display %> + <% end %> <% end %> @@ -129,6 +132,9 @@ aria-label={activity_label(button.label)} > <%= raw(ShellData.activity_icon(button.id)) %> + <%= if button.badge do %> + <%= button.badge.display %> + <% end %> <% end %> diff --git a/lib/bds/git.ex b/lib/bds/git.ex index 01c9619..b888453 100644 --- a/lib/bds/git.ex +++ b/lib/bds/git.ex @@ -171,6 +171,26 @@ defmodule BDS.Git do end end + def remote_state(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do + with {:ok, project_dir} <- project_dir(project_id), + {:ok, local_branch} <- current_branch(project_dir, opts) do + case upstream_branch(project_dir, opts) do + {:ok, nil} -> + {:ok, %{local_branch: local_branch, upstream_branch: nil, has_upstream: false, ahead: 0, behind: 0}} + + {:ok, upstream_branch} -> + {:ok, + %{ + local_branch: local_branch, + upstream_branch: upstream_branch, + has_upstream: true, + ahead: revision_count(project_dir, "#{upstream_branch}..HEAD", opts), + behind: revision_count(project_dir, "HEAD..#{upstream_branch}", opts) + }} + end + end + end + defp project_dir(project_id) do case Projects.get_project(project_id) do nil -> {:error, :not_found} @@ -274,6 +294,13 @@ defmodule BDS.Git do ] end + defp upstream_branch(project_dir, opts) do + case run_git(project_dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], opts) do + {:ok, output} -> {:ok, blank_to_nil(output)} + {:error, {:git_failed, _message}} -> {:ok, nil} + end + end + defp parse_status(output) do output |> String.split("\n", trim: true) @@ -334,6 +361,20 @@ defmodule BDS.Git do end end + defp revision_count(project_dir, revision_range, opts) do + case run_git(project_dir, ["rev-list", "--count", revision_range], opts) do + {:ok, output} -> parse_count(output) + {:error, {:git_failed, _message}} -> 0 + end + end + + defp parse_count(value) do + case Integer.parse(to_string(value || "")) do + {count, _rest} -> count + :error -> 0 + end + end + defp category_for_path("posts/" <> _rest), do: :posts defp category_for_path("scripts/" <> _rest), do: :scripts defp category_for_path("templates/" <> _rest), do: :templates diff --git a/priv/ui/app.css b/priv/ui/app.css index 29eb90b..9c90673 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -393,6 +393,23 @@ button { background-color: var(--vscode-activityBar-foreground); } +.activity-bar-badge { + position: absolute; + top: 8px; + right: 8px; + min-width: 16px; + height: 16px; + padding: 0 4px; + font-size: 10px; + font-weight: 600; + background-color: var(--vscode-activityBarBadge-background); + color: var(--vscode-activityBarBadge-foreground); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; +} + .activity-bar-item svg, .tab-icon svg { display: block; diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index c6b2e56..8b3cda6 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -26,6 +26,7 @@ defmodule BDS.Desktop.ShellLiveTest do {:ok, _project} = Projects.set_active_project(project.id) original_shell_platform = Application.get_env(:bds, :shell_platform) + original_git_remote_state_provider = Application.get_env(:bds, :git_remote_state_provider) on_exit(fn -> if is_nil(original_shell_platform) do @@ -33,6 +34,12 @@ defmodule BDS.Desktop.ShellLiveTest do else Application.put_env(:bds, :shell_platform, original_shell_platform) end + + if is_nil(original_git_remote_state_provider) do + Application.delete_env(:bds, :git_remote_state_provider) + else + Application.put_env(:bds, :git_remote_state_provider, original_git_remote_state_provider) + end end) %{project: project, temp_dir: temp_dir} @@ -158,6 +165,18 @@ defmodule BDS.Desktop.ShellLiveTest do refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") end + test "shell live renders the legacy git activity badge from remote behind count" do + Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts -> + {:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}} + end) + + {:ok, _view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + assert html =~ ~s(data-view="git") + assert html =~ ~s(class="activity-bar-badge") + assert html =~ ">7<" + end + test "sidebar open supports preview and pin intents for entity tabs" do {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) diff --git a/test/bds/git_test.exs b/test/bds/git_test.exs index 74743c5..e2e1cb7 100644 --- a/test/bds/git_test.exs +++ b/test/bds/git_test.exs @@ -72,6 +72,22 @@ defmodule BDS.GitTest do assert repo.current_branch == "main" end + test "remote_state reports upstream ahead and behind counts", %{project: project} do + runner = fake_runner(fn + "git", ["rev-parse", "--abbrev-ref", "HEAD"], _opts -> {"main\n", 0} + "git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], _opts -> {"origin/main\n", 0} + "git", ["rev-list", "--count", "origin/main..HEAD"], _opts -> {"2\n", 0} + "git", ["rev-list", "--count", "HEAD..origin/main"], _opts -> {"5\n", 0} + end) + + assert {:ok, remote_state} = Git.remote_state(project.id, runner: runner) + assert remote_state.local_branch == "main" + assert remote_state.upstream_branch == "origin/main" + assert remote_state.has_upstream == true + assert remote_state.ahead == 2 + assert remote_state.behind == 5 + end + test "fetch, pull, push, commit_all, reconcile, and prune_lfs_cache run non-interactively", %{ project: project } do diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index c25dcf7..adbf191 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -136,11 +136,17 @@ defmodule BDS.UI.ShellTest do template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") assert css =~ "color: var(--vscode-activityBar-foreground)" + assert css =~ ".activity-bar-badge" assert css =~ ".tab-actions" assert css =~ ".tab-dirty-indicator" assert css =~ ".tab.dirty .tab-close" assert css =~ ".tab:focus-visible" assert css =~ ".window-titlebar-action-button:focus" + assert css =~ ".panel-tab.active" + assert css =~ "border-bottom-color: var(--vscode-focusBorder);" + assert css =~ ".sidebar-section-header" + assert css =~ "justify-content: space-between" + assert css =~ "align-items: center" assert css =~ "padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));" assert live_js =~ "windowControlsOverlay" assert live_js =~ "geometrychange" @@ -150,6 +156,7 @@ defmodule BDS.UI.ShellTest do assert live_js =~ "event.preventDefault()" assert live_js =~ "this.pushEvent(\"shortcut\"" assert template =~ "data-shortcuts={encoded_shortcuts(@client_shortcuts)}" + assert template =~ "activity-bar-badge" assert template =~ "tab-actions" assert template =~ "tab-dirty-indicator" end