feat(details): add commit and working tree panels

This commit is contained in:
2026-06-18 18:47:48 -05:00
parent 09011a6a4f
commit df6212ab49
5 changed files with 209 additions and 42 deletions

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@
# Build output and editor state # Build output and editor state
/build/ /build/
/build-*/
/.vs/ /.vs/
/.vscode/ /.vscode/
/imgui.ini /imgui.ini

View File

@@ -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) { bool GitManager::loadRepositoryData(RepositoryView& repository, std::string& error) {
repository.local_branches.clear(); repository.remote_branches.clear(); repository.remotes.clear(); repository.local_branches.clear(); repository.remote_branches.clear(); repository.remotes.clear();
repository.worktrees.clear(); repository.worktree_branches.clear(); repository.submodules.clear(); repository.worktrees.clear(); repository.worktree_branches.clear(); repository.submodules.clear();
repository.commits.clear(); repository.selected_commit = 0; repository.commits.clear(); repository.selected_commit = 0;
loadWorkingTree(repository);
const char* workdir = git_repository_workdir(repository.repo); const char* workdir = git_repository_workdir(repository.repo);
repository.path = workdir ? workdir : git_repository_path(repository.repo); repository.path = workdir ? workdir : git_repository_path(repository.repo);
std::filesystem::path path(repository.path); std::filesystem::path path(repository.path);

View File

@@ -23,4 +23,5 @@ private:
void loadRefBadges(RepositoryView& repository); void loadRefBadges(RepositoryView& repository);
void computeGraphLanes(RepositoryView& repository); void computeGraphLanes(RepositoryView& repository);
bool loadRepositoryData(RepositoryView& repository, std::string& error); bool loadRepositoryData(RepositoryView& repository, std::string& error);
void loadWorkingTree(RepositoryView& repository);
}; };

View File

