From df6212ab49ca030de7267996d15452f2307d23a3 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Thu, 18 Jun 2026 18:47:48 -0500 Subject: [PATCH] feat(details): add commit and working tree panels --- .gitignore | 1 + src/managers/git_manager.cpp | 34 ++++++ src/managers/git_manager.h | 1 + src/models/repository.h | 7 ++ src/ui/gitree_ui.cpp | 208 ++++++++++++++++++++++++++++------- 5 files changed, 209 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index 6c31dbc..2e541e1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ # Build output and editor state /build/ +/build-*/ /.vs/ /.vscode/ /imgui.ini diff --git a/src/managers/git_manager.cpp b/src/managers/git_manager.cpp index c766e07..df05020 100644 --- a/src/managers/git_manager.cpp +++ b/src/managers/git_manager.cpp @@ -145,10 +145,44 @@ void GitManager::computeGraphLanes(RepositoryView& repository) { } } +void GitManager::loadWorkingTree(RepositoryView& repository) { + repository.working_files.clear(); + git_status_options options{}; + git_status_options_init(&options, GIT_STATUS_OPTIONS_VERSION); + options.show = GIT_STATUS_SHOW_INDEX_AND_WORKDIR; + options.flags = GIT_STATUS_OPT_INCLUDE_UNTRACKED | GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS | + GIT_STATUS_OPT_RENAMES_HEAD_TO_INDEX | GIT_STATUS_OPT_RENAMES_INDEX_TO_WORKDIR; + git_status_list* list = nullptr; + if (git_status_list_new(&list, repository.repo, &options) != 0) return; + + auto add_file = [&repository](const char* path, FileChangeKind kind, bool staged) { + if (path) repository.working_files.push_back({path, kind, staged}); + }; + for (size_t index = 0; index < git_status_list_entrycount(list); ++index) { + const git_status_entry* entry = git_status_byindex(list, index); + if (!entry) continue; + const char* index_path = entry->head_to_index && entry->head_to_index->new_file.path + ? entry->head_to_index->new_file.path : nullptr; + const char* worktree_path = entry->index_to_workdir && entry->index_to_workdir->new_file.path + ? entry->index_to_workdir->new_file.path : nullptr; + if (entry->status & GIT_STATUS_INDEX_NEW) add_file(index_path, FileChangeKind::added, true); + else if (entry->status & GIT_STATUS_INDEX_DELETED) add_file(index_path, FileChangeKind::deleted, true); + else if (entry->status & GIT_STATUS_INDEX_RENAMED) add_file(index_path, FileChangeKind::renamed, true); + else if (entry->status & GIT_STATUS_INDEX_MODIFIED) add_file(index_path, FileChangeKind::modified, true); + + if (entry->status & GIT_STATUS_WT_NEW) add_file(worktree_path, FileChangeKind::added, false); + else if (entry->status & GIT_STATUS_WT_DELETED) add_file(worktree_path, FileChangeKind::deleted, false); + else if (entry->status & GIT_STATUS_WT_RENAMED) add_file(worktree_path, FileChangeKind::renamed, false); + else if (entry->status & GIT_STATUS_WT_MODIFIED) add_file(worktree_path, FileChangeKind::modified, false); + } + git_status_list_free(list); +} + bool GitManager::loadRepositoryData(RepositoryView& repository, std::string& error) { repository.local_branches.clear(); repository.remote_branches.clear(); repository.remotes.clear(); repository.worktrees.clear(); repository.worktree_branches.clear(); repository.submodules.clear(); repository.commits.clear(); repository.selected_commit = 0; + loadWorkingTree(repository); const char* workdir = git_repository_workdir(repository.repo); repository.path = workdir ? workdir : git_repository_path(repository.repo); std::filesystem::path path(repository.path); diff --git a/src/managers/git_manager.h b/src/managers/git_manager.h index 0d92fc8..b0a3916 100644 --- a/src/managers/git_manager.h +++ b/src/managers/git_manager.h @@ -23,4 +23,5 @@ private: void loadRefBadges(RepositoryView& repository); void computeGraphLanes(RepositoryView& repository); bool loadRepositoryData(RepositoryView& repository, std::string& error); + void loadWorkingTree(RepositoryView& repository); }; diff --git a/src/models/repository.h b/src/models/repository.h index f9e53f9..5fc6893 100644 --- a/src/models/repository.h +++ b/src/models/repository.h @@ -22,6 +22,12 @@ struct ChangedFile { FileChangeKind kind = FileChangeKind::other; }; +struct WorkingFile { + std::string path; + FileChangeKind kind = FileChangeKind::other; + bool staged = false; +}; + struct CommitInfo { git_oid oid{}; std::string short_id; @@ -49,6 +55,7 @@ struct RepositoryView { std::set worktree_branches; std::vector submodules; std::vector commits; + std::vector working_files; int selected_commit = 0; RepositoryView() = default; diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index f2003d0..d2281eb 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -37,6 +37,11 @@ std::string g_notice; bool g_init_popup = false; bool g_about_popup = false; bool g_licenses_popup = false; +enum class FileViewMode { path, tree }; +FileViewMode g_file_view_mode = FileViewMode::path; +bool g_view_all_files = false; +std::array g_commit_summary{}; +std::array g_commit_description{}; float g_ui_scale = 1.0f; float g_sidebar_width = 230.0f; WindowManager* g_window_manager = nullptr; @@ -354,6 +359,24 @@ void draw_commit_table() { ImGui::TableSetupColumn("COMMIT MESSAGE", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("COMMIT DATE / TIME", ImGuiTableColumnFlags_WidthFixed, ui(180.0f)); ImGui::TableHeadersRow(); + if (!repo().working_files.empty()) { + ImGui::TableNextRow(0, ui(31.0f)); + ImGui::TableSetColumnIndex(0); + if (ImGui::Selectable("##working_tree", repo().selected_commit == -1, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) + repo().selected_commit = -1; + ImGui::TableSetColumnIndex(1); + const ImVec2 position = ImGui::GetCursorScreenPos(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + const ImVec2 center{position.x + ui(15), position.y + ui(15.5f)}; + draw->AddCircle(center, ui(7), IM_COL32(90, 180, 195, 210), 12, ui(1.5f)); + ImGui::Dummy({0, ui(31)}); + ImGui::TableSetColumnIndex(2); + ImGui::TextDisabled("// WIP"); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN " %d", + static_cast(repo().working_files.size())); + } for (int i = 0; i < static_cast(repo().commits.size()); ++i) { const auto& commit = repo().commits[i]; if (g_filter[0]) { @@ -396,26 +419,138 @@ void draw_commit_table() { ImGui::EndTable(); } -void draw_details() { - ImGui::BeginChild("details", {ui(360.0f), -ui(28.0f)}, ImGuiChildFlags_Borders); - if (repo().commits.empty()) { - ImGui::TextDisabled("Select a repository with commits"); - ImGui::EndChild(); +const char* change_icon(FileChangeKind kind) { + if (kind == FileChangeKind::added) return ICON_FA_PLUS; + if (kind == FileChangeKind::deleted) return ICON_FA_MINUS; + if (kind == FileChangeKind::renamed) return ICON_FA_ARROW_RIGHT_ARROW_LEFT; + return ICON_FA_PEN; +} + +ImVec4 change_color(FileChangeKind kind) { + if (kind == FileChangeKind::added) return {0.31f, 0.82f, 0.43f, 1}; + if (kind == FileChangeKind::deleted) return {0.91f, 0.35f, 0.35f, 1}; + if (kind == FileChangeKind::renamed) return {0.38f, 0.67f, 0.92f, 1}; + return {0.94f, 0.66f, 0.25f, 1}; +} + +void draw_file_row(const std::string& path, FileChangeKind kind, int id) { + ImGui::PushID(id); + ImGui::PushStyleColor(ImGuiCol_Text, change_color(kind)); + ImGui::Selectable((std::string(change_icon(kind)) + " " + path).c_str()); + ImGui::PopStyleColor(); + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem(ICON_FA_COPY " Copy path")) ImGui::SetClipboardText(path.c_str()); + ImGui::EndPopup(); + } + ImGui::PopID(); +} + +void draw_file_toolbar() { + ImGui::TextDisabled(ICON_FA_ARROW_DOWN_A_Z); + const float controls_width = ui(225.0f); + ImGui::SameLine(std::max(ImGui::GetCursorPosX() + ui(8), ImGui::GetWindowWidth() - controls_width)); + const bool path_selected = g_file_view_mode == FileViewMode::path; + if (path_selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.28f, 0.46f, 1)); + if (ImGui::Button(ICON_FA_BARS " Path")) g_file_view_mode = FileViewMode::path; + if (path_selected) ImGui::PopStyleColor(); + ImGui::SameLine(0, 0); + const bool tree_selected = g_file_view_mode == FileViewMode::tree; + if (tree_selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.28f, 0.46f, 1)); + if (ImGui::Button(ICON_FA_FOLDER_TREE " Tree")) g_file_view_mode = FileViewMode::tree; + if (tree_selected) ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::Checkbox("View all files", &g_view_all_files); +} + +template +void draw_files(const std::vector& files, bool staged_filter = false, bool filter_by_stage = false) { + if (g_file_view_mode == FileViewMode::path) { + int id = 0; + for (const auto& file : files) { + if constexpr (requires { file.staged; }) if (filter_by_stage && file.staged != staged_filter) continue; + draw_file_row(file.path, file.kind, id++); + } return; } + std::map> groups; + for (const auto& file : files) { + if constexpr (requires { file.staged; }) if (filter_by_stage && file.staged != staged_filter) continue; + const size_t slash = file.path.find('/'); + const std::string group = slash == std::string::npos ? "Files" : file.path.substr(0, slash); + groups[group].push_back(&file); + } + int id = 0; + for (const auto& [group, entries] : groups) { + if (ImGui::TreeNodeEx((std::string(ICON_FA_FOLDER) + " " + group).c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + for (const File* file : entries) { + const size_t slash = file->path.find('/'); + draw_file_row(slash == std::string::npos ? file->path : file->path.substr(slash + 1), file->kind, id++); + } + ImGui::TreePop(); + } + } +} + +void draw_working_details() { + int staged = 0; + for (const auto& file : repo().working_files) if (file.staged) ++staged; + const int unstaged = static_cast(repo().working_files.size()) - staged; + ImGui::SetCursorPosX(std::max(ui(8), (ImGui::GetWindowWidth() - ui(210)) * 0.5f)); + ImGui::Text("%d file changes on", static_cast(repo().working_files.size())); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.25f, 0.80f, 0.86f, 1), "%s", repo().branch.c_str()); + ImGui::Separator(); + draw_file_toolbar(); + ImGui::Separator(); + ImGui::BeginChild("working_files", {-1, ui(260)}, false); + if (ImGui::TreeNodeEx((std::string("Unstaged Files (") + std::to_string(unstaged) + ")").c_str(), + ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_SpanAvailWidth)) { + ImGui::SameLine(std::max(ui(150), ImGui::GetWindowWidth() - ui(135))); + if (ImGui::SmallButton("Stage All Changes")) g_notice = "Staging is not wired yet"; + draw_files(repo().working_files, false, true); + ImGui::TreePop(); + } + if (ImGui::TreeNodeEx((std::string("Staged Files (") + std::to_string(staged) + ")").c_str(), + ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_SpanAvailWidth)) { + draw_files(repo().working_files, true, true); + ImGui::TreePop(); + } + ImGui::EndChild(); + ImGui::Separator(); + ImGui::TextUnformatted(ICON_FA_CIRCLE_DOT " Commit"); + ImGui::Separator(); + static bool amend = false; + ImGui::Checkbox("Amend previous commit", &amend); + ImGui::InputTextWithHint("##commit_summary", "Commit summary", g_commit_summary.data(), g_commit_summary.size()); + ImGui::InputTextMultiline("##commit_description", g_commit_description.data(), g_commit_description.size(), + {-1, ui(115)}); + ImGui::BeginDisabled(staged == 0 || g_commit_summary[0] == '\0'); + if (ImGui::Button("Stage Changes to Commit", {-1, ui(40)})) g_notice = "Commit creation is not wired yet"; + ImGui::EndDisabled(); +} + +void draw_commit_details() { const int selected = std::clamp(repo().selected_commit, 0, static_cast(repo().commits.size() - 1)); g_git_manager->loadCommitChanges(repo(), selected, g_notice); const auto& commit = repo().commits[selected]; - ImGui::TextDisabled("COMMIT"); + ImGui::TextDisabled("commit:"); ImGui::SameLine(); ImGui::Text("%s", commit.short_id.c_str()); ImGui::Separator(); - ImGui::Dummy({0, 6}); + ImGui::BeginChild("message_card", {-1, ui(125)}, ImGuiChildFlags_Borders); ImGui::PushTextWrapPos(); ImGui::Text("%s", commit.summary.c_str()); ImGui::PopTextWrapPos(); - ImGui::Dummy({0, 26}); - ImGui::Separator(); + ImGui::EndChild(); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ui(80)) * 0.5f); + const ImVec2 handle = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddLine( + handle, + {handle.x + ui(80), handle.y}, + IM_COL32(72, 80, 94, 255), + ui(3)); + ImGui::Dummy({ui(80), ui(6)}); + const unsigned int author_texture = g_avatar_cache ? g_avatar_cache->textureFor(commit.email) : 0; if (author_texture) { ImGui::Image(ImTextureRef(static_cast(author_texture)), {ui(40), ui(40)}); @@ -423,48 +558,37 @@ void draw_details() { } ImGui::BeginGroup(); ImGui::TextColored(ImVec4(0.25f, 0.80f, 0.86f, 1), "%s", commit.author.c_str()); - ImGui::TextDisabled("%s", commit.email.c_str()); ImGui::TextDisabled("authored %s", commit.date.c_str()); ImGui::EndGroup(); - ImGui::Dummy({0, 24}); - ImGui::TextColored(ImVec4(0.95f, 0.68f, 0.22f, 1), "%d parent%s", commit.parents, commit.parents == 1 ? "" : "s"); - ImGui::Separator(); - int added = 0; - int modified = 0; - int deleted = 0; + if (!commit.parent_ids.empty()) { + char parent[GIT_OID_SHA1_HEXSIZE + 1]{}; + git_oid_tostr(parent, sizeof(parent), &commit.parent_ids.front()); + ImGui::SameLine(std::max(ui(180), ImGui::GetWindowWidth() - ui(95))); + ImGui::TextDisabled("parent: %.7s", parent); + } + ImGui::Dummy({0, ui(14)}); + int added = 0, modified = 0, deleted = 0; for (const auto& file : commit.changed_files) { if (file.kind == FileChangeKind::added) ++added; else if (file.kind == FileChangeKind::deleted) ++deleted; else ++modified; } - if (modified) ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN " %d modified", modified); - if (added) { - if (modified) ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.31f, 0.82f, 0.43f, 1), ICON_FA_PLUS " %d added", added); - } - if (deleted) { - if (modified || added) ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.91f, 0.35f, 0.35f, 1), ICON_FA_MINUS " %d deleted", deleted); - } + if (modified) ImGui::TextColored(change_color(FileChangeKind::modified), ICON_FA_PEN " %d modified", modified); + if (added) { if (modified) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::added), ICON_FA_PLUS " %d added", added); } + if (deleted) { if (modified || added) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::deleted), ICON_FA_MINUS " %d deleted", deleted); } + draw_file_toolbar(); + ImGui::Separator(); + if (g_file_view_mode == FileViewMode::tree) ImGui::TextUnformatted("Collapse All"); ImGui::BeginChild("changed_files", {-1, -1}); - for (int index = 0; index < static_cast(commit.changed_files.size()); ++index) { - const auto& file = commit.changed_files[index]; - const char* icon = file.kind == FileChangeKind::added ? ICON_FA_PLUS - : file.kind == FileChangeKind::deleted ? ICON_FA_MINUS : ICON_FA_PEN; - const ImVec4 color = file.kind == FileChangeKind::added ? ImVec4(0.31f, 0.82f, 0.43f, 1) - : file.kind == FileChangeKind::deleted ? ImVec4(0.91f, 0.35f, 0.35f, 1) - : ImVec4(0.94f, 0.66f, 0.25f, 1); - ImGui::PushID(index); - ImGui::PushStyleColor(ImGuiCol_Text, color); - ImGui::Selectable((std::string(icon) + " " + file.path).c_str()); - ImGui::PopStyleColor(); - if (ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem(ICON_FA_COPY " Copy path")) ImGui::SetClipboardText(file.path.c_str()); - ImGui::EndPopup(); - } - ImGui::PopID(); - } + draw_files(commit.changed_files); ImGui::EndChild(); +} + +void draw_details() { + ImGui::BeginChild("details", {ui(368.0f), -ui(28.0f)}, ImGuiChildFlags_Borders); + if (repo().selected_commit == -1) draw_working_details(); + else if (!repo().commits.empty()) draw_commit_details(); + else ImGui::TextDisabled("Select a repository with commits"); ImGui::EndChild(); }