893 lines
36 KiB
C++
893 lines
36 KiB
C++
#include <git2.h>
|
|
#include <GLFW/glfw3.h>
|
|
#include <imgui.h>
|
|
#include <imgui_impl_glfw.h>
|
|
#include <imgui_impl_opengl3.h>
|
|
#include <izo/dialogs.hpp>
|
|
#include <IconsFontAwesome6.h>
|
|
#include "user_data.h"
|
|
#include "window_manager.h"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <set>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
enum class RefKind { local, remote, tag };
|
|
|
|
struct RefBadge {
|
|
std::string name;
|
|
RefKind kind = RefKind::local;
|
|
bool current = false;
|
|
bool worktree = false;
|
|
bool upstream = false;
|
|
};
|
|
|
|
struct CommitInfo {
|
|
git_oid oid{};
|
|
std::string short_id;
|
|
std::string summary;
|
|
std::string author;
|
|
std::string email;
|
|
std::string date;
|
|
int parents = 0;
|
|
std::vector<RefBadge> refs;
|
|
};
|
|
|
|
struct RepositoryView {
|
|
git_repository* repo = nullptr;
|
|
std::string path;
|
|
std::string name = "New Tab";
|
|
std::string branch = "detached";
|
|
std::vector<std::string> local_branches;
|
|
std::vector<std::string> remote_branches;
|
|
std::vector<std::string> remotes;
|
|
std::vector<std::string> worktrees;
|
|
std::set<std::string> worktree_branches;
|
|
std::vector<std::string> submodules;
|
|
std::vector<CommitInfo> commits;
|
|
int selected_commit = 0;
|
|
|
|
~RepositoryView() { close(); }
|
|
void close() {
|
|
if (repo) git_repository_free(repo);
|
|
repo = nullptr;
|
|
}
|
|
};
|
|
|
|
std::vector<std::unique_ptr<RepositoryView>> g_tabs;
|
|
size_t g_active_tab = 0;
|
|
UserData* g_user_data = nullptr;
|
|
std::array<char, 1024> g_path{};
|
|
std::array<char, 256> g_filter{};
|
|
std::string g_notice;
|
|
bool g_init_popup = false;
|
|
bool g_about_popup = false;
|
|
bool g_licenses_popup = false;
|
|
float g_ui_scale = 1.0f;
|
|
float g_sidebar_width = 230.0f;
|
|
WindowManager* g_window_manager = nullptr;
|
|
|
|
float ui(float value) { return value * g_ui_scale; }
|
|
RepositoryView& repo() { return *g_tabs.at(g_active_tab); }
|
|
|
|
void create_new_tab() {
|
|
g_tabs.push_back(std::make_unique<RepositoryView>());
|
|
g_active_tab = g_tabs.size() - 1;
|
|
}
|
|
|
|
void close_tab(size_t index) {
|
|
if (index >= g_tabs.size()) return;
|
|
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<std::ptrdiff_t>(index));
|
|
if (g_tabs.empty()) create_new_tab();
|
|
else if (g_active_tab >= g_tabs.size()) g_active_tab = g_tabs.size() - 1;
|
|
else if (index < g_active_tab) --g_active_tab;
|
|
}
|
|
|
|
std::string last_error(const char* fallback) {
|
|
const git_error* error = git_error_last();
|
|
return error && error->message ? error->message : fallback;
|
|
}
|
|
|
|
std::string format_time(git_time_t value, int offset_minutes) {
|
|
std::time_t adjusted = static_cast<std::time_t>(value + offset_minutes * 60);
|
|
std::tm tm{};
|
|
#ifdef _WIN32
|
|
gmtime_s(&tm, &adjusted);
|
|
#else
|
|
gmtime_r(&adjusted, &tm);
|
|
#endif
|
|
char buffer[48]{};
|
|
std::strftime(buffer, sizeof(buffer), "%m/%d/%Y %I:%M %p", &tm);
|
|
return buffer;
|
|
}
|
|
|
|
void read_branches(git_branch_t type, std::vector<std::string>& output) {
|
|
git_branch_iterator* iterator = nullptr;
|
|
if (git_branch_iterator_new(&iterator, repo().repo, type) != 0) return;
|
|
git_reference* reference = nullptr;
|
|
git_branch_t found{};
|
|
while (git_branch_next(&reference, &found, iterator) == 0) {
|
|
const char* name = nullptr;
|
|
if (git_branch_name(&name, reference) == 0 && name) output.emplace_back(name);
|
|
git_reference_free(reference);
|
|
}
|
|
git_branch_iterator_free(iterator);
|
|
}
|
|
|
|
void add_badge(const git_oid* oid, RefBadge badge) {
|
|
if (!oid) return;
|
|
const auto commit = std::find_if(repo().commits.begin(), repo().commits.end(),
|
|
[oid](const CommitInfo& item) { return git_oid_equal(&item.oid, oid) != 0; });
|
|
if (commit != repo().commits.end()) commit->refs.push_back(std::move(badge));
|
|
}
|
|
|
|
void load_ref_badges() {
|
|
for (const git_branch_t type : {GIT_BRANCH_LOCAL, GIT_BRANCH_REMOTE}) {
|
|
git_branch_iterator* iterator = nullptr;
|
|
if (git_branch_iterator_new(&iterator, repo().repo, type) != 0) continue;
|
|
git_reference* reference = nullptr;
|
|
git_branch_t found{};
|
|
while (git_branch_next(&reference, &found, iterator) == 0) {
|
|
const char* name = nullptr;
|
|
if (git_branch_name(&name, reference) == 0 && name) {
|
|
RefBadge badge;
|
|
badge.name = name;
|
|
badge.kind = type == GIT_BRANCH_LOCAL ? RefKind::local : RefKind::remote;
|
|
badge.current = type == GIT_BRANCH_LOCAL && badge.name == repo().branch;
|
|
badge.worktree = type == GIT_BRANCH_LOCAL &&
|
|
(badge.current || repo().worktree_branches.contains(badge.name));
|
|
if (type == GIT_BRANCH_LOCAL) {
|
|
git_reference* upstream = nullptr;
|
|
badge.upstream = git_branch_upstream(&upstream, reference) == 0;
|
|
git_reference_free(upstream);
|
|
}
|
|
git_object* object = nullptr;
|
|
if (git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0) {
|
|
add_badge(git_object_id(object), std::move(badge));
|
|
git_object_free(object);
|
|
}
|
|
}
|
|
git_reference_free(reference);
|
|
}
|
|
git_branch_iterator_free(iterator);
|
|
}
|
|
|
|
git_reference_iterator* tags = nullptr;
|
|
if (git_reference_iterator_glob_new(&tags, repo().repo, "refs/tags/*") != 0) return;
|
|
git_reference* reference = nullptr;
|
|
while (git_reference_next(&reference, tags) == 0) {
|
|
git_object* object = nullptr;
|
|
if (git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0) {
|
|
const char* shorthand = git_reference_shorthand(reference);
|
|
add_badge(git_object_id(object), {shorthand ? shorthand : "tag", RefKind::tag});
|
|
git_object_free(object);
|
|
}
|
|
git_reference_free(reference);
|
|
}
|
|
git_reference_iterator_free(tags);
|
|
}
|
|
|
|
int submodule_callback(git_submodule* submodule, const char*, void* payload) {
|
|
auto* names = static_cast<std::vector<std::string>*>(payload);
|
|
names->emplace_back(git_submodule_name(submodule));
|
|
return 0;
|
|
}
|
|
|
|
void load_repository_data() {
|
|
repo().local_branches.clear();
|
|
repo().remote_branches.clear();
|
|
repo().remotes.clear();
|
|
repo().worktrees.clear();
|
|
repo().worktree_branches.clear();
|
|
repo().submodules.clear();
|
|
repo().commits.clear();
|
|
repo().selected_commit = 0;
|
|
|
|
const char* workdir = git_repository_workdir(repo().repo);
|
|
const char* repo_path = workdir ? workdir : git_repository_path(repo().repo);
|
|
repo().path = repo_path ? repo_path : "";
|
|
std::filesystem::path p(repo().path);
|
|
repo().name = p.filename().string();
|
|
if (repo().name.empty()) repo().name = p.parent_path().filename().string();
|
|
|
|
git_reference* head = nullptr;
|
|
if (git_repository_head(&head, repo().repo) == 0) {
|
|
const char* shorthand = git_reference_shorthand(head);
|
|
if (shorthand) repo().branch = shorthand;
|
|
git_reference_free(head);
|
|
}
|
|
|
|
read_branches(GIT_BRANCH_LOCAL, repo().local_branches);
|
|
read_branches(GIT_BRANCH_REMOTE, repo().remote_branches);
|
|
|
|
git_strarray names{};
|
|
if (git_remote_list(&names, repo().repo) == 0) {
|
|
for (size_t i = 0; i < names.count; ++i) repo().remotes.emplace_back(names.strings[i]);
|
|
git_strarray_dispose(&names);
|
|
}
|
|
if (git_worktree_list(&names, repo().repo) == 0) {
|
|
for (size_t i = 0; i < names.count; ++i) {
|
|
repo().worktrees.emplace_back(names.strings[i]);
|
|
git_worktree* worktree = nullptr;
|
|
git_repository* worktree_repo = nullptr;
|
|
git_reference* worktree_head = nullptr;
|
|
if (git_worktree_lookup(&worktree, repo().repo, names.strings[i]) == 0 &&
|
|
git_repository_open_from_worktree(&worktree_repo, worktree) == 0 &&
|
|
git_repository_head(&worktree_head, worktree_repo) == 0) {
|
|
const char* branch = git_reference_shorthand(worktree_head);
|
|
if (branch) repo().worktree_branches.emplace(branch);
|
|
}
|
|
git_reference_free(worktree_head);
|
|
git_repository_free(worktree_repo);
|
|
git_worktree_free(worktree);
|
|
}
|
|
git_strarray_dispose(&names);
|
|
}
|
|
git_submodule_foreach(repo().repo, submodule_callback, &repo().submodules);
|
|
|
|
git_revwalk* walk = nullptr;
|
|
if (git_revwalk_new(&walk, repo().repo) != 0) return;
|
|
git_revwalk_sorting(walk, GIT_SORT_TOPOLOGICAL | GIT_SORT_TIME);
|
|
if (git_revwalk_push_head(walk) != 0) {
|
|
git_revwalk_free(walk);
|
|
return;
|
|
}
|
|
git_oid oid{};
|
|
while (repo().commits.size() < 250 && git_revwalk_next(&oid, walk) == 0) {
|
|
git_commit* commit = nullptr;
|
|
if (git_commit_lookup(&commit, repo().repo, &oid) != 0) continue;
|
|
CommitInfo item;
|
|
item.oid = oid;
|
|
char id[GIT_OID_SHA1_HEXSIZE + 1]{};
|
|
git_oid_tostr(id, sizeof(id), &oid);
|
|
item.short_id.assign(id, 8);
|
|
const char* summary = git_commit_summary(commit);
|
|
item.summary = summary ? summary : "(no commit message)";
|
|
const git_signature* author = git_commit_author(commit);
|
|
if (author) {
|
|
item.author = author->name ? author->name : "Unknown";
|
|
item.email = author->email ? author->email : "";
|
|
item.date = format_time(author->when.time, author->when.offset);
|
|
}
|
|
item.parents = static_cast<int>(git_commit_parentcount(commit));
|
|
repo().commits.push_back(std::move(item));
|
|
git_commit_free(commit);
|
|
}
|
|
git_revwalk_free(walk);
|
|
load_ref_badges();
|
|
}
|
|
|
|
bool open_repository(const char* path) {
|
|
git_repository* opened = nullptr;
|
|
if (git_repository_open_ext(&opened, path, 0, nullptr) != 0) {
|
|
g_notice = last_error("Unable to open repository");
|
|
return false;
|
|
}
|
|
repo().close();
|
|
repo().repo = opened;
|
|
load_repository_data();
|
|
g_notice = "Opened " + repo().name;
|
|
return true;
|
|
}
|
|
|
|
void pick_and_open_repository() {
|
|
izo::dialog_options options;
|
|
options.title = "Open Git repository";
|
|
if (!repo().path.empty()) options.initial_path = repo().path;
|
|
else options.initial_path = std::filesystem::current_path();
|
|
|
|
const izo::dialog_result result = izo::pick_folder(options);
|
|
if (result.status == izo::dialog_status::cancelled) return;
|
|
if (result.status == izo::dialog_status::error) {
|
|
g_notice = result.error_message.empty() ? "Unable to open the folder picker" : result.error_message;
|
|
return;
|
|
}
|
|
if (result.paths.empty()) {
|
|
g_notice = "The folder picker returned no path";
|
|
return;
|
|
}
|
|
|
|
const auto utf8 = result.paths.front().u8string();
|
|
const std::string path(reinterpret_cast<const char*>(utf8.data()), utf8.size());
|
|
if (repo().repo) create_new_tab();
|
|
open_repository(path.c_str());
|
|
}
|
|
|
|
bool init_repository(const char* path) {
|
|
git_repository* created = nullptr;
|
|
if (git_repository_init(&created, path, 0) != 0) {
|
|
g_notice = last_error("Unable to initialize repository");
|
|
return false;
|
|
}
|
|
git_repository_free(created);
|
|
return open_repository(path);
|
|
}
|
|
|
|
void apply_style(float scale) {
|
|
g_ui_scale = scale;
|
|
ImGuiStyle& style = ImGui::GetStyle();
|
|
style = ImGuiStyle();
|
|
style.WindowRounding = 0.0f;
|
|
style.ChildRounding = 3.0f;
|
|
style.FrameRounding = 3.0f;
|
|
style.PopupRounding = 4.0f;
|
|
style.ScrollbarRounding = 3.0f;
|
|
style.FramePadding = {8.0f, 5.0f};
|
|
style.ItemSpacing = {8.0f, 6.0f};
|
|
style.WindowPadding = {8.0f, 8.0f};
|
|
style.Colors[ImGuiCol_Text] = ImVec4(0.89f, 0.91f, 0.94f, 1.0f);
|
|
style.Colors[ImGuiCol_TextDisabled] = ImVec4(0.53f, 0.58f, 0.66f, 1.0f);
|
|
style.Colors[ImGuiCol_WindowBg] = ImVec4(0.100f, 0.116f, 0.145f, 1.0f);
|
|
style.Colors[ImGuiCol_ChildBg] = ImVec4(0.116f, 0.133f, 0.165f, 1.0f);
|
|
style.Colors[ImGuiCol_PopupBg] = ImVec4(0.13f, 0.15f, 0.19f, 1.0f);
|
|
style.Colors[ImGuiCol_Border] = ImVec4(0.24f, 0.28f, 0.34f, 1.0f);
|
|
style.Colors[ImGuiCol_FrameBg] = ImVec4(0.16f, 0.19f, 0.23f, 1.0f);
|
|
style.Colors[ImGuiCol_FrameBgHovered] = ImVec4(0.20f, 0.24f, 0.29f, 1.0f);
|
|
style.Colors[ImGuiCol_Button] = ImVec4(0.16f, 0.19f, 0.23f, 1.0f);
|
|
style.Colors[ImGuiCol_ButtonHovered] = ImVec4(0.10f, 0.44f, 0.56f, 1.0f);
|
|
style.Colors[ImGuiCol_Header] = ImVec4(0.08f, 0.40f, 0.50f, 1.0f);
|
|
style.Colors[ImGuiCol_HeaderHovered] = ImVec4(0.10f, 0.48f, 0.59f, 1.0f);
|
|
style.Colors[ImGuiCol_HeaderActive] = ImVec4(0.07f, 0.35f, 0.45f, 1.0f);
|
|
style.Colors[ImGuiCol_Tab] = ImVec4(0.13f, 0.15f, 0.19f, 1.0f);
|
|
style.Colors[ImGuiCol_TabSelected] = ImVec4(0.19f, 0.22f, 0.27f, 1.0f);
|
|
style.Colors[ImGuiCol_Separator] = ImVec4(0.24f, 0.28f, 0.34f, 1.0f);
|
|
style.Colors[ImGuiCol_TableHeaderBg] = ImVec4(0.13f, 0.15f, 0.19f, 1.0f);
|
|
style.Colors[ImGuiCol_TableRowBgAlt] = ImVec4(0.125f, 0.143f, 0.177f, 1.0f);
|
|
style.ScaleAllSizes(scale);
|
|
}
|
|
|
|
void load_fonts(float scale) {
|
|
ImGuiIO& io = ImGui::GetIO();
|
|
io.Fonts->Clear();
|
|
ImFontConfig config;
|
|
config.OversampleH = 2;
|
|
config.OversampleV = 2;
|
|
config.PixelSnapH = false;
|
|
config.RasterizerDensity = 1.0f;
|
|
const float size = 15.0f * scale;
|
|
if (!io.Fonts->AddFontFromFileTTF(GITREE_ASSET_DIR "/InterVariable.ttf", size, &config))
|
|
io.Fonts->AddFontDefault();
|
|
|
|
static constexpr ImWchar icon_ranges[] = {ICON_MIN_FA, ICON_MAX_16_FA, 0};
|
|
ImFontConfig icon_config;
|
|
icon_config.MergeMode = true;
|
|
icon_config.PixelSnapH = true;
|
|
icon_config.GlyphMinAdvanceX = size;
|
|
io.Fonts->AddFontFromFileTTF(
|
|
GITREE_ASSET_DIR "/fa-solid-900.ttf", size, &icon_config, icon_ranges);
|
|
apply_style(scale);
|
|
}
|
|
|
|
void section(const char* label, const std::vector<std::string>& items) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.60f, 0.67f, 0.76f, 1.0f));
|
|
bool open = ImGui::TreeNodeEx(label, ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_SpanAvailWidth);
|
|
ImGui::SameLine(ImGui::GetContentRegionAvail().x - 18.0f);
|
|
ImGui::TextDisabled("%d", static_cast<int>(items.size()));
|
|
ImGui::PopStyleColor();
|
|
if (open) {
|
|
for (const auto& item : items) ImGui::Selectable((" " + item).c_str());
|
|
ImGui::TreePop();
|
|
}
|
|
ImGui::Separator();
|
|
}
|
|
|
|
struct BranchNode {
|
|
std::map<std::string, BranchNode> children;
|
|
std::string full_name;
|
|
bool branch = false;
|
|
};
|
|
|
|
void add_branch_node(BranchNode& root, const std::string& branch) {
|
|
BranchNode* node = &root;
|
|
size_t start = 0;
|
|
while (start < branch.size()) {
|
|
const size_t slash = branch.find('/', start);
|
|
const std::string part = branch.substr(start, slash == std::string::npos ? std::string::npos : slash - start);
|
|
node = &node->children[part];
|
|
if (slash == std::string::npos) break;
|
|
start = slash + 1;
|
|
}
|
|
node->branch = true;
|
|
node->full_name = branch;
|
|
}
|
|
|
|
void draw_branch_nodes(const BranchNode& parent, const std::string& id_path = {}) {
|
|
for (const auto& [name, node] : parent.children) {
|
|
const std::string id = id_path + "/" + name;
|
|
if (!node.children.empty()) {
|
|
const std::string label = std::string(ICON_FA_FOLDER) + " " + name + "##" + id;
|
|
const bool open = ImGui::TreeNodeEx(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen |
|
|
ImGuiTreeNodeFlags_SpanAvailWidth);
|
|
if (open) {
|
|
if (node.branch) ImGui::Selectable((std::string(ICON_FA_CODE_BRANCH) + " " + name + "##branch" + id).c_str());
|
|
draw_branch_nodes(node, id);
|
|
ImGui::TreePop();
|
|
}
|
|
} else {
|
|
ImGui::Selectable((std::string(ICON_FA_CODE_BRANCH) + " " + name + "##" + id).c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
void branch_section(const char* label, const std::vector<std::string>& branches) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.60f, 0.67f, 0.76f, 1.0f));
|
|
const bool open = ImGui::TreeNodeEx(label, ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_SpanAvailWidth);
|
|
ImGui::SameLine(ImGui::GetContentRegionAvail().x - ui(18.0f));
|
|
ImGui::TextDisabled("%d", static_cast<int>(branches.size()));
|
|
ImGui::PopStyleColor();
|
|
if (open) {
|
|
BranchNode root;
|
|
for (const auto& branch : branches) {
|
|
if (g_filter[0] && branch.find(g_filter.data()) == std::string::npos) continue;
|
|
add_branch_node(root, branch);
|
|
}
|
|
draw_branch_nodes(root, label);
|
|
ImGui::TreePop();
|
|
}
|
|
ImGui::Separator();
|
|
}
|
|
|
|
void draw_sidebar(float width) {
|
|
ImGui::BeginChild("sidebar", {width, -ui(28.0f)}, ImGuiChildFlags_Borders);
|
|
if (ImGui::Button(ICON_FA_LIST " List", {ui(98), ui(30)})) {}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(ICON_FA_LAYER_GROUP " Workspace", {ui(102), ui(30)})) {}
|
|
ImGui::TextDisabled("VIEWING %d", static_cast<int>(repo().local_branches.size() + repo().remote_branches.size()));
|
|
ImGui::SetNextItemWidth(-1);
|
|
ImGui::InputTextWithHint("##filter", ICON_FA_MAGNIFYING_GLASS " Search branches...", g_filter.data(), g_filter.size());
|
|
ImGui::Spacing();
|
|
branch_section(ICON_FA_HOUSE " LOCAL", repo().local_branches);
|
|
branch_section(ICON_FA_CLOUD " REMOTE", repo().remote_branches);
|
|
section(ICON_FA_DIAGRAM_PROJECT " WORKTREES", repo().worktrees);
|
|
section(ICON_FA_CUBES " SUBMODULES", repo().submodules);
|
|
ImGui::EndChild();
|
|
}
|
|
|
|
void draw_ref_badge(const RefBadge& badge, int index) {
|
|
std::string text;
|
|
if (badge.current) text += ICON_FA_CHECK " ";
|
|
text += badge.name;
|
|
if (badge.kind == RefKind::local) text += std::string(" ") + ICON_FA_HOUSE;
|
|
if (badge.worktree) text += std::string(" ") + ICON_FA_COMPUTER;
|
|
if (badge.kind == RefKind::remote || badge.upstream) text += std::string(" ") + ICON_FA_CLOUD;
|
|
if (badge.kind == RefKind::tag) text += std::string(" ") + ICON_FA_TAG;
|
|
|
|
const ImVec4 color = badge.kind == RefKind::tag
|
|
? ImVec4(0.42f, 0.22f, 0.62f, 1.0f)
|
|
: badge.kind == RefKind::remote
|
|
? ImVec4(0.10f, 0.31f, 0.35f, 1.0f)
|
|
: ImVec4(0.08f, 0.40f, 0.50f, 1.0f);
|
|
ImGui::PushStyleColor(ImGuiCol_Button, color);
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(color.x + 0.06f, color.y + 0.06f, color.z + 0.06f, 1.0f));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, color);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, ui(3.0f));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {ui(6.0f), ui(2.0f)});
|
|
const std::string id = text + "##ref_badge_" + std::to_string(index);
|
|
ImGui::Button(id.c_str());
|
|
ImGui::PopStyleVar(2);
|
|
ImGui::PopStyleColor(3);
|
|
}
|
|
|
|
void draw_graph_cell(int row, int parents) {
|
|
ImDrawList* draw = ImGui::GetWindowDrawList();
|
|
ImVec2 p = ImGui::GetCursorScreenPos();
|
|
const float x = p.x + ui(28.0f) + ((parents > 1 || row % 7 == 4) ? ui(18.0f) : 0.0f);
|
|
const float y = p.y + ImGui::GetTextLineHeight() * 0.5f;
|
|
const ImU32 cyan = IM_COL32(16, 178, 214, 255);
|
|
draw->AddLine({x, p.y - ui(4.0f)}, {x, p.y + ImGui::GetTextLineHeight() + ui(5.0f)}, cyan, ui(2.0f));
|
|
if (parents > 1) draw->AddBezierCubic({x, y}, {x + ui(28), y}, {x + ui(28), y + ui(16)}, {x + ui(28), y + ui(25)}, IM_COL32(153, 55, 220, 255), ui(2.0f));
|
|
draw->AddCircleFilled({x, y}, ui(5.0f), row == 0 ? IM_COL32(240, 247, 250, 255) : cyan);
|
|
draw->AddCircle({x, y}, ui(7.0f), cyan, 0, ui(2.0f));
|
|
ImGui::Dummy({ui(78.0f), ImGui::GetTextLineHeight()});
|
|
}
|
|
|
|
void draw_commit_table() {
|
|
ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV |
|
|
ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp;
|
|
if (!ImGui::BeginTable("commits", 4, flags, {-1, -1})) return;
|
|
ImGui::TableSetupScrollFreeze(0, 1);
|
|
ImGui::TableSetupColumn("BRANCH / TAG", ImGuiTableColumnFlags_WidthFixed, ui(210.0f));
|
|
ImGui::TableSetupColumn("GRAPH", ImGuiTableColumnFlags_WidthFixed, ui(92.0f));
|
|
ImGui::TableSetupColumn("COMMIT MESSAGE", ImGuiTableColumnFlags_WidthStretch);
|
|
ImGui::TableSetupColumn("COMMIT DATE / TIME", ImGuiTableColumnFlags_WidthFixed, ui(180.0f));
|
|
ImGui::TableHeadersRow();
|
|
for (int i = 0; i < static_cast<int>(repo().commits.size()); ++i) {
|
|
const auto& commit = repo().commits[i];
|
|
if (g_filter[0]) {
|
|
const bool message_matches = commit.summary.find(g_filter.data()) != std::string::npos;
|
|
const bool ref_matches = std::any_of(commit.refs.begin(), commit.refs.end(), [](const RefBadge& ref) {
|
|
return ref.name.find(g_filter.data()) != std::string::npos;
|
|
});
|
|
if (!message_matches && !ref_matches) continue;
|
|
}
|
|
ImGui::TableNextRow();
|
|
ImGui::TableSetColumnIndex(0);
|
|
std::string row_id = "##commit" + std::to_string(i);
|
|
if (ImGui::Selectable(row_id.c_str(), repo().selected_commit == i,
|
|
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) repo().selected_commit = i;
|
|
ImGui::SameLine(0, ui(3));
|
|
for (int ref_index = 0; ref_index < static_cast<int>(commit.refs.size()); ++ref_index) {
|
|
if (ref_index > 0) ImGui::SameLine(0, ui(4));
|
|
draw_ref_badge(commit.refs[ref_index], i * 1000 + ref_index);
|
|
}
|
|
ImGui::TableSetColumnIndex(1);
|
|
draw_graph_cell(i, commit.parents);
|
|
ImGui::TableSetColumnIndex(2);
|
|
ImGui::TextUnformatted(commit.summary.c_str());
|
|
ImGui::TableSetColumnIndex(3);
|
|
ImGui::TextDisabled("%s", commit.date.c_str());
|
|
}
|
|
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();
|
|
return;
|
|
}
|
|
const auto& commit = repo().commits[std::clamp(repo().selected_commit, 0, static_cast<int>(repo().commits.size() - 1))];
|
|
ImGui::TextDisabled("COMMIT");
|
|
ImGui::SameLine();
|
|
ImGui::Text("%s", commit.short_id.c_str());
|
|
ImGui::Separator();
|
|
ImGui::Dummy({0, 6});
|
|
ImGui::PushTextWrapPos();
|
|
ImGui::Text("%s", commit.summary.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Dummy({0, 26});
|
|
ImGui::Separator();
|
|
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::Dummy({0, 24});
|
|
ImGui::TextColored(ImVec4(0.95f, 0.68f, 0.22f, 1), "%d parent%s", commit.parents, commit.parents == 1 ? "" : "s");
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Changed files and diffs will appear here");
|
|
ImGui::EndChild();
|
|
}
|
|
|
|
bool toolbar_action(const char* id, const char* icon, const char* tooltip) {
|
|
ImGui::PushID(id);
|
|
const bool clicked = ImGui::Button(icon, {ui(36), ui(36)});
|
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) {
|
|
ImGui::BeginTooltip();
|
|
ImGui::TextUnformatted(tooltip);
|
|
ImGui::EndTooltip();
|
|
}
|
|
ImGui::PopID();
|
|
return clicked;
|
|
}
|
|
|
|
void external_link(const char* label, const char* url) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.31f, 0.76f, 0.92f, 1.0f));
|
|
if (ImGui::Selectable(label, false, ImGuiSelectableFlags_DontClosePopups)) {
|
|
std::string error;
|
|
if (!izo::open_path(url, &error)) g_notice = error.empty() ? "Unable to open link" : error;
|
|
}
|
|
ImGui::PopStyleColor();
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::SetTooltip("%s", url);
|
|
}
|
|
}
|
|
|
|
void draw_about_popup() {
|
|
if (g_about_popup) {
|
|
ImGui::OpenPopup("About Gitree");
|
|
g_about_popup = false;
|
|
}
|
|
if (!ImGui::BeginPopupModal("About Gitree", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return;
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.38f, 0.88f, 0.94f, 1.0f));
|
|
ImGui::SetWindowFontScale(1.35f);
|
|
ImGui::TextUnformatted("Gitree");
|
|
ImGui::SetWindowFontScale(1.0f);
|
|
ImGui::PopStyleColor();
|
|
ImGui::TextDisabled("Version %s", GITREE_VERSION);
|
|
ImGui::Dummy({ui(460), ui(8)});
|
|
ImGui::TextWrapped("A fast native Git client built with C++, Dear ImGui, GLFW, and libgit2.");
|
|
ImGui::Spacing();
|
|
external_link("Gitree project page", "https://dock-it.dev/iDENTITY-Technology/Gitree");
|
|
ImGui::Separator();
|
|
if (ImGui::Button("Licenses", {ui(110), 0})) {
|
|
g_licenses_popup = true;
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button("Close", {ui(90), 0})) ImGui::CloseCurrentPopup();
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
void draw_licenses_popup() {
|
|
if (g_licenses_popup) {
|
|
ImGui::OpenPopup("Licenses and dependencies");
|
|
g_licenses_popup = false;
|
|
}
|
|
if (!ImGui::BeginPopupModal("Licenses and dependencies", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return;
|
|
|
|
ImGui::TextUnformatted("Gitree");
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("Creative Commons Attribution-ShareAlike 4.0");
|
|
ImGui::TextDisabled("Third-party software included with Gitree:");
|
|
ImGui::Spacing();
|
|
|
|
if (ImGui::BeginTable("dependency_licenses", 2,
|
|
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit,
|
|
{ui(680), ui(270)})) {
|
|
ImGui::TableSetupColumn("Dependency", ImGuiTableColumnFlags_WidthFixed, ui(170));
|
|
ImGui::TableSetupColumn("License and source", ImGuiTableColumnFlags_WidthStretch);
|
|
ImGui::TableHeadersRow();
|
|
|
|
struct Dependency { const char* name; const char* license; const char* url; };
|
|
constexpr Dependency dependencies[] = {
|
|
{"libgit2 1.9.4", "GPL-2.0 with linking exception", "https://github.com/libgit2/libgit2"},
|
|
{"Dear ImGui 1.92.8", "MIT License", "https://github.com/ocornut/imgui"},
|
|
{"GLFW 3.4", "zlib/libpng License", "https://github.com/glfw/glfw"},
|
|
{"iZo 0.1.0", "MIT License", "https://dock-it.dev/Idea-Studios/iZo"},
|
|
{"Inter", "SIL Open Font License 1.1", "https://github.com/rsms/inter"},
|
|
{"Font Awesome Free", "SIL Open Font License 1.1", "https://github.com/FortAwesome/Font-Awesome"},
|
|
};
|
|
for (const auto& dependency : dependencies) {
|
|
ImGui::TableNextRow();
|
|
ImGui::TableSetColumnIndex(0);
|
|
ImGui::TextUnformatted(dependency.name);
|
|
ImGui::TableSetColumnIndex(1);
|
|
ImGui::TextDisabled("%s", dependency.license);
|
|
external_link(dependency.url, dependency.url);
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
ImGui::Spacing();
|
|
ImGui::TextDisabled("Complete license texts are included in the source tree with each dependency.");
|
|
if (ImGui::Button("Back", {ui(90), 0})) {
|
|
g_about_popup = true;
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button("Close", {ui(90), 0})) ImGui::CloseCurrentPopup();
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
void draw_popups() {
|
|
if (g_init_popup) { ImGui::OpenPopup("Create repository"); g_init_popup = false; }
|
|
if (ImGui::BeginPopupModal("Create repository", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
|
ImGui::TextUnformatted("Repository folder");
|
|
ImGui::SetNextItemWidth(ui(520.0f));
|
|
const bool enter = ImGui::InputText("##path", g_path.data(), g_path.size(), ImGuiInputTextFlags_EnterReturnsTrue);
|
|
if (enter || ImGui::Button("Create", {ui(90), 0})) {
|
|
if (init_repository(g_path.data())) ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button("Cancel", {ui(90), 0})) ImGui::CloseCurrentPopup();
|
|
if (!g_notice.empty()) ImGui::TextColored(ImVec4(0.96f, 0.55f, 0.35f, 1), "%s", g_notice.c_str());
|
|
ImGui::EndPopup();
|
|
}
|
|
draw_about_popup();
|
|
draw_licenses_popup();
|
|
}
|
|
|
|
void draw_footer() {
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("%s", g_notice.empty() ? "Ready" : g_notice.c_str());
|
|
const char* version = "Gitree " GITREE_VERSION;
|
|
ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(version).x - ui(18.0f));
|
|
ImGui::TextDisabled("%s", version);
|
|
}
|
|
|
|
void draw_new_tab() {
|
|
ImGui::BeginChild("new_tab", {-1, -ui(28.0f)}, ImGuiChildFlags_Borders);
|
|
ImGui::Dummy({0, ui(36)});
|
|
ImGui::SetCursorPosX(ui(42));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.38f, 0.88f, 0.94f, 1.0f));
|
|
ImGui::SetWindowFontScale(1.45f);
|
|
ImGui::TextUnformatted(ICON_FA_CODE_BRANCH " New Tab");
|
|
ImGui::SetWindowFontScale(1.0f);
|
|
ImGui::PopStyleColor();
|
|
ImGui::SetCursorPosX(ui(42));
|
|
ImGui::TextDisabled("Open a repository or return to a recently closed tab.");
|
|
ImGui::SetCursorPosX(ui(42));
|
|
if (ImGui::Button(ICON_FA_FOLDER_OPEN " Open repository", {ui(190), ui(38)}))
|
|
pick_and_open_repository();
|
|
|
|
ImGui::Dummy({0, ui(24)});
|
|
ImGui::SetCursorPosX(ui(42));
|
|
ImGui::TextUnformatted("RECENTLY CLOSED");
|
|
ImGui::SetCursorPosX(ui(42));
|
|
ImGui::BeginChild("recently_closed", {-ui(42), ui(300)}, ImGuiChildFlags_Borders);
|
|
const auto& recent = g_user_data->recentlyClosed();
|
|
if (recent.empty()) {
|
|
ImGui::TextDisabled("No recently closed repositories yet.");
|
|
} else {
|
|
for (int i = 0; i < static_cast<int>(recent.size()); ++i) {
|
|
const std::filesystem::path path(recent[i]);
|
|
std::string name = path.filename().string();
|
|
if (name.empty()) name = path.parent_path().filename().string();
|
|
ImGui::PushID(i);
|
|
if (ImGui::Selectable((std::string(ICON_FA_CLOCK_ROTATE_LEFT) + " " + name).c_str(), false,
|
|
ImGuiSelectableFlags_AllowDoubleClick, {0, ui(42)})) {
|
|
open_repository(recent[i].c_str());
|
|
}
|
|
ImGui::SameLine(ui(260));
|
|
ImGui::TextDisabled("%s", recent[i].c_str());
|
|
ImGui::PopID();
|
|
}
|
|
}
|
|
ImGui::EndChild();
|
|
ImGui::EndChild();
|
|
}
|
|
|
|
void draw_app() {
|
|
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
|
ImGui::SetNextWindowPos(viewport->WorkPos);
|
|
ImGui::SetNextWindowSize(viewport->WorkSize);
|
|
ImGui::Begin("Gitree", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
|
|
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_MenuBar);
|
|
|
|
if (ImGui::BeginMenuBar()) {
|
|
if (ImGui::BeginMenu("File")) {
|
|
if (ImGui::MenuItem("Open repository...", "Ctrl+O")) pick_and_open_repository();
|
|
if (ImGui::MenuItem("Create repository...", "Ctrl+N")) g_init_popup = true;
|
|
ImGui::Separator();
|
|
if (ImGui::MenuItem("Exit") && g_window_manager) g_window_manager->requestClose();
|
|
ImGui::EndMenu();
|
|
}
|
|
if (ImGui::BeginMenu("Edit")) { ImGui::MenuItem("Preferences", nullptr, false, false); ImGui::EndMenu(); }
|
|
if (ImGui::BeginMenu("View")) { ImGui::MenuItem("Refresh", "F5"); ImGui::EndMenu(); }
|
|
if (ImGui::BeginMenu("Help")) {
|
|
if (ImGui::MenuItem("About Gitree")) g_about_popup = true;
|
|
ImGui::EndMenu();
|
|
}
|
|
ImGui::EndMenuBar();
|
|
}
|
|
|
|
size_t tab_to_close = g_tabs.size();
|
|
bool add_tab = false;
|
|
if (ImGui::BeginTabBar("repositories", ImGuiTabBarFlags_AutoSelectNewTabs | ImGuiTabBarFlags_Reorderable)) {
|
|
for (size_t i = 0; i < g_tabs.size(); ++i) {
|
|
bool open = true;
|
|
const std::string label = g_tabs[i]->name + "###repo_tab_" + std::to_string(i);
|
|
const ImGuiTabItemFlags flags = i == g_active_tab ? ImGuiTabItemFlags_SetSelected : ImGuiTabItemFlags_None;
|
|
if (ImGui::BeginTabItem(label.c_str(), &open, flags)) {
|
|
g_active_tab = i;
|
|
ImGui::EndTabItem();
|
|
}
|
|
if (!open) tab_to_close = i;
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton(ICON_FA_PLUS "##new_tab")) add_tab = true;
|
|
ImGui::EndTabBar();
|
|
}
|
|
if (tab_to_close < g_tabs.size()) close_tab(tab_to_close);
|
|
if (add_tab) create_new_tab();
|
|
ImGui::Separator();
|
|
|
|
if (!repo().repo) {
|
|
draw_new_tab();
|
|
draw_footer();
|
|
draw_popups();
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
ImGui::BeginChild("repo_toolbar", {-1, ui(56)}, ImGuiChildFlags_Borders);
|
|
ImGui::TextDisabled("repository");
|
|
ImGui::SameLine(ui(145)); ImGui::TextDisabled("branch");
|
|
ImGui::SetCursorPosY(ui(26));
|
|
ImGui::Text("%s", repo().name.c_str());
|
|
ImGui::SameLine(ui(145));
|
|
ImGui::SetNextItemWidth(ui(180));
|
|
if (ImGui::BeginCombo("##branch", repo().branch.c_str())) {
|
|
for (const auto& branch : repo().local_branches) ImGui::Selectable(branch.c_str(), branch == repo().branch);
|
|
ImGui::EndCombo();
|
|
}
|
|
constexpr int action_count = 6;
|
|
const float action_size = ui(36.0f);
|
|
const float action_spacing = ui(7.0f);
|
|
const float action_group_width = action_size * action_count + action_spacing * (action_count - 1);
|
|
const float centered_x = (ImGui::GetWindowWidth() - action_group_width) * 0.5f;
|
|
ImGui::SetCursorPos({std::max(ui(350.0f), centered_x), ui(10.0f)});
|
|
|
|
if (toolbar_action("pull", ICON_FA_DOWNLOAD, "Pull from remote")) g_notice = "Pull is not wired yet";
|
|
ImGui::SameLine(0, action_spacing);
|
|
if (toolbar_action("push", ICON_FA_UPLOAD, "Push to remote")) g_notice = "Push is not wired yet";
|
|
ImGui::SameLine(0, action_spacing);
|
|
if (toolbar_action("branch", ICON_FA_CODE_BRANCH, "Create branch")) g_notice = "Branch creation is not wired yet";
|
|
ImGui::SameLine(0, action_spacing);
|
|
if (toolbar_action("stash", ICON_FA_BOX_ARCHIVE, "Stash changes")) g_notice = "Stash is not wired yet";
|
|
ImGui::SameLine(0, action_spacing);
|
|
if (toolbar_action("pop", ICON_FA_BOX_OPEN, "Pop latest stash")) g_notice = "Stash pop is not wired yet";
|
|
ImGui::SameLine(0, action_spacing);
|
|
if (toolbar_action("refresh", ICON_FA_ROTATE, "Refresh repository")) load_repository_data();
|
|
ImGui::EndChild();
|
|
|
|
draw_sidebar(ui(g_sidebar_width));
|
|
ImGui::SameLine(0, 0);
|
|
ImGui::InvisibleButton("##sidebar_splitter", {ui(6.0f), -ui(28.0f)});
|
|
if (ImGui::IsItemHovered() || ImGui::IsItemActive()) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW);
|
|
if (ImGui::IsItemActive()) {
|
|
g_sidebar_width = std::clamp(g_sidebar_width + ImGui::GetIO().MouseDelta.x / g_ui_scale, 180.0f, 520.0f);
|
|
}
|
|
const ImVec2 splitter_min = ImGui::GetItemRectMin();
|
|
const ImVec2 splitter_max = ImGui::GetItemRectMax();
|
|
ImGui::GetWindowDrawList()->AddLine(
|
|
{(splitter_min.x + splitter_max.x) * 0.5f, splitter_min.y},
|
|
{(splitter_min.x + splitter_max.x) * 0.5f, splitter_max.y},
|
|
ImGui::IsItemHovered() || ImGui::IsItemActive() ? IM_COL32(36, 178, 205, 255) : IM_COL32(62, 72, 88, 255),
|
|
ui(1.0f));
|
|
ImGui::SameLine(0, 0);
|
|
const float detail_width = ui(368.0f);
|
|
ImGui::BeginChild("center", {ImGui::GetContentRegionAvail().x - detail_width, -24.0f}, false);
|
|
draw_commit_table();
|
|
ImGui::EndChild();
|
|
ImGui::SameLine();
|
|
draw_details();
|
|
|
|
draw_footer();
|
|
draw_popups();
|
|
ImGui::End();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
git_libgit2_init();
|
|
UserData user_data;
|
|
g_user_data = &user_data;
|
|
g_sidebar_width = user_data.sidebarWidth();
|
|
create_new_tab();
|
|
|
|
WindowManager window_manager;
|
|
if (!window_manager.create("Gitree", 1500, 900)) return 1;
|
|
g_window_manager = &window_manager;
|
|
|
|
IMGUI_CHECKVERSION();
|
|
ImGui::CreateContext();
|
|
ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
|
ImGui::GetIO().IniFilename = user_data.imguiIniPath().c_str();
|
|
load_fonts(window_manager.dpiScale());
|
|
ImGui_ImplGlfw_InitForOpenGL(window_manager.nativeWindow(), true);
|
|
ImGui_ImplOpenGL3_Init("#version 330");
|
|
|
|
if (argc > 1) open_repository(argv[1]);
|
|
|
|
while (!window_manager.shouldClose()) {
|
|
window_manager.pollEvents();
|
|
if (window_manager.consumeDpiChange()) load_fonts(window_manager.dpiScale());
|
|
ImGui_ImplOpenGL3_NewFrame();
|
|
ImGui_ImplGlfw_NewFrame();
|
|
ImGui::NewFrame();
|
|
draw_app();
|
|
ImGui::Render();
|
|
int width = 0, height = 0;
|
|
glfwGetFramebufferSize(window_manager.nativeWindow(), &width, &height);
|
|
glViewport(0, 0, width, height);
|
|
glClearColor(0.10f, 0.116f, 0.145f, 1.0f);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
|
window_manager.swapBuffers();
|
|
}
|
|
|
|
ImGui_ImplOpenGL3_Shutdown();
|
|
ImGui_ImplGlfw_Shutdown();
|
|
user_data.setSidebarWidth(g_sidebar_width);
|
|
user_data.save();
|
|
ImGui::DestroyContext();
|
|
g_window_manager = nullptr;
|
|
g_tabs.clear();
|
|
g_user_data = nullptr;
|
|
git_libgit2_shutdown();
|
|
return 0;
|
|
}
|