From 6361002f5392eadfd7d18c3005be6dea3ea5093a Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Fri, 19 Jun 2026 00:15:50 -0500 Subject: [PATCH] perf(git): move refresh and actions off the UI thread --- src/ui/gitree_ui.cpp | 408 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 370 insertions(+), 38 deletions(-) diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index 7afaba6..3f43539 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,9 @@ std::array 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 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 snapshot; +}; + +std::future 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(repository.commits.size())) + return {}; + return oid_string(repository.commits[static_cast(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(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(); + 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& 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(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(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(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; } }