perf(git): move refresh and actions off the UI thread

This commit is contained in:
2026-06-19 00:15:50 -05:00
parent e74e6ec513
commit 6361002f53

View File

@@ -27,6 +27,7 @@
#include <cstring>
#include <filesystem>
#include <fstream>
#include <future>
#include <limits>
#include <map>
#include <memory>
@@ -71,6 +72,9 @@ std::array<char, 256> g_inline_branch_name{};
std::string g_inline_branch_target;
std::string g_requested_branch_checkout;
std::string g_running_branch_checkout;
std::string g_merge_branch_source;
std::string g_merge_branch_target;
bool g_merge_branch_popup = false;
std::string g_pending_commit_jump;
RepositoryView* g_expanded_refs_repository = nullptr;
int g_expanded_refs_commit = -1;
@@ -121,6 +125,36 @@ using RefreshClock = std::chrono::steady_clock;
constexpr auto active_refresh_interval = std::chrono::seconds(2);
constexpr auto background_refresh_interval = std::chrono::seconds(5);
enum class GitAsyncOperation { reload, capture, pull, push, checkout_branch, push_branch, fetch, stash, pop_stash };
struct GitAsyncRequest {
GitAsyncOperation operation = GitAsyncOperation::reload;
RepositoryView* tab = nullptr;
std::string repo_path;
std::vector<std::string> arguments;
std::string branch;
std::string remote;
std::string success_notice;
std::string focus_commit;
std::string preserve_selected_hash;
int pull_mode = 1;
bool surface_errors = false;
};
struct GitAsyncResult {
GitAsyncOperation operation = GitAsyncOperation::reload;
RepositoryView* tab = nullptr;
std::string repo_path;
bool success = false;
std::string notice;
std::string focus_commit;
std::string preserve_selected_hash;
bool surface_errors = false;
std::unique_ptr<RepositoryView> snapshot;
};
std::future<GitAsyncResult> g_git_async_future;
float ui(float value) { return value * g_ui_scale; }
RepositoryView& repo() { return *g_tabs.at(g_active_tab); }
@@ -187,25 +221,191 @@ bool copy_to_clipboard(std::string_view text, const char* description) {
return true;
}
void transfer_repository_state(RepositoryView& source, RepositoryView& target) {
if (&source == &target) return;
target.close();
target.repo = source.repo;
source.repo = nullptr;
target.commit_walk = source.commit_walk;
source.commit_walk = nullptr;
target.history_exhausted = source.history_exhausted;
target.credentials_checked = source.credentials_checked;
target.path = std::move(source.path);
target.name = std::move(source.name);
target.branch = std::move(source.branch);
target.local_branches = std::move(source.local_branches);
target.remote_branches = std::move(source.remote_branches);
target.local_branch_divergence = std::move(source.local_branch_divergence);
target.remote_branch_divergence = std::move(source.remote_branch_divergence);
target.remotes = std::move(source.remotes);
target.worktrees = std::move(source.worktrees);
target.worktree_branches = std::move(source.worktree_branches);
target.submodules = std::move(source.submodules);
target.submodule_statuses = std::move(source.submodule_statuses);
target.commits = std::move(source.commits);
target.working_files = std::move(source.working_files);
target.selected_commit = source.selected_commit;
target.scroll_to_commit = source.scroll_to_commit;
target.last_background_refresh = source.last_background_refresh;
target.pending_background_refresh = source.pending_background_refresh;
target.undo_action = std::move(source.undo_action);
target.redo_action = std::move(source.redo_action);
}
std::string selected_commit_hash(const RepositoryView& repository) {
if (repository.selected_commit < 0 || repository.selected_commit >= static_cast<int>(repository.commits.size()))
return {};
return oid_string(repository.commits[static_cast<size_t>(repository.selected_commit)].oid);
}
void restore_selected_commit(RepositoryView& repository, const std::string& hash) {
if (hash.empty()) return;
git_oid target{};
if (git_oid_fromstr(&target, hash.c_str()) != 0) return;
for (size_t index = 0; index < repository.commits.size(); ++index) {
if (git_oid_equal(&repository.commits[index].oid, &target) != 0) {
repository.selected_commit = static_cast<int>(index);
return;
}
}
}
RepositoryView* find_repository_tab(RepositoryView* tab) {
const auto iterator = std::find_if(g_tabs.begin(), g_tabs.end(), [tab](const auto& item) {
return item.get() == tab;
});
return iterator == g_tabs.end() ? nullptr : iterator->get();
}
void mark_repository_refreshed(RepositoryView& repository) {
repository.last_background_refresh = RefreshClock::now();
repository.pending_background_refresh = false;
}
bool reload_repository_background(RepositoryView& repository, bool surface_errors = false) {
if (!g_git_manager || !repository.repo || repository.path.empty()) return false;
std::string error;
repository.pending_background_refresh = false;
repository.last_background_refresh = RefreshClock::now();
if (!g_git_manager->reload(repository, error)) {
if (surface_errors && !error.empty()) g_notice = error;
GitAsyncResult execute_git_async_request(const GitAsyncRequest& request) {
GitAsyncResult result;
result.operation = request.operation;
result.tab = request.tab;
result.repo_path = request.repo_path;
result.focus_commit = request.focus_commit;
result.preserve_selected_hash = request.preserve_selected_hash;
result.surface_errors = request.surface_errors;
GitManager manager;
RepositoryView repository;
if (!manager.openRepository(repository, request.repo_path, result.notice)) return result;
bool action_ok = true;
switch (request.operation) {
case GitAsyncOperation::reload:
break;
case GitAsyncOperation::capture: {
std::string output;
action_ok = manager.captureGit(repository, request.arguments, output, result.notice) &&
manager.reload(repository, result.notice);
if (action_ok) result.notice = request.success_notice;
break;
}
case GitAsyncOperation::pull:
action_ok = manager.pull(repository, request.pull_mode, result.notice);
break;
case GitAsyncOperation::push:
action_ok = manager.push(repository, result.notice);
break;
case GitAsyncOperation::checkout_branch:
action_ok = manager.checkoutBranch(repository, request.branch, result.notice);
break;
case GitAsyncOperation::push_branch:
action_ok = manager.pushBranch(repository, request.branch, result.notice);
break;
case GitAsyncOperation::fetch:
action_ok = manager.fetch(repository, request.remote, result.notice);
break;
case GitAsyncOperation::stash:
action_ok = manager.stash(repository, result.notice);
break;
case GitAsyncOperation::pop_stash:
action_ok = manager.popStash(repository, result.notice);
break;
}
if (!action_ok) return result;
auto snapshot = std::make_unique<RepositoryView>();
transfer_repository_state(repository, *snapshot);
result.snapshot = std::move(snapshot);
result.success = true;
return result;
}
void apply_git_async_result(GitAsyncResult result) {
if (result.operation == GitAsyncOperation::pull || result.operation == GitAsyncOperation::push)
g_running_toolbar_action = ToolbarActionRequest::none;
if (result.operation == GitAsyncOperation::checkout_branch)
g_running_branch_checkout.clear();
RepositoryView* target = find_repository_tab(result.tab);
if (!target || target->path != result.repo_path) {
if ((result.success || result.surface_errors) && !result.notice.empty()) g_notice = result.notice;
return;
}
if (result.success && result.snapshot) {
transfer_repository_state(*result.snapshot, *target);
if (!result.focus_commit.empty()) {
if (target == &repo()) g_pending_commit_jump = result.focus_commit;
else restore_selected_commit(*target, result.focus_commit);
} else {
restore_selected_commit(*target, result.preserve_selected_hash);
}
mark_repository_refreshed(*target);
}
if ((result.success || result.surface_errors) && !result.notice.empty()) g_notice = result.notice;
}
bool git_async_busy() {
return g_git_async_future.valid() &&
g_git_async_future.wait_for(std::chrono::seconds(0)) != std::future_status::ready;
}
void poll_git_async_result() {
if (!g_git_async_future.valid()) return;
if (g_git_async_future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return;
apply_git_async_result(g_git_async_future.get());
}
bool start_git_async_request(GitAsyncRequest request, const char* busy_notice = "Git operation already running") {
poll_git_async_result();
if (git_async_busy()) {
if (busy_notice && *busy_notice) g_notice = busy_notice;
return false;
}
if (!request.tab || request.repo_path.empty()) return false;
g_git_async_future = std::async(std::launch::async, [request = std::move(request)]() {
return execute_git_async_request(request);
});
return true;
}
bool reload_repository_background(RepositoryView& repository, bool surface_errors = false) {
if (!g_git_manager || !repository.repo || repository.path.empty()) return false;
GitAsyncRequest request;
request.operation = GitAsyncOperation::reload;
request.tab = &repository;
request.repo_path = repository.path;
request.surface_errors = surface_errors;
request.preserve_selected_hash = selected_commit_hash(repository);
const bool started = start_git_async_request(std::move(request), surface_errors
? "A Git operation is already running"
: "");
if (started) {
repository.pending_background_refresh = false;
repository.last_background_refresh = RefreshClock::now();
}
return started;
}
void process_repository_background_refreshes() {
poll_git_async_result();
if (!g_git_manager || g_tabs.empty()) return;
if (git_async_busy()) return;
if (g_running_toolbar_action != ToolbarActionRequest::none) return;
if (!g_running_branch_checkout.empty() || !g_requested_branch_checkout.empty()) return;
const RefreshClock::time_point now = RefreshClock::now();
@@ -216,19 +416,24 @@ void process_repository_background_refreshes() {
const bool due = repository.pending_background_refresh ||
repository.last_background_refresh.time_since_epoch().count() == 0 ||
now - repository.last_background_refresh >= interval;
if (due) reload_repository_background(repository, index == g_active_tab);
if (due) {
reload_repository_background(repository, index == g_active_tab);
break;
}
}
}
bool run_repository_action(const std::vector<std::string>& arguments, const std::string& success_notice,
const std::string& focus_commit = {}) {
std::string output;
if (!g_git_manager->captureGit(repo(), arguments, output, g_notice)) return false;
if (!g_git_manager->reload(repo(), g_notice)) return false;
mark_repository_refreshed(repo());
if (!focus_commit.empty()) g_pending_commit_jump = focus_commit;
g_notice = success_notice;
return true;
GitAsyncRequest request;
request.operation = GitAsyncOperation::capture;
request.tab = &repo();
request.repo_path = repo().path;
request.arguments = arguments;
request.success_notice = success_notice;
request.focus_commit = focus_commit;
request.preserve_selected_hash = selected_commit_hash(repo());
return start_git_async_request(std::move(request));
}
void mark_action_refresh(RepositoryView& repository) {
@@ -236,6 +441,61 @@ void mark_action_refresh(RepositoryView& repository) {
repository.pending_background_refresh = true;
}
bool start_branch_checkout_async(RepositoryView& repository, const std::string& branch) {
GitAsyncRequest request;
request.operation = GitAsyncOperation::checkout_branch;
request.tab = &repository;
request.repo_path = repository.path;
request.branch = branch;
return start_git_async_request(std::move(request));
}
bool start_push_branch_async(RepositoryView& repository, const std::string& branch) {
GitAsyncRequest request;
request.operation = GitAsyncOperation::push_branch;
request.tab = &repository;
request.repo_path = repository.path;
request.branch = branch;
request.preserve_selected_hash = selected_commit_hash(repository);
return start_git_async_request(std::move(request));
}
bool start_fetch_async(RepositoryView& repository, const std::string& remote, bool surface_errors = true) {
GitAsyncRequest request;
request.operation = GitAsyncOperation::fetch;
request.tab = &repository;
request.repo_path = repository.path;
request.remote = remote;
request.surface_errors = surface_errors;
request.preserve_selected_hash = selected_commit_hash(repository);
return start_git_async_request(std::move(request), "A Git operation is already running");
}
bool start_toolbar_action_async(RepositoryView& repository, ToolbarActionRequest action) {
GitAsyncRequest request;
request.tab = &repository;
request.repo_path = repository.path;
request.preserve_selected_hash = selected_commit_hash(repository);
if (action == ToolbarActionRequest::pull) {
request.operation = GitAsyncOperation::pull;
request.pull_mode = g_user_data->pullMode();
} else if (action == ToolbarActionRequest::push) {
request.operation = GitAsyncOperation::push;
} else {
return false;
}
return start_git_async_request(std::move(request));
}
bool start_stash_async(RepositoryView& repository, bool pop) {
GitAsyncRequest request;
request.operation = pop ? GitAsyncOperation::pop_stash : GitAsyncOperation::stash;
request.tab = &repository;
request.repo_path = repository.path;
request.preserve_selected_hash = selected_commit_hash(repository);
return start_git_async_request(std::move(request));
}
void draw_dotted_bezier(ImDrawList* draw, const ImVec2& start, const ImVec2& control_start,
const ImVec2& control_end, const ImVec2& end, ImU32 color, float radius, float spacing) {
const float estimated_length = std::hypot(end.x - start.x, end.y - start.y) +
@@ -288,6 +548,12 @@ void cancel_inline_branch() {
g_focus_inline_branch = false;
}
void cancel_merge_branch() {
g_merge_branch_source.clear();
g_merge_branch_target.clear();
g_merge_branch_popup = false;
}
void begin_inline_branch(int commit_index) {
if (commit_index < 0 || commit_index >= static_cast<int>(repo().commits.size())) {
g_notice = "Create an initial commit before creating a branch";
@@ -314,6 +580,13 @@ void clear_sidebar_filter() {
g_filter.fill('\0');
}
void request_branch_merge(const std::string& source_branch, const std::string& target_branch) {
if (source_branch.empty() || target_branch.empty() || source_branch == target_branch) return;
g_merge_branch_source = source_branch;
g_merge_branch_target = target_branch;
g_merge_branch_popup = true;
}
void request_branch_checkout(const std::string& branch) {
g_requested_branch_checkout = branch;
}
@@ -336,6 +609,7 @@ void reset_repository_view() {
g_expanded_refs_repository = nullptr;
g_expanded_refs_commit = -1;
cancel_inline_branch();
cancel_merge_branch();
g_requested_branch_checkout.clear();
g_running_branch_checkout.clear();
g_reset_repository_view = true;
@@ -362,6 +636,7 @@ void close_tab(size_t index) {
if (index >= g_tabs.size()) return;
const bool closing_active_tab = index == g_active_tab;
if (g_inline_branch_repository == g_tabs[index].get()) cancel_inline_branch();
cancel_merge_branch();
if (g_user_data && !g_tabs[index]->path.empty()) g_user_data->addRecentlyClosed(g_tabs[index]->path);
g_tabs.erase(g_tabs.begin() + static_cast<std::ptrdiff_t>(index));
if (g_tabs.empty()) create_new_tab();
@@ -843,21 +1118,45 @@ void branch_context(const std::string& branch, bool remote) {
request_branch_checkout(branch);
clear_sidebar_filter();
}
if (!remote && ImGui::MenuItem(ICON_TB_ARROW_RIGHT_ARROW_LEFT " Merge into current branch")) {
request_branch_merge(branch, repo().branch);
clear_sidebar_filter();
}
if (!remote && ImGui::MenuItem(ICON_TB_UPLOAD " Push branch")) {
if (g_git_manager->pushBranch(repo(), branch, g_notice))
mark_action_refresh(repo());
start_push_branch_async(repo(), branch);
clear_sidebar_filter();
}
if (remote && ImGui::MenuItem(ICON_TB_DOWNLOAD " Fetch")) {
const size_t slash = branch.find('/');
if (g_git_manager->fetch(repo(), slash == std::string::npos ? branch : branch.substr(0, slash), g_notice))
mark_action_refresh(repo());
start_fetch_async(repo(), slash == std::string::npos ? branch : branch.substr(0, slash));
}
ImGui::Separator();
if (ImGui::MenuItem(ICON_TB_COPY " Copy branch name")) copy_to_clipboard(branch, "branch name");
ImGui::EndPopup();
}
void branch_drag_source(const std::string& branch, bool remote) {
if (!ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) return;
ImGui::SetDragDropPayload("GITREE_BRANCH", branch.c_str(), branch.size() + 1);
ImGui::TextUnformatted(remote ? ICON_TB_CLOUD " Remote branch" : ICON_TB_CODE_BRANCH " Local branch");
ImGui::TextDisabled("%s", branch.c_str());
ImGui::EndDragDropSource();
}
void branch_drag_target(const std::string& branch) {
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) &&
ImGui::GetDragDropPayload() != nullptr) {
ImGui::SetTooltip("%s Drop to merge into %s", ICON_TB_ARROW_RIGHT_ARROW_LEFT, branch.c_str());
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
if (!ImGui::BeginDragDropTarget()) return;
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("GITREE_BRANCH")) {
const char* source = static_cast<const char*>(payload->Data);
if (source && source[0] != '\0') request_branch_merge(source, branch);
}
ImGui::EndDragDropTarget();
}
std::string branch_divergence_text(const std::string& branch, bool remote) {
const auto& divergences = remote
? repo().remote_branch_divergence
@@ -893,6 +1192,8 @@ void draw_branch_nodes(const BranchNode& parent, const char* branch_icon, const
sidebar_item_row(branch_icon, name, "branch" + id,
nullptr, 0, branch_icon_color, divergence,
!remote && node.full_name == repo().branch);
branch_drag_source(node.full_name, remote);
if (!remote) branch_drag_target(node.full_name);
branch_context(node.full_name, remote);
}
draw_branch_nodes(node, branch_icon, root_group_icon, remote, id, depth + 1);
@@ -902,6 +1203,8 @@ void draw_branch_nodes(const BranchNode& parent, const char* branch_icon, const
const std::string divergence = branch_divergence_text(node.full_name, remote);
sidebar_item_row(branch_icon, name, id, nullptr, 0, branch_icon_color, divergence,
!remote && node.full_name == repo().branch);
branch_drag_source(node.full_name, remote);
if (!remote) branch_drag_target(node.full_name);
branch_context(node.full_name, remote);
}
}
@@ -2692,6 +2995,45 @@ void draw_licenses_popup() {
void draw_git_action_popups() {
static bool checkout_new_branch = true;
if (g_merge_branch_popup) {
g_merge_branch_popup = false;
ImGui::OpenPopup("Merge branches");
}
if (ImGui::BeginPopupModal("Merge branches", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::TextUnformatted(ICON_TB_ARROW_RIGHT_ARROW_LEFT " Merge branches");
ImGui::Separator();
ImGui::Spacing();
ImGui::TextUnformatted(ICON_TB_CODE_BRANCH " Source branch");
ImGui::TextDisabled("%s", g_merge_branch_source.c_str());
ImGui::TextUnformatted(ICON_TB_CODE_BRANCH " Target branch");
ImGui::TextDisabled("%s", g_merge_branch_target.c_str());
if (g_merge_branch_target != repo().branch)
ImGui::TextWrapped("%s The target branch will be checked out first, then the merge will run on that branch.",
ICON_TB_TRIANGLE_EXCLAMATION);
else
ImGui::TextWrapped("This will merge the source branch into the currently checked out branch.");
ImGui::Spacing();
ImGui::BeginDisabled(g_merge_branch_source.empty() || g_merge_branch_target.empty() ||
g_merge_branch_source == g_merge_branch_target);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.22f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.42f, 0.27f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.20f, 0.48f, 0.30f, 1.0f));
if (ImGui::Button(ICON_TB_ARROW_RIGHT_ARROW_LEFT " Merge branch", {ui(150.0f), 0}) &&
g_git_manager->mergeBranch(repo(), g_merge_branch_source, g_merge_branch_target, g_notice)) {
mark_action_refresh(repo());
cancel_merge_branch();
ImGui::CloseCurrentPopup();
}
ImGui::PopStyleColor(3);
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button(ICON_TB_XMARK " Cancel", {ui(90.0f), 0})) {
cancel_merge_branch();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
if (g_branch_create_popup) {
g_git_name.fill('\0');
checkout_new_branch = true;
@@ -3184,9 +3526,8 @@ void draw_app() {
}
if (ImGui::BeginMenu("Edit")) { ImGui::MenuItem("Preferences", nullptr, false, false); ImGui::EndMenu(); }
if (ImGui::BeginMenu("View")) {
if ((ImGui::MenuItem("Refresh", "F5") || refresh_requested) &&
g_git_manager->reload(repo(), g_notice))
mark_repository_refreshed(repo());
if (ImGui::MenuItem("Refresh", "F5") || refresh_requested)
reload_repository_background(repo(), true);
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Help")) {
@@ -3436,13 +3777,11 @@ void draw_app() {
}
ImGui::SameLine(0, action_spacing);
if (toolbar_action("stash", "Stash", ICON_TB_BOX_ARCHIVE, "Stash changes", true, false, 58)) {
if (g_git_manager->stash(repo(), g_notice))
mark_action_refresh(repo());
start_stash_async(repo(), false);
}
ImGui::SameLine(0, action_spacing);
if (toolbar_action("pop", "Pop", ICON_TB_BOX_OPEN, "Pop latest stash", true, false, 54)) {
if (g_git_manager->popStash(repo(), g_notice))
mark_action_refresh(repo());
start_stash_async(repo(), true);
}
draw_open_in_button();
ImGui::EndChild();
@@ -3504,20 +3843,13 @@ void draw_app() {
draw_popups();
ImGui::End();
if (toolbar_action_to_execute == ToolbarActionRequest::pull) {
if (g_git_manager->pull(repo(), g_user_data->pullMode(), g_notice))
mark_action_refresh(repo());
} else if (toolbar_action_to_execute == ToolbarActionRequest::push) {
if (g_git_manager->push(repo(), g_notice))
mark_action_refresh(repo());
}
if (toolbar_action_to_execute != ToolbarActionRequest::none)
g_running_toolbar_action = ToolbarActionRequest::none;
if (toolbar_action_to_execute != ToolbarActionRequest::none &&
start_toolbar_action_async(repo(), toolbar_action_to_execute))
g_running_toolbar_action = toolbar_action_to_execute;
if (!branch_checkout_to_execute.empty()) {
if (g_git_manager->checkoutBranch(repo(), branch_checkout_to_execute, g_notice))
mark_action_refresh(repo());
g_running_branch_checkout.clear();
if (!g_running_branch_checkout.empty() && start_branch_checkout_async(repo(), branch_checkout_to_execute))
g_running_branch_checkout = branch_checkout_to_execute;
}
}