29 Commits

Author SHA1 Message Date
6ecbd597b6 fix(build): revert libgit2 submodule to upstream commit to resolve CI checkout error
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 2m1s
Build Releases / Build Windows x64 (push) Successful in 8m28s
Build Releases / Create Release (push) Successful in 2m3s
2026-06-19 15:24:33 -05:00
2c07bb0132 Merge branch 'main' into prod
Some checks failed
Build Releases / Build Windows x64 (push) Failing after 1m9s
Build Releases / Build Linux x64 DEB (push) Failing after 1m13s
Build Releases / Create Release (push) Failing after 10s
2026-06-19 15:17:17 -05:00
1114c05cb9 Not sure
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m14s
Build Releases / Build Windows x64 (push) Failing after 1m20s
Build Releases / Create Release (push) Has been skipped
2026-06-19 15:12:11 -05:00
52ed46a203 Merge remote-tracking branch 'origin/prod' into prod
Some checks failed
Build Releases / Build Windows x64 (push) Failing after 1m2s
Build Releases / Build Linux x64 DEB (push) Failing after 1m10s
Build Releases / Create Release (push) Failing after 11s
2026-06-19 15:10:08 -05:00
9ebf3699ad fix(diff): make code minimap scrollable and compact 2026-06-19 00:50:55 -05:00
258973da57 feat(auth): prompt and store remote credentials 2026-06-19 00:47:37 -05:00
4b846a62cd fix(ui): animate toolbar action spinners 2026-06-19 00:38:38 -05:00
4dab8e8a9a Merge branch 'main' into prod 2026-06-19 00:37:04 -05:00
76634c3fd3 fix(ui): finish refresh and diff navigation polish 2026-06-19 00:36:53 -05:00
1c730302d5 perf(startup): defer repository loading 2026-06-19 00:36:43 -05:00
93f67534c6 fix(ui): reset diff viewer scroll on open 2026-06-19 00:35:41 -05:00
5ba7d46809 feat(files): use tabler icons for committed tree entries 2026-06-19 00:34:37 -05:00
f53efe92c4 fix(ui): resize top bar action labels 2026-06-19 00:33:26 -05:00
f7f95e28ae fix(ui): use folder tree icon for submodules
Some checks failed
Build Releases / Build Windows x64 (push) Failing after 1m13s
Build Releases / Build Linux x64 DEB (push) Failing after 1m23s
Build Releases / Create Release (push) Has been skipped
2026-06-19 00:32:21 -05:00
8d59d066a6 fix(build): silence libgit2 and app icon warnings 2026-06-19 00:29:46 -05:00
aebfe65352 fix(ui): ellipsize blame attribution text 2026-06-19 00:27:45 -05:00
b6fdec12f5 fix(graph): avoid lane collisions and dead ends 2026-06-19 00:21:49 -05:00
5ac621d7b5 feat(ui): add hover-paused toast notifications 2026-06-19 00:18:18 -05:00
6361002f53 perf(git): move refresh and actions off the UI thread 2026-06-19 00:15:50 -05:00
e74e6ec513 fix(refresh): resync after toolbar and branch actions
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m32s
Build Releases / Build Windows x64 (push) Successful in 7m2s
Build Releases / Create Release (push) Has been skipped
2026-06-19 00:09:21 -05:00
279fe6e7f9 fix(ui): return to main view after staging diff 2026-06-19 00:08:08 -05:00
79bd00d84d fix(shortcuts): wire repository menu accelerators 2026-06-19 00:07:22 -05:00
eeb134c6ab feat(layout): persist sidebar and commit table state 2026-06-19 00:04:41 -05:00
1ac26e3e36 chore(submodules): point libgit2 at mirror
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m58s
Build Releases / Build Windows x64 (push) Successful in 6m39s
Build Releases / Create Release (push) Has been skipped
2026-06-19 00:02:51 -05:00
b94ca105de feat(viewer): restyle toolbar and add line wrap toggle 2026-06-18 23:59:49 -05:00
60d1e67fbe chore(submodules): point imgui and glfw at mirrors
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m37s
Build Releases / Create Release (push) Has been cancelled
Build Releases / Build Windows x64 (push) Has been cancelled
2026-06-18 23:57:12 -05:00
5c9047b444 feat(viewer): overhaul code minimap interaction 2026-06-18 23:56:45 -05:00
45a4ceb72a fix(sidebar): remove section header hover fill 2026-06-18 23:51:10 -05:00
f869ecf46b fix(sidebar): simplify filter placeholder text 2026-06-18 23:49:59 -05:00
11 changed files with 1999 additions and 440 deletions

6
.gitmodules vendored
View File

@@ -1,12 +1,12 @@
[submodule "vendor/libgit2"]
path = vendor/libgit2
url = https://github.com/libgit2/libgit2.git
url = https://dock-it.dev/Idea-Studios/libgit2
[submodule "vendor/imgui"]
path = vendor/imgui
url = https://github.com/ocornut/imgui.git
url = https://dock-it.dev/Idea-Studios/imgui
[submodule "vendor/glfw"]
path = vendor/glfw
url = https://github.com/glfw/glfw.git
url = https://dock-it.dev/Idea-Studios/glfw
[submodule "vendor/iZo"]
path = vendor/iZo
url = https://dock-it.dev/Idea-Studios/iZo

View File

@@ -86,7 +86,7 @@ target_compile_definitions(gitree PRIVATE
)
if(WIN32)
target_link_libraries(gitree PRIVATE dwmapi urlmon windowscodecs bcrypt)
target_link_libraries(gitree PRIVATE dwmapi urlmon windowscodecs bcrypt crypt32)
endif()
if(MSVC)

View File

