feat(diff): add full file diff viewer
Some checks are pending
Build Windows EXE / build-windows (push) Has started running

This commit is contained in:
2026-06-18 19:42:29 -05:00
parent 6f0b48402e
commit 27ecf6c351
6 changed files with 420 additions and 16 deletions

View File

@@ -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

View File

@@ -1,6 +1,7 @@
#include "managers/git_manager.h"
#include <algorithm>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
@@ -309,13 +310,28 @@ bool GitManager::reload(RepositoryView& repository, std::string& error) {
bool GitManager::runGit(RepositoryView& repository, const std::vector<std::string>& 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<std::string>& 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<std::strin
FILE_SHARE_READ | FILE_SHARE_WRITE, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY, nullptr);
if (output == INVALID_HANDLE_VALUE) {
DeleteFileW(output_path);
message = "Unable to capture Git command output";
error = "Unable to capture Git command output";
return false;
}
@@ -351,7 +367,7 @@ bool GitManager::runGit(RepositoryView& repository, const std::vector<std::strin
}
FlushFileBuffers(output);
SetFilePointer(output, 0, nullptr, FILE_BEGIN);
std::string command_output;
command_output.clear();
char buffer[4096];
DWORD bytes_read = 0;
while (ReadFile(output, buffer, sizeof(buffer), &bytes_read, nullptr) && bytes_read)
@@ -361,25 +377,37 @@ bool GitManager::runGit(RepositoryView& repository, const std::vector<std::strin
while (!command_output.empty() && (command_output.back() == '\r' || command_output.back() == '\n'))
command_output.pop_back();
if (!started || exit_code != 0) {
message = command_output.empty() ? "Unable to run Git command" : command_output;
error = command_output.empty() ? "Unable to run Git command" : command_output;
return false;
}
#else
(void)arguments;
message = "Git commands are currently supported on Windows";
error = "Git commands are currently supported on Windows";
return false;
#endif
if (reload_repository) {
std::string reload_error;
if (!loadRepositoryData(repository, reload_error)) {
message = reload_error;
return false;
}
}
message = success_message;
return true;
}
bool GitManager::applyPatch(RepositoryView& repository, const std::string& patch, bool cached,
bool reverse, std::string& error) {
const std::filesystem::path patch_path = std::filesystem::temp_directory_path() /
("gitree-hunk-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".patch");
{
std::ofstream stream(patch_path, std::ios::binary);
if (!stream) { error = "Unable to create temporary patch"; return false; }
stream.write(patch.data(), static_cast<std::streamsize>(patch.size()));
}
std::vector<std::string> 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<std::string> args = remote.empty()
? std::vector<std::string>{"fetch", "--all", "--prune"}

View File

@@ -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<std::string>& 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);

320
src/ui/diff_viewer.cpp Normal file
View File

@@ -0,0 +1,320 @@
#include "ui/diff_viewer.h"
#include "managers/git_manager.h"
#include "models/repository.h"
#include <IconsFontAwesome6.h>
#include <imgui.h>
#include <izo/dialogs.hpp>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <sstream>
namespace {
float scaled(float value, float scale) { return value * scale; }
std::vector<std::string> splitLines(const std::string& text) {
std::vector<std::string> 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<unsigned char>(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<std::string> 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<int>(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<int>(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<int>(hunk_index);
pending_action = HunkAction::stage;
}
ImGui::PopStyleColor();
} else if (ImGui::SmallButton("Unstage Hunk")) {
pending_hunk = static_cast<int>(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<int>(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<std::string>* 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<int>(index));
draw_line((*lines)[index], mode_ == Mode::file ? static_cast<int>(index + 1) : 0,
mode_ == Mode::file ? static_cast<int>(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();
}

45
src/ui/diff_viewer.h Normal file
View File

@@ -0,0 +1,45 @@
#pragma once
#include <string>
#include <vector>
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<Line> lines;
std::string patch;
};
std::string path_;
bool staged_ = false;
Mode mode_ = Mode::diff;
std::string file_header_;
std::vector<Hunk> hunks_;
std::vector<std::string> file_lines_;
std::vector<std::string> blame_lines_;
std::vector<std::string> 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);
};

View File

@@ -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 <algorithm>
@@ -60,6 +61,7 @@ std::array<char, 1024> 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)});