@@ -22,6 +22,12 @@ struct ChangedFile {
FileChangeKind kind = FileChangeKind::other; FileChangeKind kind = FileChangeKind::other;
}; };
struct WorkingFile {
std::string path;
FileChangeKind kind = FileChangeKind::other;
bool staged = false;
};
struct CommitInfo { struct CommitInfo {
git_oid oid{}; git_oid oid{};
std::string short_id; std::string short_id;
@@ -49,6 +55,7 @@ struct RepositoryView {
std::set<std::string> worktree_branches; std::set<std::string> worktree_branches;
std::vector<std::string> submodules; std::vector<std::string> submodules;
std::vector<CommitInfo> commits; std::vector<CommitInfo> commits;
std::vector<WorkingFile> working_files;
int selected_commit = 0; int selected_commit = 0;
RepositoryView() = default; RepositoryView() = default;

View File

@@ -37,6 +37,11 @@ std::string g_notice;
bool g_init_popup = false; bool g_init_popup = false;
bool g_about_popup = false; bool g_about_popup = false;
bool g_licenses_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<char, 128> g_commit_summary{};
std::array<char, 1024> g_commit_description{};
float g_ui_scale = 1.0f; float g_ui_scale = 1.0f;
float g_sidebar_width = 230.0f; float g_sidebar_width = 230.0f;
WindowManager* g_window_manager = nullptr; WindowManager* g_window_manager = nullptr;
@@ -354,6 +359,24 @@ void draw_commit_table() {
ImGui::TableSetupColumn("COMMIT MESSAGE", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("COMMIT MESSAGE", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("COMMIT DATE / TIME", ImGuiTableColumnFlags_WidthFixed, ui(180.0f)); ImGui::TableSetupColumn("COMMIT DATE / TIME", ImGuiTableColumnFlags_WidthFixed, ui(180.0f));
ImGui::TableHeadersRow(); 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<int>(repo().working_files.size()));
}
for (int i = 0; i < static_cast<int>(repo().commits.size()); ++i) { for (int i = 0; i < static_cast<int>(repo().commits.size()); ++i) {
const auto& commit = repo().commits[i]; const auto& commit = repo().commits[i];
if (g_filter[0]) { if (g_filter[0]) {
@@ -396,26 +419,138 @@ void draw_commit_table() {
ImGui::EndTable(); ImGui::EndTable();
} }
void draw_details() { const char* change_icon(FileChangeKind kind) {
ImGui::BeginChild("details", {ui(360.0f), -ui(28.0f)}, ImGuiChildFlags_Borders); if (kind == FileChangeKind::added) return ICON_FA_PLUS;
if (repo().commits.empty()) { if (kind == FileChangeKind::deleted) return ICON_FA_MINUS;
ImGui::TextDisabled("Select a repository with commits"); if (kind == FileChangeKind::renamed) return ICON_FA_ARROW_RIGHT_ARROW_LEFT;
ImGui::EndChild(); 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 <typename File>
void draw_files(const std::vector<File>& 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; return;
} }
std::map<std::string, std::vector<const File*>> 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<int>(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<int>(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<int>(repo().commits.size() - 1)); const int selected = std::clamp(repo().selected_commit, 0, static_cast<int>(repo().commits.size() - 1));
g_git_manager->loadCommitChanges(repo(), selected, g_notice); g_git_manager->loadCommitChanges(repo(), selected, g_notice);
const auto& commit = repo().commits[selected]; const auto& commit = repo().commits[selected];
ImGui::TextDisabled("COMMIT"); ImGui::TextDisabled("commit:");
ImGui::SameLine(); ImGui::SameLine();
ImGui::Text("%s", commit.short_id.c_str()); ImGui::Text("%s", commit.short_id.c_str());
ImGui::Separator(); ImGui::Separator();
ImGui::Dummy({0, 6}); ImGui::BeginChild("message_card", {-1, ui(125)}, ImGuiChildFlags_Borders);
ImGui::PushTextWrapPos(); ImGui::PushTextWrapPos();
ImGui::Text("%s", commit.summary.c_str()); ImGui::Text("%s", commit.summary.c_str());
ImGui::PopTextWrapPos(); ImGui::PopTextWrapPos();
ImGui::Dummy({0, 26}); ImGui::EndChild();
ImGui::Separator(); 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; const unsigned int author_texture = g_avatar_cache ? g_avatar_cache->textureFor(commit.email) : 0;
if (author_texture) { if (author_texture) {
ImGui::Image(ImTextureRef(static_cast<ImTextureID>(author_texture)), {ui(40), ui(40)}); ImGui::Image(ImTextureRef(static_cast<ImTextureID>(author_texture)), {ui(40), ui(40)});
@@ -423,48 +558,37 @@ void draw_details() {
} }
ImGui::BeginGroup(); ImGui::BeginGroup();
ImGui::TextColored(ImVec4(0.25f, 0.80f, 0.86f, 1), "%s", commit.author.c_str()); 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::TextDisabled("authored %s", commit.date.c_str());
ImGui::EndGroup(); ImGui::EndGroup();
ImGui::Dummy({0, 24}); if (!commit.parent_ids.empty()) {
ImGui::TextColored(ImVec4(0.95f, 0.68f, 0.22f, 1), "%d parent%s", commit.parents, commit.parents == 1 ? "" : "s"); char parent[GIT_OID_SHA1_HEXSIZE + 1]{};
ImGui::Separator(); git_oid_tostr(parent, sizeof(parent), &commit.parent_ids.front());
int added = 0; ImGui::SameLine(std::max(ui(180), ImGui::GetWindowWidth() - ui(95)));
int modified = 0; ImGui::TextDisabled("parent: %.7s", parent);
int deleted = 0; }
ImGui::Dummy({0, ui(14)});
int added = 0, modified = 0, deleted = 0;
for (const auto& file : commit.changed_files) { for (const auto& file : commit.changed_files) {
if (file.kind == FileChangeKind::added) ++added; if (file.kind == FileChangeKind::added) ++added;
else if (file.kind == FileChangeKind::deleted) ++deleted; else if (file.kind == FileChangeKind::deleted) ++deleted;
else ++modified; else ++modified;
} }
if (modified) ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN " %d modified", modified); if (modified) ImGui::TextColored(change_color(FileChangeKind::modified), ICON_FA_PEN " %d modified", modified);
if (added) { if (added) { if (modified) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::added), ICON_FA_PLUS " %d added", added); }
if (modified) ImGui::SameLine(); if (deleted) { if (modified || added) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::deleted), ICON_FA_MINUS " %d deleted", deleted); }
ImGui::TextColored(ImVec4(0.31f, 0.82f, 0.43f, 1), ICON_FA_PLUS " %d added", added); draw_file_toolbar();
} ImGui::Separator();
if (deleted) { if (g_file_view_mode == FileViewMode::tree) ImGui::TextUnformatted("Collapse All");
if (modified || added) ImGui::SameLine();
ImGui::TextColored(ImVec4(0.91f, 0.35f, 0.35f, 1), ICON_FA_MINUS " %d deleted", deleted);
}
ImGui::BeginChild("changed_files", {-1, -1}); ImGui::BeginChild("changed_files", {-1, -1});
for (int index = 0; index < static_cast<int>(commit.changed_files.size()); ++index) { draw_files(commit.changed_files);
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();
}
ImGui::EndChild(); 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(); ImGui::EndChild();
} }