From 27ecf6c351ff62169ab62f44febd7220564f8cb0 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Thu, 18 Jun 2026 19:42:29 -0500 Subject: [PATCH] feat(diff): add full file diff viewer --- CMakeLists.txt | 2 + src/managers/git_manager.cpp | 56 ++++-- src/managers/git_manager.h | 4 + src/ui/diff_viewer.cpp | 320 +++++++++++++++++++++++++++++++++++ src/ui/diff_viewer.h | 45 +++++ src/ui/gitree_ui.cpp | 9 +- 6 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 src/ui/diff_viewer.cpp create mode 100644 src/ui/diff_viewer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f8b455d..fb6cb64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,8 @@ add_executable(gitree WIN32 src/ui/gitree_ui.h src/ui/graph_renderer.cpp src/ui/graph_renderer.h + src/ui/diff_viewer.cpp + src/ui/diff_viewer.h src/managers/git_manager.cpp src/managers/git_manager.h src/managers/window_manager.cpp diff --git a/src/managers/git_manager.cpp b/src/managers/git_manager.cpp index 7083114..5d44853 100644 --- a/src/managers/git_manager.cpp +++ b/src/managers/git_manager.cpp @@ -1,6 +1,7 @@ #include "managers/git_manager.h" #include +#include #include #include #include @@ -309,13 +310,28 @@ bool GitManager::reload(RepositoryView& repository, std::string& error) { bool GitManager::runGit(RepositoryView& repository, const std::vector& arguments, const char* success_message, std::string& message, bool reload_repository) { - if (!repository.repo) { message = "No repository is open"; return false; } + std::string command_output; + if (!captureGit(repository, arguments, command_output, message)) return false; + if (reload_repository) { + std::string reload_error; + if (!loadRepositoryData(repository, reload_error)) { + message = reload_error; + return false; + } + } + message = success_message; + return true; +} + +bool GitManager::captureGit(RepositoryView& repository, const std::vector& arguments, + std::string& command_output, std::string& error) { + if (!repository.repo) { error = "No repository is open"; return false; } #ifdef _WIN32 wchar_t temporary_directory[MAX_PATH]{}; wchar_t output_path[MAX_PATH]{}; if (!GetTempPathW(MAX_PATH, temporary_directory) || !GetTempFileNameW(temporary_directory, L"gtr", 0, output_path)) { - message = "Unable to create Git command output file"; + error = "Unable to create Git command output file"; return false; } SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; @@ -323,7 +339,7 @@ bool GitManager::runGit(RepositoryView& repository, const std::vector(patch.size())); + } + std::vector arguments{"apply", "--whitespace=nowarn"}; + if (cached) arguments.push_back("--cached"); + if (reverse) arguments.push_back("--reverse"); + arguments.push_back(patch_path.string()); + const bool applied = runGit(repository, arguments, + reverse ? "Hunk reverted" : "Hunk staged", error); + std::error_code remove_error; + std::filesystem::remove(patch_path, remove_error); + return applied; +} + bool GitManager::fetch(RepositoryView& repository, const std::string& remote, std::string& error) { const std::vector args = remote.empty() ? std::vector{"fetch", "--all", "--prune"} diff --git a/src/managers/git_manager.h b/src/managers/git_manager.h index 60a4900..7ed905b 100644 --- a/src/managers/git_manager.h +++ b/src/managers/git_manager.h @@ -40,6 +40,10 @@ public: bool updateSubmodule(RepositoryView& repository, const std::string& name, std::string& error); std::string worktreePath(RepositoryView& repository, const std::string& name, std::string& error); bool loadCommitChanges(RepositoryView& repository, int commit_index, std::string& error); + bool captureGit(RepositoryView& repository, const std::vector& arguments, + std::string& output, std::string& error); + bool applyPatch(RepositoryView& repository, const std::string& patch, bool cached, + bool reverse, std::string& error); private: static std::string lastError(const char* fallback); diff --git a/src/ui/diff_viewer.cpp b/src/ui/diff_viewer.cpp new file mode 100644 index 0000000..346b447 --- /dev/null +++ b/src/ui/diff_viewer.cpp @@ -0,0 +1,320 @@ +#include "ui/diff_viewer.h" + +#include "managers/git_manager.h" +#include "models/repository.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { +float scaled(float value, float scale) { return value * scale; } + +std::vector splitLines(const std::string& text) { + std::vector lines; + std::istringstream stream(text); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + lines.push_back(std::move(line)); + } + return lines; +} + +void parseRange(const std::string& header, char marker, int& line) { + const size_t marker_position = header.find(marker); + if (marker_position == std::string::npos) return; + size_t cursor = marker_position + 1; + line = 0; + while (cursor < header.size() && std::isdigit(static_cast(header[cursor]))) { + line = line * 10 + (header[cursor] - '0'); + ++cursor; + } +} + +ImU32 syntaxColor(const std::string& text) { + const size_t first = text.find_first_not_of(" \t"); + if (first == std::string::npos) return IM_COL32(188, 192, 199, 255); + const std::string_view value(text.c_str() + first, text.size() - first); + if (value.starts_with("//") || value.starts_with("# ")) return IM_COL32(105, 158, 102, 255); + if (value.starts_with('#')) return IM_COL32(187, 134, 204, 255); + static constexpr const char* keywords[] = { + "class ", "struct ", "enum ", "if ", "else", "for ", "while ", "return ", + "const ", "static ", "void ", "bool ", "int ", "float ", "auto ", "namespace " + }; + for (const char* keyword : keywords) + if (value.starts_with(keyword)) return IM_COL32(102, 153, 204, 255); + if (value.find('"') != std::string_view::npos || value.find('\'') != std::string_view::npos) + return IM_COL32(206, 145, 120, 255); + return IM_COL32(198, 201, 207, 255); +} + +bool compactButton(const char* label, bool active = false) { + if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.25f, 0.43f, 1.0f)); + const bool clicked = ImGui::SmallButton(label); + if (active) ImGui::PopStyleColor(); + return clicked; +} +} + +void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std::string& path, + bool staged, std::string& notice) { + path_ = path; + staged_ = staged; + mode_ = Mode::diff; + reload(repository, manager, notice); +} + +void DiffViewer::close() { + path_.clear(); + file_header_.clear(); + hunks_.clear(); + file_lines_.clear(); + blame_lines_.clear(); + history_lines_.clear(); +} + +void DiffViewer::parseDiff(const std::string& text) { + file_header_.clear(); + hunks_.clear(); + Hunk* current = nullptr; + int old_line = 0; + int new_line = 0; + for (const std::string& raw : splitLines(text)) { + if (raw.starts_with("@@")) { + hunks_.push_back({}); + current = &hunks_.back(); + current->header = raw; + parseRange(raw, '-', old_line); + parseRange(raw, '+', new_line); + current->patch = file_header_ + raw + "\n"; + continue; + } + if (!current) { + file_header_ += raw + "\n"; + continue; + } + Line line; + line.raw = raw; + line.text = raw.empty() ? std::string{} : raw.substr(1); + if (!raw.empty() && raw[0] == '+') { + line.kind = LineKind::added; + line.new_number = new_line++; + } else if (!raw.empty() && raw[0] == '-') { + line.kind = LineKind::removed; + line.old_number = old_line++; + } else if (!raw.starts_with("\\ No newline")) { + line.old_number = old_line++; + line.new_number = new_line++; + } + current->lines.push_back(std::move(line)); + current->patch += raw + "\n"; + } +} + +void DiffViewer::reload(RepositoryView& repository, GitManager& manager, std::string& notice) { + std::vector arguments{"diff", "--no-ext-diff", "--no-color", "--unified=3"}; + if (staged_) arguments.push_back("--cached"); + arguments.insert(arguments.end(), {"--", path_}); + std::string output; + std::string error; + if (!manager.captureGit(repository, arguments, output, error)) notice = error; + parseDiff(output); + + if (hunks_.empty()) { + const auto file = std::find_if(repository.working_files.begin(), repository.working_files.end(), + [this](const WorkingFile& item) { return item.path == path_; }); + if (file != repository.working_files.end() && file->kind == FileChangeKind::added && !staged_) { + std::ifstream stream(std::filesystem::path(repository.path) / path_, std::ios::binary); + std::ostringstream contents; + contents << stream.rdbuf(); + const auto lines = splitLines(contents.str()); + file_header_ = "diff --git a/" + path_ + " b/" + path_ + "\nnew file mode 100644\n--- /dev/null\n+++ b/" + path_ + "\n"; + Hunk hunk; + hunk.header = "@@ -0,0 +1," + std::to_string(lines.size()) + " @@"; + hunk.patch = file_header_ + hunk.header + "\n"; + int number = 1; + for (const auto& text : lines) { + hunk.lines.push_back({0, number++, LineKind::added, text, "+" + text}); + hunk.patch += "+" + text + "\n"; + } + hunks_.push_back(std::move(hunk)); + } + } + file_lines_.clear(); + blame_lines_.clear(); + history_lines_.clear(); + if (mode_ != Mode::diff) loadSupplement(repository, manager, mode_, notice); +} + +void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager, Mode mode, + std::string& notice) { + std::string output; + std::string error; + if (mode == Mode::file) { + if (staged_) { + if (!manager.captureGit(repository, {"show", ":" + path_}, output, error)) notice = error; + } else { + std::ifstream stream(std::filesystem::path(repository.path) / path_, std::ios::binary); + std::ostringstream contents; + contents << stream.rdbuf(); + output = contents.str(); + } + file_lines_ = splitLines(output); + } else if (mode == Mode::blame) { + if (!manager.captureGit(repository, {"blame", "--date=short", "--", path_}, output, error)) notice = error; + blame_lines_ = splitLines(output); + } else if (mode == Mode::history) { + if (!manager.captureGit(repository, + {"log", "--follow", "--date=short", "--pretty=format:%h %ad %an %s", "--", path_}, + output, error)) notice = error; + history_lines_ = splitLines(output); + } +} + +void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {scaled(8, scale), scaled(5, scale)}); + ImGui::BeginChild("diff_viewer", {-1, -1}, ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN); + ImGui::SameLine(0, scaled(7, scale)); + ImGui::TextUnformatted(path_.c_str()); + + ImGui::SameLine(std::max(scaled(240, scale), ImGui::GetWindowWidth() - scaled(455, scale))); + if (compactButton(staged_ ? "Staged" : "Unstaged", true)) {} + ImGui::SameLine(); + if (compactButton("File View", mode_ == Mode::file)) { + mode_ = Mode::file; loadSupplement(repository, manager, mode_, notice); + } + ImGui::SameLine(); + if (compactButton("Diff View", mode_ == Mode::diff)) mode_ = Mode::diff; + ImGui::SameLine(0, scaled(28, scale)); + if (compactButton("Blame", mode_ == Mode::blame)) { + mode_ = Mode::blame; loadSupplement(repository, manager, mode_, notice); + } + ImGui::SameLine(); + if (compactButton("History", mode_ == Mode::history)) { + mode_ = Mode::history; loadSupplement(repository, manager, mode_, notice); + } + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.43f, 0.90f, 0.51f, 1)); + if (compactButton(staged_ ? "Unstage File" : "Stage File")) { + const bool changed = staged_ ? manager.unstageFile(repository, path_, notice) + : manager.stageFile(repository, path_, notice); + if (changed) { staged_ = !staged_; reload(repository, manager, notice); } + } + ImGui::PopStyleColor(); + ImGui::SameLine(); + if (compactButton(ICON_FA_XMARK)) close(); + ImGui::Separator(); + if (!path_.empty()) { + if (compactButton(ICON_FA_PEN " Edit This File")) { + std::string error; + if (!izo::open_path(std::filesystem::path(repository.path) / path_, &error)) notice = error; + } + ImGui::SameLine(ImGui::GetWindowWidth() - scaled(116, scale)); + ImGui::TextDisabled("UTF-8"); + if (!staged_) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1)); + if (compactButton(ICON_FA_TRASH_CAN)) { + if (manager.discardFile(repository, path_, notice)) close(); + } + ImGui::PopStyleColor(); + } + ImGui::Separator(); + } + + ImGui::BeginChild("diff_content", {-1, -1}, ImGuiChildFlags_None, + ImGuiWindowFlags_HorizontalScrollbar); + const float row_height = scaled(21, scale); + const float number_width = scaled(48, scale); + auto draw_line = [&](const std::string& text, int old_number, int new_number, LineKind kind) { + ImGui::InvisibleButton("##line", {std::max(ImGui::GetContentRegionAvail().x, scaled(900, scale)), row_height}); + const ImVec2 minimum = ImGui::GetItemRectMin(); + const ImVec2 maximum = ImGui::GetItemRectMax(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + if (kind == LineKind::added) draw->AddRectFilled(minimum, maximum, IM_COL32(31, 65, 43, 225)); + else if (kind == LineKind::removed) draw->AddRectFilled(minimum, maximum, IM_COL32(70, 38, 40, 225)); + draw->AddRectFilled(minimum, {minimum.x + number_width * 2, maximum.y}, IM_COL32(31, 34, 40, 255)); + char old_buffer[16]{}; + char new_buffer[16]{}; + if (old_number) std::snprintf(old_buffer, sizeof(old_buffer), "%d", old_number); + if (new_number) std::snprintf(new_buffer, sizeof(new_buffer), "%d", new_number); + draw->AddText({minimum.x + scaled(5, scale), minimum.y + scaled(2, scale)}, + IM_COL32(123, 128, 138, 255), old_buffer); + draw->AddText({minimum.x + number_width + scaled(5, scale), minimum.y + scaled(2, scale)}, + IM_COL32(123, 128, 138, 255), new_buffer); + const char marker = kind == LineKind::added ? '+' : kind == LineKind::removed ? '-' : ' '; + char marker_text[2]{marker, 0}; + draw->AddText({minimum.x + number_width * 2 + scaled(5, scale), minimum.y + scaled(2, scale)}, + kind == LineKind::added ? IM_COL32(87, 190, 112, 255) : + kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) : IM_COL32(110, 115, 125, 255), marker_text); + draw->AddText({minimum.x + number_width * 2 + scaled(22, scale), minimum.y + scaled(2, scale)}, + syntaxColor(text), text.c_str()); + }; + + if (mode_ == Mode::diff) { + if (hunks_.empty()) ImGui::TextDisabled("No textual diff is available for this file."); + int line_id = 0; + int pending_hunk = -1; + enum class HunkAction { none, stage, discard, unstage }; + HunkAction pending_action = HunkAction::none; + for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) { + ImGui::PushID(static_cast(hunk_index)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.56f, 0.70f, 0.90f, 1)); + ImGui::TextUnformatted(hunks_[hunk_index].header.c_str()); + ImGui::PopStyleColor(); + ImGui::SameLine(std::max(scaled(220, scale), ImGui::GetWindowWidth() - scaled(205, scale))); + if (!staged_) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1)); + if (ImGui::SmallButton("Discard Hunk")) { + pending_hunk = static_cast(hunk_index); + pending_action = HunkAction::discard; + } + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.43f, 0.90f, 0.51f, 1)); + if (ImGui::SmallButton("Stage Hunk")) { + pending_hunk = static_cast(hunk_index); + pending_action = HunkAction::stage; + } + ImGui::PopStyleColor(); + } else if (ImGui::SmallButton("Unstage Hunk")) { + pending_hunk = static_cast(hunk_index); + pending_action = HunkAction::unstage; + } + for (const Line& line : hunks_[hunk_index].lines) { + ImGui::PushID(line_id++); + draw_line(line.text, line.old_number, line.new_number, line.kind); + ImGui::PopID(); + } + ImGui::PopID(); + } + if (pending_hunk >= 0 && pending_hunk < static_cast(hunks_.size())) { + const bool cached = pending_action != HunkAction::discard; + const bool reverse = pending_action != HunkAction::stage; + if (manager.applyPatch(repository, hunks_[pending_hunk].patch, cached, reverse, notice)) + reload(repository, manager, notice); + } + } else { + const std::vector* lines = mode_ == Mode::file ? &file_lines_ : + mode_ == Mode::blame ? &blame_lines_ : &history_lines_; + for (size_t index = 0; index < lines->size(); ++index) { + ImGui::PushID(static_cast(index)); + draw_line((*lines)[index], mode_ == Mode::file ? static_cast(index + 1) : 0, + mode_ == Mode::file ? static_cast(index + 1) : 0, LineKind::context); + ImGui::PopID(); + } + if (lines->empty()) ImGui::TextDisabled("No data is available for this view."); + } + ImGui::EndChild(); + ImGui::EndChild(); + ImGui::PopStyleVar(); +} diff --git a/src/ui/diff_viewer.h b/src/ui/diff_viewer.h new file mode 100644 index 0000000..a703919 --- /dev/null +++ b/src/ui/diff_viewer.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +class GitManager; +struct RepositoryView; + +class DiffViewer { +public: + void open(RepositoryView& repository, GitManager& manager, const std::string& path, + bool staged, std::string& notice); + void close(); + bool isOpen() const { return !path_.empty(); } + void draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice); + +private: + enum class Mode { diff, file, blame, history }; + enum class LineKind { context, added, removed }; + struct Line { + int old_number = 0; + int new_number = 0; + LineKind kind = LineKind::context; + std::string text; + std::string raw; + }; + struct Hunk { + std::string header; + std::vector lines; + std::string patch; + }; + + std::string path_; + bool staged_ = false; + Mode mode_ = Mode::diff; + std::string file_header_; + std::vector hunks_; + std::vector file_lines_; + std::vector blame_lines_; + std::vector history_lines_; + + void reload(RepositoryView& repository, GitManager& manager, std::string& notice); + void loadSupplement(RepositoryView& repository, GitManager& manager, Mode mode, std::string& notice); + void parseDiff(const std::string& text); +}; diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index 3454ec0..6442881 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -11,6 +11,7 @@ #include "managers/user_data.h" #include "managers/window_manager.h" #include "models/repository.h" +#include "ui/diff_viewer.h" #include "ui/graph_renderer.h" #include @@ -60,6 +61,7 @@ std::array g_commit_description{}; float g_ui_scale = 1.0f; float g_sidebar_width = 230.0f; float g_details_width = 368.0f; +DiffViewer g_diff_viewer; WindowManager* g_window_manager = nullptr; GitManager* g_git_manager = nullptr; ImFont* g_outline_icon_font = nullptr; @@ -779,6 +781,9 @@ void draw_file_row(const std::string& path, FileChangeKind kind, int id, bool working_file = false, bool staged = false, const std::string& action_path = {}) { ImGui::PushID(id); ImGui::InvisibleButton("##file", {-1, ui(25.0f)}); + const std::string& git_path = action_path.empty() ? path : action_path; + if (working_file && ImGui::IsItemClicked(ImGuiMouseButton_Left)) + g_diff_viewer.open(repo(), *g_git_manager, git_path, staged, g_notice); const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); @@ -786,7 +791,6 @@ void draw_file_row(const std::string& path, FileChangeKind kind, int id, const float y = minimum.y + (maximum.y - minimum.y - ImGui::GetFontSize()) * 0.5f; draw->AddText({minimum.x + ui(4.0f), y}, ImGui::ColorConvertFloat4ToU32(change_color(kind)), change_icon(kind)); draw->AddText({minimum.x + ui(20.0f), y}, IM_COL32(174, 179, 187, 255), path.c_str()); - const std::string& git_path = action_path.empty() ? path : action_path; if (ImGui::BeginPopupContextItem()) { if (working_file && !staged && ImGui::MenuItem(ICON_FA_CIRCLE_PLUS " Stage file")) g_git_manager->stageFile(repo(), git_path, g_notice); @@ -1725,7 +1729,8 @@ void draw_app() { g_details_width = std::clamp(g_details_width, std::min(280.0f, details_maximum), details_maximum); const float detail_width = ui(g_details_width); ImGui::BeginChild("center", {content_width - detail_width - ui(6.0f), -ui(28.0f)}, false); - draw_commit_table(); + if (g_diff_viewer.isOpen()) g_diff_viewer.draw(repo(), *g_git_manager, g_ui_scale, g_notice); + else draw_commit_table(); ImGui::EndChild(); ImGui::SameLine(0, 0); ImGui::InvisibleButton("##details_splitter", {ui(6.0f), -ui(28.0f)});