@@ -4,10 +4,12 @@
#include <izo/Time.hpp>
#include <algorithm>
#include <array>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#ifdef _WIN32
@@ -80,6 +82,83 @@ namespace
return action;
}
void populateRepositoryIdentity(RepositoryView &repository)
{
const char *workdir = git_repository_workdir(repository.repo);
repository.path = workdir ? workdir : git_repository_path(repository.repo);
std::filesystem::path path(repository.path);
repository.name = path.filename().string();
if (repository.name.empty())
repository.name = path.parent_path().filename().string();
repository.branch = "detached";
git_reference *head = nullptr;
if (git_repository_head(&head, repository.repo) == 0)
{
if (const char *name = git_reference_shorthand(head))
repository.branch = name;
git_reference_free(head);
}
}
std::vector<std::string> repositoryRemotes(git_repository *repository)
{
std::vector<std::string> remotes;
git_strarray names{};
if (git_remote_list(&names, repository) == 0)
{
for (size_t i = 0; i < names.count; ++i)
remotes.emplace_back(names.strings[i]);
git_strarray_dispose(&names);
}
return remotes;
}
std::string preferredRemoteName(git_repository *repository)
{
const std::vector<std::string> remotes = repositoryRemotes(repository);
if (remotes.empty())
return {};
const auto origin = std::find(remotes.begin(), remotes.end(), "origin");
return origin != remotes.end() ? *origin : remotes.front();
}
#ifdef _WIN32
std::string encodeBase64(std::string_view value)
{
static constexpr std::string_view alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string encoded;
encoded.reserve(((value.size() + 2) / 3) * 4);
for (size_t index = 0; index < value.size(); index += 3)
{
const uint32_t a = static_cast<unsigned char>(value[index]);
const uint32_t b = index + 1 < value.size() ? static_cast<unsigned char>(value[index + 1]) : 0;
const uint32_t c = index + 2 < value.size() ? static_cast<unsigned char>(value[index + 2]) : 0;
const uint32_t chunk = (a << 16) | (b << 8) | c;
encoded.push_back(alphabet[(chunk >> 18) & 0x3F]);
encoded.push_back(alphabet[(chunk >> 12) & 0x3F]);
encoded.push_back(index + 1 < value.size() ? alphabet[(chunk >> 6) & 0x3F] : '=');
encoded.push_back(index + 2 < value.size() ? alphabet[chunk & 0x3F] : '=');
}
return encoded;
}
#endif
std::vector<std::string> withAuthOverrideArguments(std::vector<std::string> arguments,
const std::optional<GitAuthOverride> &auth)
{
if (!auth || auth->username.empty())
return arguments;
const std::string header = "http.extraHeader=Authorization: Basic " +
encodeBase64(auth->username + ":" + auth->password);
arguments.insert(arguments.begin(), {
"-c", "credential.interactive=never",
"-c", header,
});
return arguments;
}
void addBadge(RepositoryView &repository, const git_oid *oid, RefBadge badge)
{
if (!oid)
@@ -369,54 +448,65 @@ void GitManager::computeGraphLanes(RepositoryView &repository)
{
git_oid expected{};
};
const auto lane_for_commit = [](std::vector<Lane> &lanes, const git_oid &oid) {
const auto found = std::find_if(lanes.begin(), lanes.end(), [&oid](const Lane &lane)
{ return git_oid_equal(&lane.expected, &oid) != 0; });
if (found != lanes.end())
return static_cast<size_t>(std::distance(lanes.begin(), found));
lanes.push_back({oid});
return lanes.size() - 1;
};
const auto contains_oid = [](const std::vector<Lane> &lanes, const git_oid &oid) {
return std::any_of(lanes.begin(), lanes.end(), [&oid](const Lane &lane)
{ return git_oid_equal(&lane.expected, &oid) != 0; });
};
std::vector<Lane> lanes;
for (auto &commit : repository.commits)
{
auto current = std::find_if(lanes.begin(), lanes.end(), [&commit](const Lane &lane)
{ return git_oid_equal(&lane.expected, &commit.oid) != 0; });
if (current == lanes.end())
{
lanes.push_back({commit.oid});
current = std::prev(lanes.end());
}
const size_t commit_lane = static_cast<size_t>(std::distance(lanes.begin(), current));
size_t active_lane = commit_lane;
size_t active_lane = lane_for_commit(lanes, commit.oid);
const size_t commit_lane = active_lane;
commit.lane = static_cast<int>(commit_lane);
commit.graph_color = commit.lane;
for (size_t duplicate = lanes.size(); duplicate-- > 0;)
{
if (duplicate != active_lane &&
git_oid_equal(&lanes[duplicate].expected, &commit.oid) != 0)
{
lanes.erase(lanes.begin() + static_cast<std::ptrdiff_t>(duplicate));
if (duplicate < active_lane)
--active_lane;
}
}
if (commit.parent_ids.empty())
{
lanes.erase(lanes.begin() + static_cast<std::ptrdiff_t>(active_lane));
continue;
}
lanes[active_lane].expected = commit.parent_ids.front();
size_t insert_lane = active_lane + 1;
for (size_t parent = 1; parent < commit.parent_ids.size(); ++parent)
{
if (contains_oid(lanes, commit.parent_ids[parent]))
continue;
lanes.insert(lanes.begin() + static_cast<std::ptrdiff_t>(std::min(insert_lane, lanes.size())),
{commit.parent_ids[parent]});
++insert_lane;
}
for (size_t duplicate = lanes.size(); duplicate-- > 0;)
{
if (duplicate != active_lane &&
git_oid_equal(&lanes[duplicate].expected, &commit.parent_ids.front()) != 0)
if (duplicate == active_lane)
continue;
if (git_oid_equal(&lanes[duplicate].expected, &commit.oid) != 0)
{
lanes.erase(lanes.begin() + static_cast<std::ptrdiff_t>(duplicate));
if (duplicate < active_lane)
--active_lane;
}
}
for (size_t parent = 1; parent < commit.parent_ids.size(); ++parent)
for (size_t left = 0; left < lanes.size(); ++left)
{
const auto found = std::find_if(lanes.begin(), lanes.end(), [&commit, parent](const Lane &candidate)
{ return git_oid_equal(&candidate.expected, &commit.parent_ids[parent]) != 0; });
if (found == lanes.end())
lanes.insert(lanes.begin() + static_cast<std::ptrdiff_t>(
std::min(active_lane + parent, lanes.size())),
{commit.parent_ids[parent]});
for (size_t right = lanes.size(); right-- > left + 1;)
{
if (git_oid_equal(&lanes[left].expected, &lanes[right].expected) != 0)
lanes.erase(lanes.begin() + static_cast<std::ptrdiff_t>(right));
}
}
}
}
@@ -528,26 +618,15 @@ bool GitManager::loadRepositoryData(RepositoryView &repository, std::string &err
repository.commits.clear();
repository.selected_commit = 0;
repository.scroll_to_commit = -1;
repository.undo_action = {};
repository.redo_action = {};
if (repository.commit_walk)
git_revwalk_free(repository.commit_walk);
repository.commit_walk = nullptr;
repository.history_exhausted = false;
loadWorkingTree(repository);
const char *workdir = git_repository_workdir(repository.repo);
repository.path = workdir ? workdir : git_repository_path(repository.repo);
std::filesystem::path path(repository.path);
repository.name = path.filename().string();
if (repository.name.empty())
repository.name = path.parent_path().filename().string();
populateRepositoryIdentity(repository);
loadToolbarHistoryActions(repository);
git_reference *head = nullptr;
if (git_repository_head(&head, repository.repo) == 0)
{
if (const char *name = git_reference_shorthand(head))
repository.branch = name;
git_reference_free(head);
}
readBranches(repository, GIT_BRANCH_LOCAL, repository.local_branches);
readBranches(repository, GIT_BRANCH_REMOTE, repository.remote_branches);
loadBranchDivergence(repository);
@@ -672,7 +751,7 @@ bool GitManager::loadMoreCommits(RepositoryView &repository, size_t page_size, s
return true;
}
bool GitManager::openRepository(RepositoryView &repository, const std::string &path, std::string &error)
bool GitManager::openRepositoryHandle(RepositoryView &repository, const std::string &path, std::string &error)
{
git_repository *opened = nullptr;
if (git_repository_open_ext(&opened, path.c_str(), 0, nullptr) != 0)
@@ -682,6 +761,14 @@ bool GitManager::openRepository(RepositoryView &repository, const std::string &p
}
repository.close();
repository.repo = opened;
populateRepositoryIdentity(repository);
return true;
}
bool GitManager::openRepository(RepositoryView &repository, const std::string &path, std::string &error)
{
if (!openRepositoryHandle(repository, path, error))
return false;
return loadRepositoryData(repository, error);
}
@@ -726,10 +813,11 @@ 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)
const char *success_message, std::string &message, bool reload_repository,
const std::optional<GitAuthOverride> &auth)
{
std::string command_output;
if (!captureGit(repository, arguments, command_output, message))
if (!captureGit(repository, withAuthOverrideArguments(arguments, auth), command_output, message))
return false;
if (reload_repository)
{
@@ -745,7 +833,8 @@ bool GitManager::runGit(RepositoryView &repository, const std::vector<std::strin
}
bool GitManager::captureGit(RepositoryView &repository, const std::vector<std::string> &arguments,
std::string &command_output, std::string &error)
std::string &command_output, std::string &error,
const std::string *stdin_data)
{
if (!repository.repo)
{
@@ -762,10 +851,24 @@ bool GitManager::captureGit(RepositoryView &repository, const std::vector<std::s
return false;
}
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE input_read = nullptr;
HANDLE input_write = nullptr;
if (stdin_data && !CreatePipe(&input_read, &input_write, &security, 0))
{
DeleteFileW(output_path);
error = "Unable to create Git command input pipe";
return false;
}
if (input_write)
SetHandleInformation(input_write, HANDLE_FLAG_INHERIT, 0);
HANDLE output = CreateFileW(output_path, GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY, nullptr);
if (output == INVALID_HANDLE_VALUE)
{
if (input_read)
CloseHandle(input_read);
if (input_write)
CloseHandle(input_write);
DeleteFileW(output_path);
error = "Unable to capture Git command output";
return false;
@@ -782,18 +885,34 @@ bool GitManager::captureGit(RepositoryView &repository, const std::vector<std::s
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdOutput = output;
startup.hStdError = output;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
startup.hStdInput = input_read ? input_read : GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION process{};
const BOOL started = CreateProcessW(nullptr, mutable_command.data(), nullptr, nullptr, TRUE,
CREATE_NO_WINDOW, nullptr, nullptr, &startup, &process);
DWORD exit_code = 1;
if (started)
{
if (input_read)
{
CloseHandle(input_read);
input_read = nullptr;
}
if (input_write && stdin_data)
{
DWORD bytes_written = 0;
WriteFile(input_write, stdin_data->data(), static_cast<DWORD>(stdin_data->size()), &bytes_written, nullptr);
CloseHandle(input_write);
input_write = nullptr;
}
WaitForSingleObject(process.hProcess, INFINITE);
GetExitCodeProcess(process.hProcess, &exit_code);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
if (input_read)
CloseHandle(input_read);
if (input_write)
CloseHandle(input_write);
FlushFileBuffers(output);
SetFilePointer(output, 0, nullptr, FILE_BEGIN);
command_output.clear();
@@ -824,7 +943,7 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err
return true;
bool uses_https = false;
for (const std::string &remote_name : repository.remotes)
for (const std::string &remote_name : repositoryRemotes(repository.repo))
{
git_remote *remote = nullptr;
if (git_remote_lookup(&remote, repository.repo, remote_name.c_str()) == 0)
@@ -865,9 +984,8 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err
if (!gcm_available)
{
error = "This HTTPS remote needs authentication, but no credential helper is configured. "
"Install Git Credential Manager or configure credential.helper in Git.";
return false;
repository.credentials_checked = true;
return true;
}
std::string configure_output;
@@ -878,6 +996,78 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err
return true;
}
std::string GitManager::remoteUrl(RepositoryView &repository, const std::string &remote_name, std::string &error)
{
git_remote *remote = nullptr;
if (git_remote_lookup(&remote, repository.repo, remote_name.c_str()) != 0)
{
error = lastError("Unable to find remote");
return {};
}
const char *url = git_remote_url(remote);
const std::string remote_url = url ? url : "";
git_remote_free(remote);
if (remote_url.empty())
error = "Remote has no URL";
return remote_url;
}
bool GitManager::storeCredential(RepositoryView &repository, const std::string &remote_url,
const std::string &username, const std::string &password,
std::string &error)
{
if (username.empty())
{
error = "Username is required";
return false;
}
if (!prepareCredentials(repository, error))
return false;
std::string helper;
std::string helper_error;
if (!captureGit(repository, {"config", "--get-all", "credential.helper"}, helper, helper_error) || helper.empty())
{
error = helper_error.empty() ? "No Git credential helper is configured" : helper_error;
return false;
}
const size_t scheme = remote_url.find("://");
if (scheme == std::string::npos)
{
error = "Remote URL is invalid";
return false;
}
const size_t authority_begin = scheme + 3;
const size_t path_begin = remote_url.find('/', authority_begin);
const std::string protocol = remote_url.substr(0, scheme);
const std::string host = path_begin == std::string::npos
? remote_url.substr(authority_begin)
: remote_url.substr(authority_begin, path_begin - authority_begin);
const std::string path = path_begin == std::string::npos ? "/" : remote_url.substr(path_begin);
std::ostringstream input;
input << "protocol=" << protocol << "\n";
input << "host=" << host << "\n";
input << "path=" << path << "\n";
input << "username=" << username << "\n";
input << "password=" << password << "\n\n";
std::string output;
std::string payload = input.str();
return captureGit(repository, {"credential", "approve"}, output, error, &payload);
}
bool GitManager::isAuthenticationFailure(std::string_view message)
{
std::string lowered(message);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char value)
{ return static_cast<char>(std::tolower(value)); });
return lowered.find(" 403") != std::string::npos ||
lowered.find(" 401") != std::string::npos ||
lowered.find("forbidden") != std::string::npos ||
lowered.find("authentication failed") != std::string::npos ||
lowered.find("access denied") != std::string::npos ||
lowered.find("could not read username") != std::string::npos ||
lowered.find("terminal prompts disabled") != std::string::npos;
}
bool GitManager::applyPatch(RepositoryView &repository, const std::string &patch, bool cached,
bool reverse, std::string &error)
{
@@ -908,22 +1098,24 @@ bool GitManager::applyPatch(RepositoryView &repository, const std::string &patch
return applied;
}
bool GitManager::fetch(RepositoryView &repository, const std::string &remote, std::string &error)
bool GitManager::fetch(RepositoryView &repository, const std::string &remote, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
const std::vector<std::string> args = remote.empty()
? std::vector<std::string>{"fetch", "--all", "--prune"}
: std::vector<std::string>{"fetch", remote, "--prune"};
return runGit(repository, args, "Fetch complete", error);
return runGit(repository, args, "Fetch complete", error, true, auth);
}
bool GitManager::pull(RepositoryView &repository, int mode, std::string &error)
bool GitManager::pull(RepositoryView &repository, int mode, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
if (mode == 0)
return fetch(repository, {}, error);
return fetch(repository, {}, error, auth);
std::vector<std::string> args{"pull"};
if (mode == 1)
args.push_back("--ff");
@@ -931,26 +1123,25 @@ bool GitManager::pull(RepositoryView &repository, int mode, std::string &error)
args.push_back("--ff-only");
else if (mode == 3)
args.push_back("--rebase");
return runGit(repository, args, "Pull complete", error);
return runGit(repository, args, "Pull complete", error, true, auth);
}
bool GitManager::push(RepositoryView &repository, std::string &error)
bool GitManager::push(RepositoryView &repository, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
if (runGit(repository, {"push"}, "Push complete", error))
if (runGit(repository, {"push"}, "Push complete", error, true, auth))
return true;
if (repository.remotes.empty())
const std::string remote = preferredRemoteName(repository.repo);
if (remote.empty())
return false;
const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") !=
repository.remotes.end()
? "origin"
: repository.remotes.front();
return runGit(repository, {"push", "--set-upstream", remote, repository.branch},
"Push complete; upstream configured", error);
"Push complete; upstream configured", error, true, auth);
}
bool GitManager::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error)
bool GitManager::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
@@ -982,20 +1173,16 @@ bool GitManager::pushBranch(RepositoryView &repository, const std::string &branc
if (!remote_name.empty() && !remote_branch_name.empty())
return runGit(repository, {"push", remote_name, branch + ":" + remote_branch_name},
"Push complete", error);
"Push complete", error, true, auth);
if (repository.remotes.empty())
const std::string remote = preferredRemoteName(repository.repo);
if (remote.empty())
{
error = "No remote is configured for this repository";
return false;
}
const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") !=
repository.remotes.end()
? "origin"
: repository.remotes.front();
return runGit(repository, {"push", "--set-upstream", remote, branch + ":" + branch},
"Push complete; upstream configured", error);
"Push complete; upstream configured", error, true, auth);
}
bool GitManager::stash(RepositoryView &repository, std::string &error)
@@ -1250,6 +1437,27 @@ bool GitManager::checkoutBranch(RepositoryView &repository, const std::string &b
return true;
}
bool GitManager::mergeBranch(RepositoryView &repository, const std::string &source_branch,
const std::string &target_branch, std::string &error)
{
if (source_branch.empty() || target_branch.empty())
{
error = "Unable to merge branches";
return false;
}
if (source_branch == target_branch)
{
error = "A branch cannot be merged into itself";
return false;
}
if (repository.branch != target_branch && !checkoutBranch(repository, target_branch, error))
return false;
if (!runGit(repository, {"merge", "--no-ff", "--no-edit", source_branch}, "Branches merged", error))
return false;
error = "Merged " + source_branch + " into " + target_branch;
return true;
}
bool GitManager::updateSubmodule(RepositoryView &repository, const std::string &name, std::string &error)
{
git_submodule *submodule = nullptr;

View File

@@ -1,69 +1,91 @@
#pragma once
#pragma once
#include "models/repository.h"
#include <optional>
#include <string>
#include <vector>
struct GitAuthOverride
{
std::string remote_url;
std::string username;
std::string password;
};
class GitManager
{
public:
GitManager();
~GitManager();
bool openRepositoryHandle(RepositoryView &repository, const std::string &path, std::string &error);
bool openRepository(RepositoryView &repository, const std::string &path, std::string &error);
bool initializeRepository(RepositoryView &repository, const std::string &path,
const std::string &initial_branch, std::string &error);
bool cloneRepository(RepositoryView &repository, const std::string &url,
const std::string &path, bool shallow, std::string &error);
bool reload(RepositoryView &repository, std::string &error);
bool cloneRepository(RepositoryView &repository, const std::string &url,
const std::string &path, bool shallow, std::string &error);
bool reload(RepositoryView &repository, std::string &error);
bool loadMoreCommits(RepositoryView &repository, size_t page_size, std::string &error);
bool checkoutBranch(RepositoryView &repository, const std::string &branch, std::string &error);
bool fetch(RepositoryView &repository, const std::string &remote, std::string &error);
bool pull(RepositoryView &repository, int mode, std::string &error);
bool push(RepositoryView &repository, std::string &error);
bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error);
bool mergeBranch(RepositoryView &repository, const std::string &source_branch,
const std::string &target_branch, std::string &error);
bool fetch(RepositoryView &repository, const std::string &remote, std::string &error,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool pull(RepositoryView &repository, int mode, std::string &error,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool push(RepositoryView &repository, std::string &error,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool stash(RepositoryView &repository, std::string &error);
bool popStash(RepositoryView &repository, std::string &error);
bool undoCommit(RepositoryView &repository, std::string &error);
bool redoCommit(RepositoryView &repository, std::string &error);
bool stageAll(RepositoryView &repository, std::string &error);
bool unstageAll(RepositoryView &repository, std::string &error);
bool stageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool unstageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool discardFile(RepositoryView &repository, const std::string &path, std::string &error);
bool discardAll(RepositoryView &repository, std::string &error);
bool commit(RepositoryView &repository, const std::string &summary,
const std::string &description, bool amend, std::string &error);
bool createBranch(RepositoryView &repository, const std::string &name,
const std::string &start_point, bool checkout, std::string &error);
bool createTag(RepositoryView &repository, const std::string &name,
const std::string &target, std::string &error);
bool addRemote(RepositoryView &repository, const std::string &name,
const std::string &url, std::string &error);
bool addWorktree(RepositoryView &repository, const std::string &path,
const std::string &branch, std::string &error);
bool addSubmodule(RepositoryView &repository, const std::string &url,
const std::string &path, std::string &error);
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 undoCommit(RepositoryView &repository, std::string &error);
bool redoCommit(RepositoryView &repository, std::string &error);
bool stageAll(RepositoryView &repository, std::string &error);
bool unstageAll(RepositoryView &repository, std::string &error);
bool stageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool unstageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool discardFile(RepositoryView &repository, const std::string &path, std::string &error);
bool discardAll(RepositoryView &repository, std::string &error);
bool commit(RepositoryView &repository, const std::string &summary,
const std::string &description, bool amend, std::string &error);
bool createBranch(RepositoryView &repository, const std::string &name,
const std::string &start_point, bool checkout, std::string &error);
bool createTag(RepositoryView &repository, const std::string &name,
const std::string &target, std::string &error);
bool addRemote(RepositoryView &repository, const std::string &name,
const std::string &url, std::string &error);
bool addWorktree(RepositoryView &repository, const std::string &path,
const std::string &branch, std::string &error);
bool addSubmodule(RepositoryView &repository, const std::string &url,
const std::string &path, std::string &error);
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 loadCommitFiles(RepositoryView &repository, int commit_index, std::string &error);
bool captureGit(RepositoryView &repository, const std::vector<std::string> &arguments,
std::string &output, std::string &error);
std::string &output, std::string &error,
const std::string *stdin_data = nullptr);
bool applyPatch(RepositoryView &repository, const std::string &patch, bool cached,
bool reverse, std::string &error);
std::string remoteUrl(RepositoryView &repository, const std::string &remote_name, std::string &error);
bool storeCredential(RepositoryView &repository, const std::string &remote_url,
const std::string &username, const std::string &password,
std::string &error);
static bool isAuthenticationFailure(std::string_view message);
private:
static std::string lastError(const char *fallback);
static std::string formatTime(git_time_t value, int offset_minutes);
void loadToolbarHistoryActions(RepositoryView &repository);
void readBranches(RepositoryView &repository, git_branch_t type, std::vector<std::string> &output);
void loadBranchDivergence(RepositoryView &repository);
void loadRefBadges(RepositoryView &repository);
void computeGraphLanes(RepositoryView &repository);
bool loadRepositoryData(RepositoryView &repository, std::string &error);
static std::string lastError(const char *fallback);
static std::string formatTime(git_time_t value, int offset_minutes);
void loadToolbarHistoryActions(RepositoryView &repository);
void readBranches(RepositoryView &repository, git_branch_t type, std::vector<std::string> &output);
void loadBranchDivergence(RepositoryView &repository);
void loadRefBadges(RepositoryView &repository);
void computeGraphLanes(RepositoryView &repository);
bool loadRepositoryData(RepositoryView &repository, std::string &error);
void loadWorkingTree(RepositoryView &repository);
bool prepareCredentials(RepositoryView &repository, std::string &error);
bool runGit(RepositoryView &repository, const std::vector<std::string> &arguments,
const char *success_message, std::string &message, bool reload = true);
const char *success_message, std::string &message, bool reload = true,
const std::optional<GitAuthOverride> &auth = std::nullopt);
};

View File

@@ -10,8 +10,15 @@ extern "C"
#include <algorithm>
#include <fstream>
#include <iomanip>
#include <optional>
#include <sstream>
#include <utility>
#ifdef _WIN32
#include <windows.h>
#include <wincrypt.h>
#endif
namespace
{
std::filesystem::path roaming_directory()
@@ -28,6 +35,89 @@ namespace
const ikv_node_t *value = object ? ikv_object_get(object, key) : nullptr;
return value && ikv_node_type(value) == type ? value : nullptr;
}
std::optional<std::pair<std::string, std::string>> splitCredentialPayload(const std::string &payload)
{
const size_t separator = payload.find('\n');
if (separator == std::string::npos)
return std::nullopt;
return std::pair<std::string, std::string>{
payload.substr(0, separator),
payload.substr(separator + 1)};
}
#ifdef _WIN32
std::string hexEncode(const std::string &value)
{
static constexpr char digits[] = "0123456789abcdef";
std::string encoded;
encoded.reserve(value.size() * 2);
for (const unsigned char byte : value)
{
encoded.push_back(digits[(byte >> 4) & 0x0F]);
encoded.push_back(digits[byte & 0x0F]);
}
return encoded;
}
std::optional<std::string> hexDecode(const std::string &value)
{
if (value.size() % 2 != 0)
return std::nullopt;
auto decode_nibble = [](char character) -> int
{
if (character >= '0' && character <= '9')
return character - '0';
if (character >= 'a' && character <= 'f')
return 10 + (character - 'a');
if (character >= 'A' && character <= 'F')
return 10 + (character - 'A');
return -1;
};
std::string decoded;
decoded.reserve(value.size() / 2);
for (size_t index = 0; index < value.size(); index += 2)
{
const int high = decode_nibble(value[index]);
const int low = decode_nibble(value[index + 1]);
if (high < 0 || low < 0)
return std::nullopt;
decoded.push_back(static_cast<char>((high << 4) | low));
}
return decoded;
}
std::optional<std::string> protectCredentialString(const std::string &value)
{
DATA_BLOB input{};
input.pbData = reinterpret_cast<BYTE *>(const_cast<char *>(value.data()));
input.cbData = static_cast<DWORD>(value.size());
DATA_BLOB output{};
if (!CryptProtectData(&input, L"Gitree Git Credential", nullptr, nullptr, nullptr,
CRYPTPROTECT_UI_FORBIDDEN, &output))
return std::nullopt;
std::string protected_value(reinterpret_cast<const char *>(output.pbData), output.cbData);
LocalFree(output.pbData);
return hexEncode(protected_value);
}
std::optional<std::string> unprotectCredentialString(const std::string &value)
{
const auto binary = hexDecode(value);
if (!binary)
return std::nullopt;
DATA_BLOB input{};
input.pbData = reinterpret_cast<BYTE *>(const_cast<char *>(binary->data()));
input.cbData = static_cast<DWORD>(binary->size());
DATA_BLOB output{};
if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr,
CRYPTPROTECT_UI_FORBIDDEN, &output))
return std::nullopt;
std::string plain(reinterpret_cast<const char *>(output.pbData), output.cbData);
LocalFree(output.pbData);
return plain;
}
#endif
}
UserData::UserData()
@@ -42,6 +132,16 @@ UserData::~UserData()
save();
}
std::string UserData::credentialScope(const std::string &remote_url)
{
const size_t scheme = remote_url.find("://");
if (scheme == std::string::npos)
return remote_url;
const size_t authority_begin = scheme + 3;
const size_t authority_end = remote_url.find_first_of("/?#", authority_begin);
return remote_url.substr(0, authority_end == std::string::npos ? remote_url.size() : authority_end);
}
std::filesystem::path UserData::dataPath() const
{
return directory_ / "user_data.ikv2b";
@@ -65,6 +165,8 @@ void UserData::load()
sidebar_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "sidebar_collapsed", IKV_BOOL))
sidebar_collapsed_ = ikv_as_bool(value) != 0;
if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT))
pull_mode_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
@@ -76,6 +178,20 @@ void UserData::load()
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(ikv_as_float(ikv_array_get(heights, index)));
}
if (const ikv_node_t *open = object_value(settings, "sidebar_section_open", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(open), static_cast<uint32_t>(sidebar_section_open_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_open_[index] = ikv_as_bool(ikv_array_get(open, index)) != 0;
}
if (const ikv_node_t *widths = object_value(settings, "commit_table_columns", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(widths), static_cast<uint32_t>(commit_table_column_widths_.size()));
for (uint32_t index = 0; index < count; ++index)
commit_table_column_widths_[index] = static_cast<float>(ikv_as_float(ikv_array_get(widths, index)));
}
}
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
{
@@ -110,6 +226,24 @@ void UserData::load()
}
}
}
if (const ikv_node_t *credentials = object_value(root, "credentials", IKV_ARRAY))
{
for (uint32_t index = 0; index < ikv_array_size(credentials); ++index)
{
const ikv_node_t *entry = ikv_array_get(credentials, index);
if (ikv_node_type(entry) != IKV_OBJECT)
continue;
const ikv_node_t *scope = object_value(entry, "scope", IKV_STRING);
const ikv_node_t *secret = object_value(entry, "secret", IKV_STRING);
if (!scope || !secret)
continue;
const char *scope_value = ikv_as_string(scope);
const char *secret_value = ikv_as_string(secret);
if (!scope_value || !secret_value || !*scope_value || !*secret_value)
continue;
encrypted_credentials_[scope_value] = secret_value;
}
}
ikv_free(root);
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
@@ -117,6 +251,8 @@ void UserData::load()
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
for (float &width : commit_table_column_widths_)
width = std::clamp(width, 0.0f, 1200.0f);
if (open_repositories_.empty())
active_repository_ = 0;
else
@@ -133,6 +269,8 @@ void UserData::load()
sidebar_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "sidebar_collapsed", IKV_BOOL))
sidebar_collapsed_ = ikv_as_bool(value) != 0;
if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT))
pull_mode_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
@@ -144,6 +282,20 @@ void UserData::load()
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(ikv_as_float(ikv_array_get(heights, index)));
}
if (const ikv_node_t *open = object_value(settings, "sidebar_section_open", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(open), static_cast<uint32_t>(sidebar_section_open_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_open_[index] = ikv_as_bool(ikv_array_get(open, index)) != 0;
}
if (const ikv_node_t *widths = object_value(settings, "commit_table_columns", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(widths), static_cast<uint32_t>(commit_table_column_widths_.size()));
for (uint32_t index = 0; index < count; ++index)
commit_table_column_widths_[index] = static_cast<float>(ikv_as_float(ikv_array_get(widths, index)));
}
}
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
{
@@ -178,6 +330,24 @@ void UserData::load()
}
}
}
if (const ikv_node_t *credentials = object_value(root, "credentials", IKV_ARRAY))
{
for (uint32_t index = 0; index < ikv_array_size(credentials); ++index)
{
const ikv_node_t *entry = ikv_array_get(credentials, index);
if (ikv_node_type(entry) != IKV_OBJECT)
continue;
const ikv_node_t *scope = object_value(entry, "scope", IKV_STRING);
const ikv_node_t *secret = object_value(entry, "secret", IKV_STRING);
if (!scope || !secret)
continue;
const char *scope_value = ikv_as_string(scope);
const char *secret_value = ikv_as_string(secret);
if (!scope_value || !secret_value || !*scope_value || !*secret_value)
continue;
encrypted_credentials_[scope_value] = secret_value;
}
}
ikv_free(root);
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
@@ -185,6 +355,8 @@ void UserData::load()
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
for (float &width : commit_table_column_widths_)
width = std::clamp(width, 0.0f, 1200.0f);
if (open_repositories_.empty())
active_repository_ = 0;
else
@@ -244,6 +416,24 @@ void UserData::load()
save();
}
std::optional<SavedGitCredential> UserData::remoteCredential(const std::string &remote_url) const
{
const auto iterator = encrypted_credentials_.find(credentialScope(remote_url));
if (iterator == encrypted_credentials_.end())
return std::nullopt;
#ifdef _WIN32
const auto plain = unprotectCredentialString(iterator->second);
if (!plain)
return std::nullopt;
const auto split = splitCredentialPayload(*plain);
if (!split)
return std::nullopt;
return SavedGitCredential{remote_url, split->first, split->second};
#else
return std::nullopt;
#endif
}
void UserData::addRecentRepository(const std::string &path)
{
if (path.empty())
@@ -289,6 +479,29 @@ void UserData::setRepositorySession(std::vector<std::string> paths, size_t activ
save();
}
void UserData::storeRemoteCredential(const std::string &remote_url, const std::string &username,
const std::string &password)
{
const std::string scope = credentialScope(remote_url);
if (scope.empty() || username.empty())
return;
#ifdef _WIN32
const auto protected_value = protectCredentialString(username + "\n" + password);
if (!protected_value)
return;
encrypted_credentials_[scope] = *protected_value;
save();
#else
(void)password;
#endif
}
void UserData::clearRemoteCredential(const std::string &remote_url)
{
encrypted_credentials_.erase(credentialScope(remote_url));
save();
}
void UserData::save() const
{
std::filesystem::create_directories(directory_);
@@ -299,11 +512,18 @@ void UserData::save() const
ikv_node_t *settings = ikv_object_add_object(root, "settings");
ikv_object_set_float(settings, "sidebar_width", sidebar_width_);
ikv_object_set_float(settings, "details_width", details_width_);
ikv_object_set_bool(settings, "sidebar_collapsed", sidebar_collapsed_);
ikv_object_set_int(settings, "pull_mode", pull_mode_);
ikv_object_set_int(settings, "zoom_percent", zoom_percent_);
ikv_node_t *heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT);
for (const float height : sidebar_section_heights_)
ikv_array_add_float(heights, height);
ikv_node_t *open = ikv_object_add_array(settings, "sidebar_section_open", IKV_BOOL);
for (const bool state : sidebar_section_open_)
ikv_array_add_bool(open, state);
ikv_node_t *widths = ikv_object_add_array(settings, "commit_table_columns", IKV_FLOAT);
for (const float width : commit_table_column_widths_)
ikv_array_add_float(widths, width);
ikv_node_t *history = ikv_object_add_array(root, "recently_closed", IKV_STRING);
for (const auto &path : recently_closed_)
@@ -319,6 +539,14 @@ void UserData::save() const
for (const auto &path : open_repositories_)
ikv_array_add_string(tabs, path.c_str());
ikv_node_t *credentials = ikv_object_add_array(root, "credentials", IKV_OBJECT);
for (const auto &[scope, secret] : encrypted_credentials_)
{
ikv_node_t *entry = ikv_array_add_object(credentials);
ikv_object_set_string(entry, "scope", scope.c_str());
ikv_object_set_string(entry, "secret", secret.c_str());
}
ikvb_write_file_version(dataPath().string().c_str(), root, IKV_VERSION_2);
ikv_free(root);
removeLegacyFiles();

View File

@@ -2,9 +2,18 @@
#include <array>
#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
struct SavedGitCredential
{
std::string remote_url;
std::string username;
std::string password;
};
class UserData
{
public:
@@ -19,12 +28,20 @@ public:
[[nodiscard]] float sidebarWidth() const { return sidebar_width_; }
[[nodiscard]] float detailsWidth() const { return details_width_; }
[[nodiscard]] float sidebarSectionHeight(size_t index) const { return sidebar_section_heights_.at(index); }
[[nodiscard]] bool sidebarCollapsed() const { return sidebar_collapsed_; }
[[nodiscard]] bool sidebarSectionOpen(size_t index) const { return sidebar_section_open_.at(index); }
[[nodiscard]] float commitTableColumnWidth(size_t index) const { return commit_table_column_widths_.at(index); }
[[nodiscard]] int pullMode() const { return pull_mode_; }
[[nodiscard]] int zoomPercent() const { return zoom_percent_; }
[[nodiscard]] std::optional<SavedGitCredential> remoteCredential(const std::string &remote_url) const;
static std::string credentialScope(const std::string &remote_url);
void setSidebarWidth(float width) { sidebar_width_ = width; }
void setDetailsWidth(float width) { details_width_ = width; }
void setSidebarSectionHeight(size_t index, float height) { sidebar_section_heights_.at(index) = height; }
void setSidebarCollapsed(bool collapsed) { sidebar_collapsed_ = collapsed; }
void setSidebarSectionOpen(size_t index, bool open) { sidebar_section_open_.at(index) = open; }
void setCommitTableColumnWidth(size_t index, float width) { commit_table_column_widths_.at(index) = width; }
void setPullMode(int mode)
{
pull_mode_ = mode;
@@ -39,6 +56,9 @@ public:
void addRecentlyClosed(const std::string &path);
std::string takeRecentlyClosed();
void setRepositorySession(std::vector<std::string> paths, size_t active_repository);
void storeRemoteCredential(const std::string &remote_url, const std::string &username,
const std::string &password);
void clearRemoteCredential(const std::string &remote_url);
void save() const;
private:
@@ -54,6 +74,10 @@ private:
float sidebar_width_ = 230.0f;
float details_width_ = 368.0f;
std::array<float, 4> sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f};
bool sidebar_collapsed_ = false;
std::array<bool, 4> sidebar_section_open_ = {true, true, true, true};
std::array<float, 4> commit_table_column_widths_ = {0.0f, 0.0f, 0.0f, 0.0f};
int pull_mode_ = 1;
int zoom_percent_ = 100;
std::unordered_map<std::string, std::string> encrypted_credentials_;
};

View File

@@ -15,6 +15,7 @@
#include <fstream>
#include <iomanip>
#include <initializer_list>
#include <numeric>
#include <sstream>
#include <string_view>
@@ -152,23 +153,23 @@ bool isMacroName(std::string_view word) {
return has_letter && word.size() > 1;
}
constexpr ImU32 syntax_normal = IM_COL32(218, 221, 226, 255);
constexpr ImU32 syntax_keyword = IM_COL32(198, 139, 230, 255);
constexpr ImU32 syntax_type = IM_COL32(91, 198, 190, 255);
constexpr ImU32 syntax_string = IM_COL32(226, 166, 140, 255);
constexpr ImU32 syntax_number = IM_COL32(181, 206, 126, 255);
constexpr ImU32 syntax_comment = IM_COL32(112, 153, 105, 255);
constexpr ImU32 syntax_function = IM_COL32(220, 199, 128, 255);
constexpr ImU32 syntax_preprocessor = IM_COL32(205, 157, 222, 255);
constexpr ImU32 syntax_member = IM_COL32(122, 184, 225, 255);
constexpr ImU32 syntax_builtin = IM_COL32(103, 172, 232, 255);
constexpr ImU32 syntax_constant = IM_COL32(214, 139, 102, 255);
constexpr ImU32 syntax_operator = IM_COL32(105, 180, 210, 255);
constexpr ImU32 syntax_decorator = IM_COL32(226, 190, 105, 255);
constexpr ImU32 syntax_macro = IM_COL32(215, 128, 180, 255);
[[maybe_unused]] void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
SyntaxLanguage language, SyntaxState& state) {
constexpr ImU32 normal = IM_COL32(218, 221, 226, 255);
constexpr ImU32 keyword = IM_COL32(198, 139, 230, 255);
constexpr ImU32 type = IM_COL32(91, 198, 190, 255);
constexpr ImU32 string = IM_COL32(226, 166, 140, 255);
constexpr ImU32 number = IM_COL32(181, 206, 126, 255);
constexpr ImU32 comment = IM_COL32(112, 153, 105, 255);
constexpr ImU32 function = IM_COL32(220, 199, 128, 255);
constexpr ImU32 preprocessor = IM_COL32(205, 157, 222, 255);
constexpr ImU32 member = IM_COL32(122, 184, 225, 255);
constexpr ImU32 builtin = IM_COL32(103, 172, 232, 255);
constexpr ImU32 constant = IM_COL32(214, 139, 102, 255);
constexpr ImU32 operator_color = IM_COL32(105, 180, 210, 255);
constexpr ImU32 decorator = IM_COL32(226, 190, 105, 255);
constexpr ImU32 macro = IM_COL32(215, 128, 180, 255);
const auto drawSpan = [&](size_t begin, size_t end, ImU32 color) {
if (end <= begin) return;
const char* first = text.data() + begin;
@@ -177,15 +178,15 @@ bool isMacroName(std::string_view word) {
position.x += ImGui::CalcTextSize(first, last, false).x;
};
if (language == SyntaxLanguage::plain) {
drawSpan(0, text.size(), normal);
drawSpan(0, text.size(), syntax_normal);
return;
}
const size_t first_non_space = text.find_first_not_of(" \t");
if ((language == SyntaxLanguage::c || language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) &&
first_non_space != std::string::npos && text[first_non_space] == '#') {
drawSpan(0, first_non_space, normal);
drawSpan(first_non_space, text.size(), preprocessor);
drawSpan(0, first_non_space, syntax_normal);
drawSpan(first_non_space, text.size(), syntax_preprocessor);
return;
}
@@ -194,16 +195,16 @@ bool isMacroName(std::string_view word) {
while (cursor < text.size()) {
if (state.continuation == SyntaxContinuation::block_comment) {
const size_t end = text.find("*/", cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), comment); return; }
drawSpan(cursor, end + 2, comment);
if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_comment); return; }
drawSpan(cursor, end + 2, syntax_comment);
cursor = end + 2;
state.continuation = SyntaxContinuation::none;
continue;
}
if (state.continuation == SyntaxContinuation::lua_comment) {
const size_t end = text.find("]]", cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), comment); return; }
drawSpan(cursor, end + 2, comment);
if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_comment); return; }
drawSpan(cursor, end + 2, syntax_comment);
cursor = end + 2;
state.continuation = SyntaxContinuation::none;
continue;
@@ -212,16 +213,16 @@ bool isMacroName(std::string_view word) {
state.continuation == SyntaxContinuation::python_double) {
const std::string_view delimiter = state.continuation == SyntaxContinuation::python_single ? "'''" : "\"\"\"";
const size_t end = text.find(delimiter, cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), string); return; }
drawSpan(cursor, end + delimiter.size(), string);
if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_string); return; }
drawSpan(cursor, end + delimiter.size(), syntax_string);
cursor = end + delimiter.size();
state.continuation = SyntaxContinuation::none;
continue;
}
if (state.continuation == SyntaxContinuation::template_string) {
const size_t end = text.find('`', cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), string); return; }
drawSpan(cursor, end + 1, string);
if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_string); return; }
drawSpan(cursor, end + 1, syntax_string);
cursor = end + 1;
state.continuation = SyntaxContinuation::none;
continue;
@@ -230,37 +231,37 @@ bool isMacroName(std::string_view word) {
const bool slash_comments = language == SyntaxLanguage::c || language == SyntaxLanguage::cpp ||
language == SyntaxLanguage::csharp || language == SyntaxLanguage::javascript;
if (slash_comments && text.compare(cursor, 2, "//") == 0) {
drawSpan(cursor, text.size(), comment);
drawSpan(cursor, text.size(), syntax_comment);
return;
}
if (slash_comments && text.compare(cursor, 2, "/*") == 0) {
const size_t end = text.find("*/", cursor + 2);
if (end == std::string::npos) {
drawSpan(cursor, text.size(), comment);
drawSpan(cursor, text.size(), syntax_comment);
state.continuation = SyntaxContinuation::block_comment;
return;
}
drawSpan(cursor, end + 2, comment);
drawSpan(cursor, end + 2, syntax_comment);
cursor = end + 2;
continue;
}
if (language == SyntaxLanguage::python && text[cursor] == '#') {
drawSpan(cursor, text.size(), comment);
drawSpan(cursor, text.size(), syntax_comment);
return;
}
if (language == SyntaxLanguage::lua && text.compare(cursor, 4, "--[[") == 0) {
const size_t end = text.find("]]", cursor + 4);
if (end == std::string::npos) {
drawSpan(cursor, text.size(), comment);
drawSpan(cursor, text.size(), syntax_comment);
state.continuation = SyntaxContinuation::lua_comment;
return;
}
drawSpan(cursor, end + 2, comment);
drawSpan(cursor, end + 2, syntax_comment);
cursor = end + 2;
continue;
}
if (language == SyntaxLanguage::lua && text.compare(cursor, 2, "--") == 0) {
drawSpan(cursor, text.size(), comment);
drawSpan(cursor, text.size(), syntax_comment);
return;
}
if (language == SyntaxLanguage::python &&
@@ -268,11 +269,11 @@ bool isMacroName(std::string_view word) {
const std::string_view delimiter(text.data() + cursor, 3);
const size_t end = text.find(delimiter, cursor + 3);
if (end == std::string::npos) {
drawSpan(cursor, text.size(), string);
drawSpan(cursor, text.size(), syntax_string);
state.continuation = delimiter[0] == '\'' ? SyntaxContinuation::python_single : SyntaxContinuation::python_double;
return;
}
drawSpan(cursor, end + 3, string);
drawSpan(cursor, end + 3, syntax_string);
cursor = end + 3;
continue;
}
@@ -287,7 +288,7 @@ bool isMacroName(std::string_view word) {
if (text[end] != '\\') escaped = false;
}
const bool closed = end <= text.size() && end > cursor + 1 && text[end - 1] == quote;
drawSpan(cursor, end, string);
drawSpan(cursor, end, syntax_string);
if (quote == '`' && !closed) state.continuation = SyntaxContinuation::template_string;
cursor = end;
continue;
@@ -296,7 +297,7 @@ bool isMacroName(std::string_view word) {
size_t end = cursor + 1;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) ||
text[end] == '.' || text[end] == '_')) ++end;
drawSpan(cursor, end, number);
drawSpan(cursor, end, syntax_number);
cursor = end;
continue;
}
@@ -305,7 +306,7 @@ bool isMacroName(std::string_view word) {
size_t end = cursor + 2;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) ||
text[end] == '_' || text[end] == '.')) ++end;
drawSpan(cursor, end, decorator);
drawSpan(cursor, end, syntax_decorator);
cursor = end;
continue;
}
@@ -328,11 +329,11 @@ bool isMacroName(std::string_view word) {
(next >= text.size() || text[next] != '(');
const bool likely_type = declared_name || isTypeWord(language, word) ||
capitalized_type;
const ImU32 color = isLiteral(word) ? constant : isKeyword(language, word) ? keyword :
declared_function ? function : declared_name || likely_type ? type :
isMacroName(word) ? macro : isBuiltin(language, word) ? builtin : member_access ?
(next < text.size() && text[next] == '(' ? function : member) :
next < text.size() && text[next] == '(' ? function : normal;
const ImU32 color = isLiteral(word) ? syntax_constant : isKeyword(language, word) ? syntax_keyword :
declared_function ? syntax_function : declared_name || likely_type ? syntax_type :
isMacroName(word) ? syntax_macro : isBuiltin(language, word) ? syntax_builtin : member_access ?
(next < text.size() && text[next] == '(' ? syntax_function : syntax_member) :
next < text.size() && text[next] == '(' ? syntax_function : syntax_normal;
drawSpan(cursor, end, color);
previous_word = word;
cursor = end;
@@ -342,7 +343,7 @@ bool isMacroName(std::string_view word) {
size_t end = cursor + 1;
while (end < text.size() &&
std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos) ++end;
drawSpan(cursor, end, operator_color);
drawSpan(cursor, end, syntax_operator);
cursor = end;
continue;
}
@@ -357,7 +358,7 @@ bool isMacroName(std::string_view word) {
if (token_start) break;
++end;
}
drawSpan(cursor, end, normal);
drawSpan(cursor, end, syntax_normal);
cursor = end;
}
}
@@ -372,87 +373,270 @@ bool compactButton(const char* label, bool active = false) {
return clicked;
}
std::string joinLines(const std::vector<std::string>& lines) {
std::string text;
for (size_t index = 0; index < lines.size(); ++index) {
if (index) text += '\n';
text += lines[index];
bool toolbarSegmentButton(const char* label, bool active, float width) {
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0.0f);
if (active) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.25f, 0.43f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.15f, 0.29f, 0.49f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.12f, 0.22f, 0.39f, 1.0f));
}
return text;
const bool clicked = ImGui::Button(label, {width, 0.0f});
if (active) ImGui::PopStyleColor(3);
ImGui::PopStyleVar();
return clicked;
}
bool drawSelectableTextBlock(const char* id, const std::string& text, const ImVec2& size) {
std::vector<char> buffer(text.begin(), text.end());
buffer.push_back('\0');
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.06f, 0.07f, 0.09f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.18f, 0.20f, 0.24f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 1.0f);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {4.0f, 4.0f});
const bool changed = ImGui::InputTextMultiline(id, buffer.data(), buffer.size(), size,
ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_AllowTabInput);
ImGui::PopStyleVar(2);
ImGui::PopStyleColor(2);
return changed;
int wrappedLineCount(std::string_view text, int wrap_columns) {
if (wrap_columns <= 0 || static_cast<int>(text.size()) <= wrap_columns) return 1;
int lines = 0;
size_t cursor = 0;
while (cursor < text.size()) {
size_t remaining = text.size() - cursor;
if (remaining <= static_cast<size_t>(wrap_columns)) {
++lines;
break;
}
size_t split = cursor + static_cast<size_t>(wrap_columns);
size_t break_at = text.rfind(' ', split);
if (break_at == std::string_view::npos || break_at < cursor + static_cast<size_t>(wrap_columns / 3))
break_at = split;
cursor = break_at;
while (cursor < text.size() && text[cursor] == ' ') ++cursor;
++lines;
}
return std::max(lines, 1);
}
struct MinimapEntry {
size_t length = 0;
ImU32 color = IM_COL32(120, 126, 136, 255);
struct MinimapSegment {
float weight = 0.0f;
ImU32 color = syntax_normal;
};
void drawMinimap(const std::vector<MinimapEntry>& entries, float scale,
float rendered_content_height, float visible_start, float visible_height) {
struct MinimapEntry {
std::vector<MinimapSegment> segments;
float units = 1.0f;
};
void addMinimapSegment(std::vector<MinimapSegment>& segments, float weight, ImU32 color) {
if (weight <= 0.0f) return;
if (!segments.empty() && segments.back().color == color) {
segments.back().weight += weight;
return;
}
segments.push_back({weight, color});
}
std::vector<MinimapSegment> buildMinimapSegments(const std::string& text, SyntaxLanguage language) {
std::vector<MinimapSegment> segments;
if (text.empty()) {
segments.push_back({0.10f, IM_COL32(0, 0, 0, 0)});
return segments;
}
size_t cursor = 0;
bool seen_identifier = false;
while (cursor < text.size()) {
const unsigned char value = static_cast<unsigned char>(text[cursor]);
if (std::isspace(value)) {
size_t end = cursor + 1;
while (end < text.size() && std::isspace(static_cast<unsigned char>(text[end]))) ++end;
addMinimapSegment(segments, std::max(0.04f, static_cast<float>(end - cursor) * 0.18f), IM_COL32(0, 0, 0, 0));
cursor = end;
continue;
}
if ((language == SyntaxLanguage::c || language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) &&
cursor + 1 < text.size() && text[cursor] == '/' && text[cursor + 1] == '/') {
addMinimapSegment(segments, std::max(0.18f, static_cast<float>(text.size() - cursor) * 0.42f), syntax_comment);
break;
}
if (language == SyntaxLanguage::python && text[cursor] == '#') {
addMinimapSegment(segments, std::max(0.18f, static_cast<float>(text.size() - cursor) * 0.42f), syntax_comment);
break;
}
if (language == SyntaxLanguage::lua && cursor + 1 < text.size() && text[cursor] == '-' && text[cursor + 1] == '-') {
addMinimapSegment(segments, std::max(0.18f, static_cast<float>(text.size() - cursor) * 0.42f), syntax_comment);
break;
}
if (text[cursor] == '"' || text[cursor] == '\'' || (language == SyntaxLanguage::javascript && text[cursor] == '`')) {
const char delimiter = text[cursor];
size_t end = cursor + 1;
while (end < text.size()) {
if (text[end] == delimiter && text[end - 1] != '\\') {
++end;
break;
}
++end;
}
addMinimapSegment(segments, std::max(0.10f, static_cast<float>(end - cursor) * 0.34f), syntax_string);
cursor = end;
continue;
}
if (std::isdigit(value)) {
size_t end = cursor + 1;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) || text[end] == '.' || text[end] == '_')) ++end;
addMinimapSegment(segments, std::max(0.08f, static_cast<float>(end - cursor) * 0.28f), syntax_number);
cursor = end;
continue;
}
if (std::isalpha(value) || text[cursor] == '_') {
size_t end = cursor + 1;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) || text[end] == '_')) ++end;
const std::string_view word(text.data() + cursor, end - cursor);
ImU32 color = syntax_normal;
if (isKeyword(language, word)) color = syntax_keyword;
else if (isTypeWord(language, word)) color = syntax_type;
else if (isBuiltin(language, word)) color = syntax_builtin;
else if (isLiteral(word)) color = syntax_constant;
else if (word.front() == '@') color = syntax_decorator;
else if (isMacroName(word)) color = syntax_macro;
else if (!seen_identifier && (isDeclarationKeyword(word) || isFunctionDeclarationKeyword(word)))
color = syntax_keyword;
else if (!seen_identifier && cursor > 0 && text[cursor - 1] == '.') color = syntax_member;
else if (end < text.size() && text[end] == '(') color = syntax_function;
addMinimapSegment(segments, std::max(0.08f, static_cast<float>(end - cursor) * 0.26f), color);
seen_identifier = true;
cursor = end;
continue;
}
if (std::string_view("+-*/%=!<>&|^~?:#").find(text[cursor]) != std::string_view::npos) {
size_t end = cursor + 1;
while (end < text.size() && std::string_view("+-*/%=!<>&|^~?:#").find(text[end]) != std::string_view::npos) ++end;
const ImU32 color = text[cursor] == '#' ? syntax_preprocessor : syntax_operator;
addMinimapSegment(segments, std::max(0.06f, static_cast<float>(end - cursor) * 0.22f), color);
cursor = end;
continue;
}
addMinimapSegment(segments, 0.05f, syntax_normal);
++cursor;
}
if (segments.empty()) segments.push_back({0.10f, syntax_normal});
return segments;
}
ImU32 dimMinimapColor(ImU32 color) {
ImVec4 rgba = ImGui::ColorConvertU32ToFloat4(color);
rgba.x *= 0.58f;
rgba.y *= 0.58f;
rgba.z *= 0.58f;
rgba.w *= 0.82f;
return ImGui::ColorConvertFloat4ToU32(rgba);
}
float minimapTotalUnits(const std::vector<MinimapEntry>& entries) {
float total_units = 0.0f;
for (const MinimapEntry& entry : entries) total_units += std::max(0.10f, entry.units);
return std::max(1.0f, total_units);
}
void drawMinimap(const std::vector<MinimapEntry>& entries, const ImVec2& viewport_size, float scale,
float rendered_content_height, float visible_start, float visible_height, float max_scroll_y,
float content_height_override = 0.0f) {
const ImVec2 minimum = ImGui::GetCursorScreenPos();
const ImVec2 size = ImGui::GetContentRegionAvail();
ImGui::InvisibleButton("##code_minimap", size);
const float total_units = minimapTotalUnits(entries);
const float min_unit_height = scaled(0.72f, scale);
const float ideal_unit_height = scaled(2.05f, scale);
const float unit_height = content_height_override > 0.0f
? std::max(min_unit_height, content_height_override / total_units)
: ideal_unit_height;
const float content_height = std::max(content_height_override, total_units * unit_height);
const ImVec2 content_size{viewport_size.x, content_height};
ImGui::InvisibleButton("##code_minimap", content_size);
const bool hovered = ImGui::IsItemHovered();
const bool active = ImGui::IsItemActive();
ImDrawList* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(minimum, {minimum.x + size.x, minimum.y + size.y}, IM_COL32(38, 40, 47, 255));
draw->AddRectFilled({minimum.x + size.x - scaled(10.0f, scale), minimum.y},
{minimum.x + size.x, minimum.y + size.y}, IM_COL32(64, 66, 74, 255));
draw->AddRectFilled(minimum, {minimum.x + viewport_size.x, minimum.y + content_size.y},
IM_COL32(26, 28, 33, 188), scaled(3.0f, scale));
draw->AddRect(minimum, {minimum.x + viewport_size.x, minimum.y + content_size.y},
hovered || active ? IM_COL32(70, 76, 88, 180) : IM_COL32(46, 50, 58, 140), scaled(3.0f, scale));
if (entries.empty() || size.y <= 2.0f) return;
if (entries.empty() || content_size.y <= 1.0f) return;
const float content_left = minimum.x + scaled(4.0f, scale);
const float content_right = minimum.x + size.x - scaled(14.0f, scale);
const float content_left = minimum.x + scaled(5.0f, scale);
const float content_right = minimum.x + viewport_size.x - scaled(5.0f, scale);
const float content_width = std::max(1.0f, content_right - content_left);
const float line_step = size.y / static_cast<float>(entries.size());
const float line_height = std::max(1.0f, std::min(scaled(2.0f, scale), line_step));
constexpr float max_reference_length = 160.0f;
for (size_t index = 0; index < entries.size(); ++index) {
const float normalized = std::clamp(static_cast<float>(entries[index].length) / max_reference_length, 0.08f, 1.0f);
const float width = content_width * normalized;
const float y = minimum.y + index * line_step;
draw->AddRectFilled({content_left, y}, {content_left + width, y + line_height},
entries[index].color, scaled(1.0f, scale));
const float line_height = std::max(scaled(0.65f, scale), std::min(scaled(2.1f, scale), unit_height));
float y = minimum.y;
for (const MinimapEntry& entry : entries) {
const float entry_units = std::max(0.10f, entry.units);
const float total_weight = std::max(0.10f, std::accumulate(entry.segments.begin(), entry.segments.end(), 0.0f,
[](float sum, const MinimapSegment& segment) { return sum + std::max(0.0f, segment.weight); }));
float x = content_left;
for (const MinimapSegment& segment : entry.segments) {
if (segment.color == IM_COL32(0, 0, 0, 0)) {
x += content_width * (segment.weight / total_weight);
continue;
}
const float width = std::max(scaled(1.0f, scale), content_width * (segment.weight / total_weight));
draw->AddRectFilled({x, y}, {std::min(content_right, x + width), y + line_height},
dimMinimapColor(segment.color), scaled(1.0f, scale));
x += width;
}
y += entry_units * unit_height;
}
const float content_height = std::max(rendered_content_height, 1.0f);
const float clamped_visible_height = std::clamp(visible_height, 0.0f, content_height);
const float clamped_visible_start = std::clamp(visible_start, 0.0f, std::max(0.0f, content_height - clamped_visible_height));
const float viewport_height = std::clamp(size.y * (clamped_visible_height / content_height),
scaled(18.0f, scale), size.y);
const float viewport_y = minimum.y + size.y * (clamped_visible_start / content_height);
const float rendered_height = std::max(rendered_content_height, 1.0f);
const float clamped_visible_height = std::clamp(visible_height, 0.0f, rendered_height);
const float clamped_visible_start = std::clamp(visible_start, 0.0f, std::max(0.0f, rendered_height - clamped_visible_height));
const float viewport_height = std::clamp(content_size.y * (clamped_visible_height / rendered_height),
scaled(14.0f, scale), content_size.y);
const float viewport_y = minimum.y + content_size.y * (clamped_visible_start / rendered_height);
if ((hovered || active) && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
const float mouse_ratio = std::clamp((ImGui::GetIO().MousePos.y - minimum.y) / std::max(1.0f, content_size.y), 0.0f, 1.0f);
const float target = std::clamp(mouse_ratio * rendered_height - clamped_visible_height * 0.5f, 0.0f, max_scroll_y);
ImGui::SetScrollY(target);
}
draw->AddRectFilled({minimum.x + scaled(1.0f, scale), viewport_y},
{minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height},
IM_COL32(120, 146, 198, 45), scaled(2.0f, scale));
{minimum.x + viewport_size.x - scaled(1.0f, scale), viewport_y + viewport_height},
IM_COL32(109, 129, 170, hovered || active ? 44 : 28), scaled(2.0f, scale));
draw->AddRect({minimum.x + scaled(1.0f, scale), viewport_y},
{minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height},
IM_COL32(120, 146, 198, 160), scaled(2.0f, scale));
{minimum.x + viewport_size.x - scaled(1.0f, scale), viewport_y + viewport_height},
IM_COL32(120, 138, 170, 120), scaled(2.0f, scale));
}
void drawCodeLine(const std::string& text, SyntaxLanguage language, SyntaxState& syntax,
float scale, ImU32 background = IM_COL32(0, 0, 0, 0), float left_gutter = 0.0f,
float minimum_width = 0.0f, float custom_row_height = 0.0f) {
const float row_height = custom_row_height > 0.0f ? custom_row_height : scaled(21.0f, scale);
float minimum_width = 0.0f, float custom_row_height = 0.0f, int wrap_columns = 0) {
const float base_row_height = scaled(21.0f, scale);
const int visual_lines = wrappedLineCount(text, wrap_columns);
const float row_height = custom_row_height > 0.0f ? custom_row_height : base_row_height * static_cast<float>(visual_lines);
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = std::max(minimum_width, ImGui::CalcTextSize(text.c_str()).x + left_gutter + scaled(12.0f, scale));
const float width = minimum_width > 0.0f ? minimum_width :
std::max(minimum_width, ImGui::CalcTextSize(text.c_str()).x + left_gutter + scaled(12.0f, scale));
ImGui::InvisibleButton("##code_line", {width, row_height});
const ImVec2 minimum = ImGui::GetItemRectMin();
const ImVec2 maximum = ImGui::GetItemRectMax();
ImDrawList* draw = ImGui::GetWindowDrawList();
if ((background & IM_COL32_A_MASK) != 0)
draw->AddRectFilled(minimum, maximum, background);
drawSyntaxText(draw, {start.x + left_gutter, start.y + scaled(2.0f, scale)}, text, language, syntax);
if (wrap_columns <= 0 || static_cast<int>(text.size()) <= wrap_columns) {
drawSyntaxText(draw, {start.x + left_gutter, start.y + scaled(2.0f, scale)}, text, language, syntax);
return;
}
size_t cursor = 0;
int line_index = 0;
while (cursor < text.size()) {
size_t remaining = text.size() - cursor;
size_t chunk_end = text.size();
if (remaining > static_cast<size_t>(wrap_columns)) {
chunk_end = cursor + static_cast<size_t>(wrap_columns);
size_t break_at = text.rfind(' ', chunk_end);
if (break_at == std::string::npos || break_at < cursor + static_cast<size_t>(wrap_columns / 3))
break_at = chunk_end;
chunk_end = break_at;
}
const std::string chunk = text.substr(cursor, chunk_end - cursor);
drawSyntaxText(draw, {start.x + left_gutter, start.y + scaled(2.0f, scale) + base_row_height * static_cast<float>(line_index)},
chunk, language, syntax);
cursor = chunk_end;
while (cursor < text.size() && text[cursor] == ' ') ++cursor;
++line_index;
}
}
void drawCodeLineNumber(int value, float x, float y, ImU32 color) {
@@ -460,6 +644,32 @@ void drawCodeLineNumber(int value, float x, float y, ImU32 color) {
const std::string text = std::to_string(value);
ImGui::GetWindowDrawList()->AddText({x, y}, color, text.c_str());
}
std::string ellipsizeText(std::string_view text, float max_width) {
if (text.empty() || max_width <= 0.0f) return {};
std::string display{text};
if (ImGui::CalcTextSize(display.c_str()).x <= max_width) return display;
constexpr const char* overflow = "...";
const float overflow_width = ImGui::CalcTextSize(overflow).x;
if (overflow_width >= max_width) return overflow;
while (!display.empty() && ImGui::CalcTextSize(display.c_str()).x + overflow_width > max_width) {
display.pop_back();
}
display += overflow;
return display;
}
void drawEllipsizedText(ImDrawList* draw, ImVec2 position, ImU32 color, std::string_view text,
float max_width, const ImVec2& clip_min, const ImVec2& clip_max) {
const std::string display = ellipsizeText(text, max_width);
if (display.empty()) return;
draw->PushClipRect(clip_min, clip_max, true);
draw->AddText(position, color, display.c_str());
draw->PopClipRect();
}
}
void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std::string& path,
@@ -468,6 +678,7 @@ void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std
commit_id_.clear();
staged_ = staged;
mode_ = Mode::diff;
scroll_to_top_ = true;
reload(repository, manager, notice);
}
@@ -477,10 +688,12 @@ void DiffViewer::openCommit(RepositoryView& repository, GitManager& manager,
commit_id_ = commit_id;
staged_ = false;
mode_ = Mode::diff;
scroll_to_top_ = true;
reload(repository, manager, notice);
if (hunks_.empty()) {
mode_ = Mode::file;
loadSupplement(repository, manager, mode_, notice);
scroll_to_top_ = true;
}
}
@@ -689,99 +902,107 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_TB_PEN);
ImGui::SameLine(0, scaled(7, scale));
ImGui::TextUnformatted(path_.c_str());
ImGui::SameLine(std::max(scaled(240, scale), ImGui::GetWindowWidth() - scaled(455, scale)));
const bool historical = !commit_id_.empty();
const std::string source_label = historical ? "Commit " + commit_id_.substr(0, 8)
: staged_ ? "Staged" : "Unstaged";
if (compactButton(source_label.c_str(), 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);
}
if (!historical) {
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();
const float top_right_width = scaled(72.0f, scale);
ImGui::SameLine(std::max(scaled(160.0f, scale), ImGui::GetWindowWidth() - top_right_width));
ImGui::TextDisabled("UTF-8");
ImGui::SameLine(0, scaled(10.0f, scale));
if (compactButton(ICON_TB_XMARK)) close();
ImGui::Separator();
if (!path_.empty()) {
ImGui::SetCursorPosX(ImGui::GetWindowWidth() - scaled(116, scale));
ImGui::TextDisabled("UTF-8");
if (!historical && !staged_) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1));
if (compactButton(ICON_TB_TRASH_CAN)) {
if (manager.discardFile(repository, path_, notice)) close();
}
ImGui::PopStyleColor();
}
ImGui::Separator();
const float segment_width = scaled(86.0f, scale);
const float file_group_width = segment_width * 2.0f;
const float blame_group_width = segment_width * 2.0f;
const float wrap_width = scaled(34.0f, scale);
const float between_groups = scaled(22.0f, scale);
const float wrap_gap = scaled(12.0f, scale);
const float toolbar_width = file_group_width + between_groups + blame_group_width + wrap_gap + wrap_width;
ImGui::SetCursorPosX(std::max(0.0f, (ImGui::GetWindowWidth() - toolbar_width) * 0.5f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {0.0f, ImGui::GetStyle().ItemSpacing.y});
if (toolbarSegmentButton("File View", mode_ == Mode::file, segment_width)) {
mode_ = Mode::file;
loadSupplement(repository, manager, mode_, notice);
scroll_to_top_ = true;
}
ImGui::SameLine();
if (toolbarSegmentButton("Diff View", mode_ == Mode::diff, segment_width)) {
mode_ = Mode::diff;
scroll_to_top_ = true;
}
ImGui::SameLine(0, between_groups);
if (toolbarSegmentButton("Blame", mode_ == Mode::blame, segment_width)) {
mode_ = Mode::blame;
loadSupplement(repository, manager, mode_, notice);
}
ImGui::SameLine();
if (toolbarSegmentButton("History", mode_ == Mode::history, segment_width)) {
mode_ = Mode::history;
loadSupplement(repository, manager, mode_, notice);
}
ImGui::PopStyleVar();
ImGui::SameLine(0, wrap_gap);
toolbarSegmentButton(ICON_TB_BARS, line_wrap_, wrap_width);
if (ImGui::IsItemClicked()) line_wrap_ = !line_wrap_;
if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort))
ImGui::SetTooltip("%s", line_wrap_ ? "Disable line wrap" : "Enable line wrap");
ImGui::Separator();
const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file || mode_ == Mode::blame || mode_ == Mode::history;
const float minimap_width = scaled(56.0f, scale);
const float minimap_width = scaled(168.0f, scale);
const float minimap_gap = scaled(10.0f, scale);
const float scrollbar_width = ImGui::GetStyle().ScrollbarSize;
const SyntaxLanguage language = languageForPath(path_);
float main_scroll_y = 0.0f;
float main_window_height = 0.0f;
float rendered_content_height = 0.0f;
std::vector<MinimapEntry> minimap_entries;
if (show_minimap) {
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
minimap_entries.push_back({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.65f});
if (mode_ == Mode::diff) {
for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) {
if (hunk_index > 0) {
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
minimap_entries.push_back({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.55f});
minimap_entries.push_back({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.45f});
}
minimap_entries.push_back({hunks_[hunk_index].header.size(), IM_COL32(128, 133, 141, 255)});
for (const Line& line : hunks_[hunk_index].lines) {
const ImU32 color = line.kind == LineKind::added ? IM_COL32(87, 190, 112, 255) :
line.kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) :
IM_COL32(112, 118, 128, 255);
minimap_entries.push_back({line.text.size() + 1, color});
minimap_entries.push_back({buildMinimapSegments(line.text, language),
line.kind == LineKind::context ? 1.0f : 1.0f});
}
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
minimap_entries.push_back({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.30f});
}
} else if (mode_ == Mode::file) {
minimap_entries.reserve(file_lines_.size());
for (const std::string& line : file_lines_)
minimap_entries.push_back({line.size(), IM_COL32(112, 118, 128, 255)});
minimap_entries.push_back({buildMinimapSegments(line, language), 1.0f});
} else if (mode_ == Mode::blame) {
minimap_entries.reserve(blame_lines_.size());
for (const BlameLine& line : blame_lines_)
minimap_entries.push_back({line.text.size(), blameColor(line.hash, line.show_attribution ? 200 : 125)});
minimap_entries.push_back({buildMinimapSegments(line.text, language), line.show_attribution ? 1.35f : 1.0f});
} else {
minimap_entries.reserve(history_entries_.size());
for (const HistoryEntry& entry : history_entries_)
minimap_entries.push_back({entry.summary.size() + entry.author.size(), IM_COL32(112, 118, 128, 255)});
minimap_entries.push_back({buildMinimapSegments(entry.summary, SyntaxLanguage::plain), 1.7f});
}
}
ImGui::BeginChild("diff_content_main",
show_minimap ? ImVec2{-minimap_width - scaled(6.0f, scale), -1} : ImVec2{-1, -1},
ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar);
ImGuiWindowFlags content_flags = line_wrap_ ? ImGuiWindowFlags_None : ImGuiWindowFlags_HorizontalScrollbar;
const bool request_scroll_to_top = scroll_to_top_;
if (request_scroll_to_top) ImGui::SetNextWindowScroll({0.0f, 0.0f});
ImGui::BeginChild("diff_content_main", ImVec2{-1, -1}, ImGuiChildFlags_None, content_flags);
scroll_to_top_ = false;
const bool use_code_font = code_font && mode_ != Mode::history;
if (use_code_font) ImGui::PushFont(code_font, 0.0f);
const float row_height = scaled(21, scale);
const float content_width = std::max(ImGui::GetContentRegionAvail().x, scaled(200.0f, scale));
const SyntaxLanguage language = languageForPath(path_);
const ImVec2 child_minimum = ImGui::GetWindowPos();
const ImVec2 child_size = ImGui::GetWindowSize();
const float content_width = std::max(
ImGui::GetContentRegionAvail().x - (show_minimap ? minimap_width + minimap_gap + scrollbar_width + scaled(6.0f, scale) : 0.0f),
scaled(200.0f, scale));
const float wrapable_text_width = std::max(scaled(32.0f, scale), content_width - scaled(12.0f, scale));
const float code_character_width = ImGui::CalcTextSize("M").x;
const int wrap_columns = line_wrap_ && code_character_width > 0.0f
? std::max(12, static_cast<int>(wrapable_text_width / code_character_width))
: 0;
// Keep the first source row clear of the toolbar and aligned with a normal row inset.
ImGui::Dummy({0.0f, row_height});
@@ -834,7 +1055,7 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
line.kind == LineKind::removed ? IM_COL32(86, 38, 42, 170) : IM_COL32(0, 0, 0, 0);
drawCodeLine(line.text, language,
line.kind == LineKind::removed ? old_syntax : new_syntax,
scale, background, code_gutter, content_width);
scale, background, code_gutter, content_width, 0.0f, wrap_columns);
const float text_y = line_minimum.y + scaled(2.0f, scale);
drawCodeLineNumber(line.old_number, line_minimum.x + scaled(6.0f, scale), text_y,
IM_COL32(126, 132, 142, 255));
@@ -847,8 +1068,10 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
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))
if (manager.applyPatch(repository, hunks_[pending_hunk].patch, cached, reverse, notice)) {
if (pending_action == HunkAction::stage) close();
reload(repository, manager, notice);
}
}
} else if (mode_ == Mode::blame) {
if (blame_lines_.empty()) {
@@ -866,7 +1089,7 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
const ImU32 background = line.show_attribution ? IM_COL32(39, 42, 50, 150) : IM_COL32(31, 34, 40, 105);
const float blame_row_height = line.show_attribution ? scaled(28.0f, scale) : scaled(21.0f, scale);
drawCodeLine(line.text, language, syntax, scale, background, code_gutter, content_width,
blame_row_height);
blame_row_height, wrap_columns);
ImDrawList* draw = ImGui::GetWindowDrawList();
const ImVec2 line_maximum = ImGui::GetItemRectMax();
draw->AddRectFilled({line_minimum.x, line_minimum.y},
@@ -886,13 +1109,17 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
accent, scaled(3.0f, scale));
}
const float info_x = avatar_min.x + avatar_size + scaled(8.0f, scale);
const float info_right = line_minimum.x + info_width - scaled(10.0f, scale);
const float info_text_width = std::max(0.0f, info_right - info_x);
const ImVec2 info_clip_min{info_x, line_minimum.y};
const ImVec2 info_clip_max{info_right, line_maximum.y};
const std::string author = line.author.empty() ? "Unknown author" : line.author;
std::string summary = line.summary.empty() ? "(no summary)" : line.summary;
if (!line.date.empty()) summary += " " + line.date;
draw->AddText({info_x, line_minimum.y + scaled(1.0f, scale)},
IM_COL32(216, 220, 226, 255), author.c_str());
draw->AddText({info_x, line_minimum.y + scaled(10.0f, scale)},
IM_COL32(134, 140, 151, 255), summary.c_str());
drawEllipsizedText(draw, {info_x, line_minimum.y + scaled(1.0f, scale)},
IM_COL32(216, 220, 226, 255), author, info_text_width, info_clip_min, info_clip_max);
drawEllipsizedText(draw, {info_x, line_minimum.y + scaled(10.0f, scale)},
IM_COL32(134, 140, 151, 255), summary, info_text_width, info_clip_min, info_clip_max);
} else {
draw->AddText({line_minimum.x + scaled(16.0f, scale), line_minimum.y + scaled(2.0f, scale)},
IM_COL32(92, 99, 110, 255), "...");
@@ -909,7 +1136,7 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
ImGui::PushID(static_cast<int>(index));
const ImVec2 line_minimum = ImGui::GetCursorScreenPos();
drawCodeLine((*lines)[index], language, syntax, scale, IM_COL32(0, 0, 0, 0),
code_gutter, content_width);
code_gutter, content_width, 0.0f, wrap_columns);
drawCodeLineNumber(static_cast<int>(index + 1), line_minimum.x + scaled(6.0f, scale),
line_minimum.y + scaled(2.0f, scale), IM_COL32(126, 132, 142, 255));
ImGui::PopID();
@@ -953,18 +1180,33 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
main_scroll_y = ImGui::GetScrollY();
main_window_height = ImGui::GetWindowHeight();
rendered_content_height = ImGui::GetCursorPosY();
const float max_scroll_y = ImGui::GetScrollMaxY();
if (show_minimap) {
const float total_units = minimapTotalUnits(minimap_entries);
const float min_unit_height = scaled(0.72f, scale);
const float ideal_unit_height = scaled(2.05f, scale);
const float max_minimap_height = std::max(scaled(24.0f, scale), child_size.y - scaled(8.0f, scale));
const float fitted_unit_height = std::clamp(max_minimap_height / total_units, min_unit_height, ideal_unit_height);
const float minimap_content_height = total_units * fitted_unit_height;
const float minimap_height = std::min(max_minimap_height, minimap_content_height);
const float minimap_x = child_minimum.x + child_size.x - scrollbar_width - minimap_width - scaled(4.0f, scale);
const float minimap_y = child_minimum.y + scaled(4.0f, scale);
ImGui::SetCursorScreenPos({minimap_x, minimap_y});
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, ImVec4(0.28f, 0.31f, 0.37f, 0.55f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(0.36f, 0.40f, 0.47f, 0.72f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(0.44f, 0.49f, 0.57f, 0.86f));
if (request_scroll_to_top) ImGui::SetNextWindowScroll({0.0f, 0.0f});
ImGui::BeginChild("diff_content_minimap", {minimap_width, minimap_height},
ImGuiChildFlags_Borders, ImGuiWindowFlags_NoMove);
drawMinimap(minimap_entries, {minimap_width - scaled(1.0f, scale), minimap_height}, scale,
rendered_content_height, main_scroll_y, main_window_height, max_scroll_y, minimap_content_height);
ImGui::EndChild();
ImGui::PopStyleColor(5);
}
if (use_code_font) ImGui::PopFont();
ImGui::EndChild();
if (show_minimap) {
ImGui::SameLine(0, scaled(6.0f, scale));
const float minimap_line_height = scaled(2.0f, scale);
const float minimap_height = std::min(main_window_height,
std::max(scaled(36.0f, scale), static_cast<float>(minimap_entries.size()) * minimap_line_height + scaled(8.0f, scale)));
ImGui::BeginChild("diff_content_minimap", {minimap_width, minimap_height}, ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
drawMinimap(minimap_entries, scale, rendered_content_height, main_scroll_y, main_window_height);
ImGui::EndChild();
}
ImGui::EndChild();
ImGui::PopStyleVar();
}

View File

@@ -60,6 +60,8 @@ private:
std::vector<std::string> file_lines_;
std::vector<BlameLine> blame_lines_;
std::vector<HistoryEntry> history_entries_;
bool line_wrap_ = false;
bool scroll_to_top_ = false;
void reload(RepositoryView& repository, GitManager& manager, std::string& notice);
void loadSupplement(RepositoryView& repository, GitManager& manager, Mode mode, std::string& notice);

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,16 @@
namespace {
struct RoutedEdge {
int child_row = -1;
int parent_row = -1;
int child_lane = 0;
int parent_lane = 0;
int route_slot = 0;
int color_lane = 0;
int detour_lane = -1;
};
template <size_t Count>
void drawRoundedPolyline(ImDrawList* draw, const std::array<ImVec2, Count>& points,
float radius, ImU32 color, float thickness) {
@@ -164,64 +174,99 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
(row_heights[static_cast<size_t>(index)] - ImGui::GetStyle().CellPadding.y * 2.0f) * 0.5f;
};
int maximum_lane = 0;
for (const auto& item : commits) maximum_lane = std::max(maximum_lane, item.lane);
std::vector<RoutedEdge> routed_edges;
routed_edges.reserve(commits.size() * 2);
const auto route_spans_overlap = [](const RoutedEdge& left, const RoutedEdge& right) {
const int top = std::max(left.child_row, right.child_row);
const int bottom = std::min(left.parent_row, right.parent_row);
return top < bottom;
};
const auto commit_blocks_lane = [&](int candidate_lane, int child_row, int parent_row) {
for (int intermediate = child_row + 1; intermediate < parent_row; ++intermediate) {
if (row_heights[static_cast<size_t>(intermediate)] > 0.0f &&
commits[static_cast<size_t>(intermediate)].lane == candidate_lane)
return true;
}
return false;
};
const auto route_blocks_lane = [&](int candidate_lane, int child_row, int parent_row) {
const RoutedEdge probe{child_row, parent_row, 0, 0, 0, 0, candidate_lane};
return std::any_of(routed_edges.begin(), routed_edges.end(), [&](const RoutedEdge& edge) {
return edge.detour_lane == candidate_lane && route_spans_overlap(edge, probe);
});
};
const auto lane_available = [&](int candidate_lane, int child_row, int parent_row) {
return !commit_blocks_lane(candidate_lane, child_row, parent_row) &&
!route_blocks_lane(candidate_lane, child_row, parent_row);
};
for (int child_row = 0; child_row < static_cast<int>(commits.size()); ++child_row) {
const auto& child_parents = parent_rows[static_cast<size_t>(child_row)];
for (size_t parent_index = 0; parent_index < child_parents.size(); ++parent_index) {
const int parent_row = child_parents[parent_index];
if (parent_row <= child_row) continue;
const CommitInfo& child_commit = commits[static_cast<size_t>(child_row)];
const CommitInfo& parent_commit = commits[static_cast<size_t>(parent_row)];
const int preferred_lane = parent_index == 0 ? child_commit.lane : parent_commit.lane;
int detour_lane = -1;
if (!lane_available(preferred_lane, child_row, parent_row)) {
for (int distance = 1; distance <= maximum_lane + 2; ++distance) {
const int left = preferred_lane - distance;
const int right = preferred_lane + distance;
if (left >= 0 && lane_available(left, child_row, parent_row)) {
detour_lane = left;
break;
}
if (lane_available(right, child_row, parent_row)) {
detour_lane = right;
maximum_lane = std::max(maximum_lane, right);
break;
}
}
if (detour_lane < 0) {
detour_lane = maximum_lane + 1;
maximum_lane = detour_lane;
}
}
RoutedEdge route;
route.child_row = child_row;
route.parent_row = parent_row;
route.child_lane = child_commit.lane;
route.parent_lane = parent_commit.lane;
route.route_slot = static_cast<int>(parent_index);
route.detour_lane = detour_lane;
route.color_lane = detour_lane >= 0 ? detour_lane : preferred_lane;
routed_edges.push_back(route);
}
}
// Every row redraws edges crossing its clip rectangle. This keeps long paths continuous
// without allowing table row clipping to cut out intermediate segments.
draw->PushClipRect(
{origin.x, origin.y - row_clip_padding},
{origin.x + ImGui::GetContentRegionAvail().x, origin.y + content_height + row_clip_padding}, true);
for (int child_row = 0; child_row < static_cast<int>(commits.size()); ++child_row) {
if (row_heights[static_cast<size_t>(child_row)] <= 0.0f) continue;
const CommitInfo& child = commits[static_cast<size_t>(child_row)];
const auto& child_parents = parent_rows[static_cast<size_t>(child_row)];
for (size_t parent_index = 0; parent_index < child_parents.size(); ++parent_index) {
const int parent_row = child_parents[parent_index];
if (parent_row <= child_row || row < child_row || row > parent_row ||
row_heights[static_cast<size_t>(parent_row)] <= 0.0f) continue;
const CommitInfo& parent = commits[static_cast<size_t>(parent_row)];
for (const RoutedEdge& route : routed_edges) {
if (row < route.child_row || row > route.parent_row ||
row_heights[static_cast<size_t>(route.child_row)] <= 0.0f ||
row_heights[static_cast<size_t>(route.parent_row)] <= 0.0f)
continue;
const float child_x = origin.x + px(17.0f) + lane_spacing * route.child_lane;
const float parent_x = origin.x + px(17.0f) + lane_spacing * route.parent_lane;
const float child_y = center_y(route.child_row);
const float parent_y = center_y(route.parent_row);
const float detour_x = route.detour_lane >= 0
? origin.x + px(17.0f) + lane_spacing * route.detour_lane
: std::numeric_limits<float>::quiet_NaN();
const float child_x = origin.x + px(17.0f) + lane_spacing * child.lane;
const float parent_x = origin.x + px(17.0f) + lane_spacing * parent.lane;
const float child_y = center_y(child_row);
const float parent_y = center_y(parent_row);
const int preferred_lane = parent_index == 0 ? child.lane : parent.lane;
const auto lane_is_blocked = [&](int candidate_lane) {
for (int intermediate = child_row + 1; intermediate < parent_row; ++intermediate) {
if (row_heights[static_cast<size_t>(intermediate)] > 0.0f &&
commits[static_cast<size_t>(intermediate)].lane == candidate_lane)
return true;
}
return false;
};
float detour_x = std::numeric_limits<float>::quiet_NaN();
if (lane_is_blocked(preferred_lane)) {
int maximum_lane = 0;
for (int intermediate = child_row; intermediate <= parent_row; ++intermediate) {
if (row_heights[static_cast<size_t>(intermediate)] > 0.0f)
maximum_lane = std::max(maximum_lane,
commits[static_cast<size_t>(intermediate)].lane);
}
int detour_lane = maximum_lane + 1;
for (int distance = 1; distance <= maximum_lane + 1; ++distance) {
const int left = preferred_lane - distance;
const int right = preferred_lane + distance;
if (left >= 0 && !lane_is_blocked(left)) {
detour_lane = left;
break;
}
if (right <= maximum_lane && !lane_is_blocked(right)) {
detour_lane = right;
break;
}
}
detour_x = origin.x + px(17.0f) + lane_spacing * detour_lane;
}
// Colors are lane-position based, so edges use the visible lane slot they route through.
const int edge_color = std::isfinite(detour_x)
? static_cast<int>(std::lround((detour_x - (origin.x + px(17.0f))) / lane_spacing))
: preferred_lane;
drawOrthogonalEdge(draw, {child_x, child_y}, {parent_x, parent_y},
laneColor(edge_color, 235), scale_, static_cast<int>(parent_index), detour_x);
}
laneColor(route.color_lane, 235), scale_, route.route_slot, detour_x);
}
draw->PopClipRect();

View File

@@ -12,6 +12,17 @@
#define ICON_TB_ARROW_UP "\xee\xa8\xa5"
#define ICON_TB_ARROW_UP_RIGHT_FROM_SQUARE "\xee\xaa\x99"
#define ICON_TB_BARS "\xee\xb1\x82"
#define ICON_TB_BRAND_C_SHARP "\xef\x80\x83"
#define ICON_TB_BRAND_CPP "\xef\x97\xbe"
#define ICON_TB_BRAND_CSS3 "\xee\xb5\xab"
#define ICON_TB_BRAND_GOLANG "\xef\x9e\x8d"
#define ICON_TB_BRAND_HTML5 "\xee\xb5\xac"
#define ICON_TB_BRAND_JAVASCRIPT "\xee\xbc\x8c"
#define ICON_TB_BRAND_NODEJS "\xef\xab\xa0"
#define ICON_TB_BRAND_POWERSHELL "\xef\x97\xad"
#define ICON_TB_BRAND_PYTHON "\xee\xb4\x81"
#define ICON_TB_BRAND_RUST "\xef\xa9\x93"
#define ICON_TB_BRAND_TYPESCRIPT "\xef\x97\xb1"
#define ICON_TB_BOX_ARCHIVE "\xee\xa8\x8b"
#define ICON_TB_BOX_OPEN "\xef\x81\xba"
#define ICON_TB_CARET_DOWN "\xee\xad\x9d"
@@ -34,13 +45,17 @@
#define ICON_TB_DOWNLOAD "\xee\xaa\x96"
#define ICON_TB_EYE "\xee\xaa\x9a"
#define ICON_TB_FILE "\xee\xaa\xa4"
#define ICON_TB_FILE_CODE "\xee\xaf\x90"
#define ICON_TB_FILE_TEXT "\xee\xaa\xa2"
#define ICON_TB_FOLDER "\xee\xaa\xad"
#define ICON_TB_FOLDER_OPEN "\xef\xab\xb7"
#define ICON_TB_FOLDER_TREE "\xee\xba\x9d"
#define ICON_TB_GLOBE "\xee\xad\x94"
#define ICON_TB_JET_FIGHTER_UP "\xef\x87\xad"
#define ICON_TB_JSON "\xef\x9e\xb2"
#define ICON_TB_LAYERS_LINKED "\xee\xba\xa1"
#define ICON_TB_MAGNIFYING_GLASS "\xee\xac\x9c"
#define ICON_TB_MARKDOWN "\xee\xb1\x81"
#define ICON_TB_MINUS "\xee\xab\xb2"
#define ICON_TB_PEN "\xee\xac\x84"
#define ICON_TB_PLUS "\xee\xac\x8b"
@@ -50,6 +65,7 @@
#define ICON_TB_SERVER "\xee\xac\x9f"
#define ICON_TB_TAG "\xee\xbe\x86"
#define ICON_TB_TERMINAL "\xee\xaf\xaf"
#define ICON_TB_TERMINAL_2 "\xee\xaf\xaf"
#define ICON_TB_TRASH_CAN "\xee\xad\x81"
#define ICON_TB_TREE "\xee\xbc\x81"
#define ICON_TB_TRIANGLE_EXCLAMATION "\xee\xa8\x86"
@@ -57,3 +73,4 @@
#define ICON_TB_USER "\xee\xad\x8d"
#define ICON_TB_WINDOW_MAXIMIZE "\xee\xab\xaa"
#define ICON_TB_XMARK "\xee\xad\x95"
#define ICON_TB_FILE_TYPE_SQL "\xef\xac\x95"