Compare commits
24 Commits
prod-8-513
...
prod-12-2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e38d51f74 | |||
| 755092933f | |||
| a5b0e02387 | |||
| ee7efa6eff | |||
| 303d8469a0 | |||
| 7989224a8c | |||
| 106a2b8cf9 | |||
| 8368b0e237 | |||
| 942e95e7a1 | |||
| 0425bb7320 | |||
| 508c0f037e | |||
| e1107756f9 | |||
| e3b6c34e88 | |||
| 57d7be3357 | |||
| 2d451a5ece | |||
| a64770b684 | |||
| 009a4efb87 | |||
| b01bbd2b73 | |||
| b100b19b16 | |||
| dff02adc45 | |||
| cdea4503c7 | |||
| a8bec3ed22 | |||
| fefc2084ac | |||
| 0152667b65 |
@@ -14,6 +14,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -38,21 +39,43 @@ jobs:
|
||||
run: |
|
||||
cmake -S . -B build-win -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=mingw-toolchain.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-static -static-libgcc -static-libstdc++"
|
||||
|
||||
- name: Build Windows executable
|
||||
run: cmake --build build-win --parallel
|
||||
|
||||
- name: Verify static runtime linkage
|
||||
run: |
|
||||
dependencies="$(x86_64-w64-mingw32-objdump -p build-win/bin/gitree.exe | sed -n 's/.*DLL Name: //p')"
|
||||
printf '%s\n' "$dependencies"
|
||||
|
||||
if printf '%s\n' "$dependencies" | grep -Eqi 'lib(gcc|stdc\+\+|winpthread).*\.dll'; then
|
||||
echo "The executable still depends on a MinGW runtime DLL."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Generate EXE name
|
||||
run: |
|
||||
repo_name="$(basename "${GITEA_REPOSITORY}")"
|
||||
short_sha="$(printf '%s' "${GITEA_SHA}" | cut -c1-7)"
|
||||
|
||||
exe_name="${repo_name}-windows-x64-${GITEA_RUN_NUMBER}-${short_sha}.exe"
|
||||
|
||||
echo "EXE_NAME=${exe_name}" >> "$GITHUB_ENV"
|
||||
echo "ARTIFACT_NAME=${repo_name}-windows-x64-${GITEA_RUN_NUMBER}-${short_sha}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare artifact
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp build-win/bin/gitree.exe dist/Gitree-windows-x64.exe
|
||||
cp build-win/bin/gitree.exe "dist/${EXE_NAME}"
|
||||
|
||||
- name: Upload Windows build
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Gitree-windows-x64
|
||||
path: dist/Gitree-windows-x64.exe
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
path: dist/${{ env.EXE_NAME }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create prod release
|
||||
@@ -69,15 +92,101 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --tags --force
|
||||
|
||||
short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)"
|
||||
tag="prod-${GITEA_RUN_NUMBER}-${short_sha}"
|
||||
api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
|
||||
repo_url="${GITEA_SERVER_URL%/}/${GITEA_REPOSITORY}"
|
||||
|
||||
previous_tag="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api}/releases?limit=50" |
|
||||
jq -r '[.[] | select(.tag_name | startswith("prod-"))][0].tag_name // empty'
|
||||
)"
|
||||
|
||||
if [ -n "$previous_tag" ]; then
|
||||
range="${previous_tag}..${GITEA_SHA}"
|
||||
echo "Generating release notes from ${previous_tag} to ${GITEA_SHA}"
|
||||
else
|
||||
range="${GITEA_SHA}"
|
||||
echo "No previous prod release found. Generating release notes from current commit."
|
||||
fi
|
||||
|
||||
{
|
||||
echo "Automated Windows release from the prod branch."
|
||||
echo
|
||||
|
||||
if [ -n "$previous_tag" ]; then
|
||||
echo "## Changes since ${previous_tag}"
|
||||
else
|
||||
echo "## Changes"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
commit_count="$(git rev-list --count "$range")"
|
||||
|
||||
if [ "$commit_count" -eq 0 ]; then
|
||||
echo "- No commits found since the previous release."
|
||||
else
|
||||
git log "$range" \
|
||||
--reverse \
|
||||
--pretty=format:'%H%x1f%s' |
|
||||
while IFS="$(printf '\037')" read -r hash subject; do
|
||||
short="$(printf '%s' "$hash" | cut -c1-7)"
|
||||
echo "- ([${short}](${repo_url}/commit/${hash})) ${subject}"
|
||||
done
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "## Authors"
|
||||
echo
|
||||
echo "Sorted by total lines added or removed."
|
||||
echo
|
||||
|
||||
git log "$range" --numstat --format='author:%an <%ae>' |
|
||||
awk '
|
||||
/^author:/ {
|
||||
author = substr($0, 8)
|
||||
next
|
||||
}
|
||||
|
||||
NF >= 3 {
|
||||
added = ($1 == "-" ? 0 : $1)
|
||||
removed = ($2 == "-" ? 0 : $2)
|
||||
|
||||
adds[author] += added
|
||||
dels[author] += removed
|
||||
churn[author] += added + removed
|
||||
}
|
||||
|
||||
END {
|
||||
for (a in churn) {
|
||||
printf "%d\t%d\t%d\t%s\n", churn[a], adds[a], dels[a], a
|
||||
}
|
||||
}
|
||||
' |
|
||||
sort -nr |
|
||||
while IFS="$(printf '\t')" read -r total added removed author; do
|
||||
echo "- ${author} — ${total} lines changed (+${added} / -${removed})"
|
||||
done
|
||||
} > release-notes.md
|
||||
|
||||
payload="$(jq -n \
|
||||
--arg tag "$tag" \
|
||||
--arg sha "$GITEA_SHA" \
|
||||
--arg name "Gitree ${tag}" \
|
||||
'{tag_name: $tag, target_commitish: $sha, name: $name, body: "Automated Windows release from the prod branch.", draft: false, prerelease: false}')"
|
||||
--arg name "${ARTIFACT_NAME}" \
|
||||
--rawfile body release-notes.md \
|
||||
'{
|
||||
tag_name: $tag,
|
||||
target_commitish: $sha,
|
||||
name: $name,
|
||||
body: $body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
}')"
|
||||
|
||||
release="$(curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
@@ -85,10 +194,11 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$payload" \
|
||||
"${api}/releases")"
|
||||
|
||||
release_id="$(printf '%s' "$release" | jq -er '.id')"
|
||||
|
||||
curl --fail-with-body --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@dist/Gitree-windows-x64.exe" \
|
||||
"${api}/releases/${release_id}/assets?name=Gitree-windows-x64.exe"
|
||||
-F "attachment=@dist/${EXE_NAME}" \
|
||||
"${api}/releases/${release_id}/assets?name=${EXE_NAME}"
|
||||
@@ -72,6 +72,8 @@ add_executable(gitree WIN32
|
||||
src/managers/avatar_cache.h
|
||||
src/managers/application_manager.cpp
|
||||
src/managers/application_manager.h
|
||||
src/managers/application_icon_cache.cpp
|
||||
src/managers/application_icon_cache.h
|
||||
src/models/repository.h
|
||||
)
|
||||
target_include_directories(gitree PRIVATE src vendor/libgit2/include vendor/icons)
|
||||
@@ -79,6 +81,7 @@ target_link_libraries(gitree PRIVATE imgui libgit2package iZo::izo ikv::ikv Open
|
||||
target_compile_definitions(gitree PRIVATE
|
||||
GITREE_VERSION="${PROJECT_VERSION}"
|
||||
GITREE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/vendor/fonts"
|
||||
GITREE_IMAGE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
|
||||
$<$<PLATFORM_ID:Windows>:NOMINMAX;WIN32_LEAN_AND_MEAN>
|
||||
)
|
||||
|
||||
|
||||
97
src/managers/application_icon_cache.cpp
Normal file
97
src/managers/application_icon_cache.cpp
Normal file
@@ -0,0 +1,97 @@
|
||||
#include "application_icon_cache.h"
|
||||
|
||||
#include <GLFW/glfw3.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#endif
|
||||
|
||||
ApplicationIconCache::~ApplicationIconCache()
|
||||
{
|
||||
shutdown();
|
||||
}
|
||||
|
||||
unsigned int ApplicationIconCache::textureFor(const std::filesystem::path &executable)
|
||||
{
|
||||
if (executable.empty())
|
||||
return 0;
|
||||
const std::string key = executable.string();
|
||||
const auto found = textures_.find(key);
|
||||
if (found != textures_.end())
|
||||
return found->second;
|
||||
const unsigned int texture = loadTexture(executable);
|
||||
textures_.emplace(key, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
unsigned int ApplicationIconCache::loadTexture(const std::filesystem::path &executable) const
|
||||
{
|
||||
#ifdef _WIN32
|
||||
HICON large_icon = nullptr;
|
||||
HICON small_icon = nullptr;
|
||||
if (ExtractIconExW(executable.wstring().c_str(), 0, &large_icon, &small_icon, 1) == 0)
|
||||
return 0;
|
||||
HICON icon = large_icon ? large_icon : small_icon;
|
||||
if (!icon)
|
||||
return 0;
|
||||
|
||||
constexpr int size = 32;
|
||||
BITMAPINFO bitmap_info{};
|
||||
bitmap_info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
bitmap_info.bmiHeader.biWidth = size;
|
||||
bitmap_info.bmiHeader.biHeight = -size;
|
||||
bitmap_info.bmiHeader.biPlanes = 1;
|
||||
bitmap_info.bmiHeader.biBitCount = 32;
|
||||
bitmap_info.bmiHeader.biCompression = BI_RGB;
|
||||
|
||||
void *pixels = nullptr;
|
||||
HDC device = CreateCompatibleDC(nullptr);
|
||||
HBITMAP bitmap = CreateDIBSection(device, &bitmap_info, DIB_RGB_COLORS, &pixels, nullptr, 0);
|
||||
unsigned int texture = 0;
|
||||
if (bitmap && pixels)
|
||||
{
|
||||
const HGDIOBJ previous = SelectObject(device, bitmap);
|
||||
std::fill_n(static_cast<unsigned char *>(pixels), size * size * 4, 0);
|
||||
if (DrawIconEx(device, 0, 0, icon, size, size, 0, nullptr, DI_NORMAL))
|
||||
{
|
||||
auto *rgba = static_cast<unsigned char *>(pixels);
|
||||
for (int pixel = 0; pixel < size * size; ++pixel)
|
||||
std::swap(rgba[pixel * 4], rgba[pixel * 4 + 2]);
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, size, size, 0,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, pixels);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
SelectObject(device, previous);
|
||||
}
|
||||
if (bitmap)
|
||||
DeleteObject(bitmap);
|
||||
if (device)
|
||||
DeleteDC(device);
|
||||
if (large_icon)
|
||||
DestroyIcon(large_icon);
|
||||
if (small_icon)
|
||||
DestroyIcon(small_icon);
|
||||
return texture;
|
||||
#else
|
||||
(void)executable;
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ApplicationIconCache::shutdown()
|
||||
{
|
||||
for (auto &[path, texture] : textures_)
|
||||
{
|
||||
(void)path;
|
||||
if (texture)
|
||||
glDeleteTextures(1, &texture);
|
||||
}
|
||||
textures_.clear();
|
||||
}
|
||||
22
src/managers/application_icon_cache.h
Normal file
22
src/managers/application_icon_cache.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
class ApplicationIconCache
|
||||
{
|
||||
public:
|
||||
~ApplicationIconCache();
|
||||
|
||||
ApplicationIconCache(const ApplicationIconCache &) = delete;
|
||||
ApplicationIconCache &operator=(const ApplicationIconCache &) = delete;
|
||||
ApplicationIconCache() = default;
|
||||
|
||||
unsigned int textureFor(const std::filesystem::path &executable);
|
||||
void shutdown();
|
||||
|
||||
private:
|
||||
unsigned int loadTexture(const std::filesystem::path &executable) const;
|
||||
std::map<std::string, unsigned int> textures_;
|
||||
};
|
||||
@@ -1,65 +1,76 @@
|
||||
#include "managers/application_manager.h"
|
||||
|
||||
#include <izo/Environment.hpp>
|
||||
#include <izo/Paths.hpp>
|
||||
#include <izo/Process.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
std::filesystem::path environmentPath(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value && *value ? std::filesystem::path(value) : std::filesystem::path{};
|
||||
}
|
||||
|
||||
std::filesystem::path firstExisting(std::initializer_list<std::filesystem::path> candidates) {
|
||||
std::error_code error;
|
||||
for (const auto& candidate : candidates)
|
||||
if (!candidate.empty() && std::filesystem::is_regular_file(candidate, error)) return candidate;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::filesystem::path findExecutable(const std::filesystem::path& root, const char* filename) {
|
||||
std::error_code error;
|
||||
if (root.empty() || !std::filesystem::is_directory(root, error)) return {};
|
||||
for (std::filesystem::recursive_directory_iterator iterator(
|
||||
root, std::filesystem::directory_options::skip_permission_denied, error), end;
|
||||
iterator != end && !error; iterator.increment(error)) {
|
||||
if (iterator->is_regular_file(error) && iterator->path().filename() == filename)
|
||||
return iterator->path();
|
||||
namespace
|
||||
{
|
||||
std::filesystem::path environmentPath(const char *name)
|
||||
{
|
||||
const auto value = izo::GetEnvVar(name);
|
||||
return value && !value->empty() ? std::filesystem::path(*value) : std::filesystem::path{};
|
||||
}
|
||||
|
||||
std::filesystem::path firstExisting(std::initializer_list<std::filesystem::path> candidates)
|
||||
{
|
||||
std::error_code error;
|
||||
for (const auto &candidate : candidates)
|
||||
if (!candidate.empty() && std::filesystem::is_regular_file(candidate, error))
|
||||
return candidate;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::filesystem::path findExecutable(const std::filesystem::path &root, const char *filename)
|
||||
{
|
||||
std::error_code error;
|
||||
if (root.empty() || !std::filesystem::is_directory(root, error))
|
||||
return {};
|
||||
for (std::filesystem::recursive_directory_iterator iterator(
|
||||
root, std::filesystem::directory_options::skip_permission_denied, error),
|
||||
end;
|
||||
iterator != end && !error; iterator.increment(error))
|
||||
{
|
||||
if (iterator->is_regular_file(error) && iterator->path().filename() == filename)
|
||||
return iterator->path();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationManager::ApplicationManager() {
|
||||
const auto local = environmentPath("LOCALAPPDATA");
|
||||
ApplicationManager::ApplicationManager()
|
||||
{
|
||||
const auto local = izo::GetKnownPath(izo::KnownPath::LocalAppData);
|
||||
const auto program_files = environmentPath("ProgramFiles");
|
||||
const auto program_files_x86 = environmentPath("ProgramFiles(x86)");
|
||||
const auto windows = environmentPath("WINDIR");
|
||||
|
||||
auto add = [this](ExternalApplicationId id, const char* name, std::filesystem::path executable,
|
||||
std::vector<std::string> arguments = {}) {
|
||||
auto add = [this](ExternalApplicationId id, const char *name, std::filesystem::path executable,
|
||||
std::vector<std::string> arguments = {})
|
||||
{
|
||||
const bool available = !executable.empty();
|
||||
targets_.push_back({{id, name, available}, std::move(executable), std::move(arguments)});
|
||||
targets_.push_back({{id, name, available, std::move(executable)}, std::move(arguments)});
|
||||
applications_.push_back(targets_.back().info);
|
||||
};
|
||||
|
||||
add(ExternalApplicationId::visual_studio_code, "VS Code", firstExisting({
|
||||
local / "Programs/Microsoft VS Code/Code.exe",
|
||||
program_files / "Microsoft VS Code/Code.exe",
|
||||
program_files_x86 / "Microsoft VS Code/Code.exe",
|
||||
}));
|
||||
local / "Programs/Microsoft VS Code/Code.exe",
|
||||
program_files / "Microsoft VS Code/Code.exe",
|
||||
program_files_x86 / "Microsoft VS Code/Code.exe",
|
||||
}));
|
||||
add(ExternalApplicationId::visual_studio, "Visual Studio", firstExisting({
|
||||
program_files / "Microsoft Visual Studio/2022/Community/Common7/IDE/devenv.exe",
|
||||
program_files / "Microsoft Visual Studio/2022/Professional/Common7/IDE/devenv.exe",
|
||||
program_files / "Microsoft Visual Studio/2022/Enterprise/Common7/IDE/devenv.exe",
|
||||
program_files_x86 / "Microsoft Visual Studio/2019/Community/Common7/IDE/devenv.exe",
|
||||
}));
|
||||
program_files / "Microsoft Visual Studio/2022/Community/Common7/IDE/devenv.exe",
|
||||
program_files / "Microsoft Visual Studio/2022/Professional/Common7/IDE/devenv.exe",
|
||||
program_files / "Microsoft Visual Studio/2022/Enterprise/Common7/IDE/devenv.exe",
|
||||
program_files_x86 / "Microsoft Visual Studio/2019/Community/Common7/IDE/devenv.exe",
|
||||
}));
|
||||
add(ExternalApplicationId::antigravity, "Antigravity", firstExisting({
|
||||
local / "Programs/Antigravity/Antigravity.exe",
|
||||
local / "Antigravity/Antigravity.exe",
|
||||
program_files / "Antigravity/Antigravity.exe",
|
||||
}));
|
||||
local / "Programs/Antigravity/Antigravity.exe",
|
||||
local / "Antigravity/Antigravity.exe",
|
||||
program_files / "Antigravity/Antigravity.exe",
|
||||
}));
|
||||
std::filesystem::path github_desktop = firstExisting({
|
||||
local / "GitHubDesktop/GitHubDesktop.exe",
|
||||
local / "GitHub Desktop/GitHubDesktop.exe",
|
||||
@@ -72,35 +83,51 @@ ApplicationManager::ApplicationManager() {
|
||||
add(ExternalApplicationId::terminal, "Terminal",
|
||||
firstExisting({local / "Microsoft/WindowsApps/wt.exe", windows / "System32/wt.exe"}), {"-d"});
|
||||
add(ExternalApplicationId::git_bash, "Git Bash", firstExisting({
|
||||
program_files / "Git/git-bash.exe", program_files_x86 / "Git/git-bash.exe",
|
||||
}), {"--cd="});
|
||||
program_files / "Git/git-bash.exe",
|
||||
program_files_x86 / "Git/git-bash.exe",
|
||||
}),
|
||||
{"--cd="});
|
||||
add(ExternalApplicationId::wsl, "WSL", firstExisting({windows / "System32/wsl.exe"}), {"--cd"});
|
||||
add(ExternalApplicationId::android_studio, "Android Studio", firstExisting({
|
||||
program_files / "Android/Android Studio/bin/studio64.exe",
|
||||
local / "Programs/Android Studio/bin/studio64.exe",
|
||||
}));
|
||||
program_files / "Android/Android Studio/bin/studio64.exe",
|
||||
local / "Programs/Android Studio/bin/studio64.exe",
|
||||
}));
|
||||
std::filesystem::path idea = firstExisting({
|
||||
program_files / "JetBrains/IntelliJ IDEA 2025.1/bin/idea64.exe",
|
||||
program_files / "JetBrains/IntelliJ IDEA 2024.3/bin/idea64.exe",
|
||||
local / "Programs/IntelliJ IDEA Ultimate/bin/idea64.exe",
|
||||
});
|
||||
if (idea.empty()) idea = findExecutable(local / "JetBrains/Toolbox/apps", "idea64.exe");
|
||||
if (idea.empty())
|
||||
idea = findExecutable(local / "JetBrains/Toolbox/apps", "idea64.exe");
|
||||
add(ExternalApplicationId::intellij_idea, "IntelliJ IDEA", std::move(idea));
|
||||
}
|
||||
|
||||
ExternalApplicationId ApplicationManager::defaultApplication() const {
|
||||
const ExternalApplication *ApplicationManager::application(ExternalApplicationId id) const
|
||||
{
|
||||
const auto found = std::find_if(applications_.begin(), applications_.end(),
|
||||
[id](const ExternalApplication &application)
|
||||
{ return application.id == id; });
|
||||
return found == applications_.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
ExternalApplicationId ApplicationManager::defaultApplication() const
|
||||
{
|
||||
const auto available = std::find_if(targets_.begin(), targets_.end(),
|
||||
[](const LaunchTarget& target) { return target.info.available; });
|
||||
[](const LaunchTarget &target)
|
||||
{ return target.info.available; });
|
||||
return available == targets_.end()
|
||||
? ExternalApplicationId::file_explorer
|
||||
: available->info.id;
|
||||
? ExternalApplicationId::file_explorer
|
||||
: available->info.id;
|
||||
}
|
||||
|
||||
bool ApplicationManager::launch(ExternalApplicationId application,
|
||||
const std::filesystem::path& repository, std::string& error) const {
|
||||
const std::filesystem::path &repository, std::string &error) const
|
||||
{
|
||||
const auto found = std::find_if(targets_.begin(), targets_.end(),
|
||||
[application](const LaunchTarget& target) { return target.info.id == application; });
|
||||
if (found == targets_.end() || !found->info.available) {
|
||||
[application](const LaunchTarget &target)
|
||||
{ return target.info.id == application; });
|
||||
if (found == targets_.end() || !found->info.available)
|
||||
{
|
||||
error = "The selected application is not installed";
|
||||
return false;
|
||||
}
|
||||
@@ -111,9 +138,13 @@ bool ApplicationManager::launch(ExternalApplicationId application,
|
||||
else
|
||||
arguments.push_back(repository_path);
|
||||
const izo::ProcessResult result = izo::LaunchProcess({
|
||||
found->executable, std::move(arguments), repository, true,
|
||||
found->info.executable,
|
||||
std::move(arguments),
|
||||
repository,
|
||||
true,
|
||||
});
|
||||
if (!result) {
|
||||
if (!result)
|
||||
{
|
||||
error = result.errorMessage.empty() ? "Unable to launch application" : result.errorMessage;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
enum class ExternalApplicationId {
|
||||
enum class ExternalApplicationId
|
||||
{
|
||||
visual_studio_code,
|
||||
visual_studio,
|
||||
antigravity,
|
||||
@@ -17,25 +18,29 @@ enum class ExternalApplicationId {
|
||||
intellij_idea,
|
||||
};
|
||||
|
||||
struct ExternalApplication {
|
||||
struct ExternalApplication
|
||||
{
|
||||
ExternalApplicationId id;
|
||||
std::string name;
|
||||
bool available = false;
|
||||
std::filesystem::path executable;
|
||||
};
|
||||
|
||||
class ApplicationManager {
|
||||
class ApplicationManager
|
||||
{
|
||||
public:
|
||||
ApplicationManager();
|
||||
|
||||
const std::vector<ExternalApplication>& applications() const { return applications_; }
|
||||
const std::vector<ExternalApplication> &applications() const { return applications_; }
|
||||
const ExternalApplication *application(ExternalApplicationId id) const;
|
||||
ExternalApplicationId defaultApplication() const;
|
||||
bool launch(ExternalApplicationId application, const std::filesystem::path& repository,
|
||||
std::string& error) const;
|
||||
bool launch(ExternalApplicationId application, const std::filesystem::path &repository,
|
||||
std::string &error) const;
|
||||
|
||||
private:
|
||||
struct LaunchTarget {
|
||||
struct LaunchTarget
|
||||
{
|
||||
ExternalApplication info;
|
||||
std::filesystem::path executable;
|
||||
std::vector<std::string> arguments;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,23 +17,31 @@
|
||||
#include <wincodec.h>
|
||||
#endif
|
||||
|
||||
AvatarCache::AvatarCache(const std::filesystem::path& user_data_directory)
|
||||
: directory_(user_data_directory / "avatars") {
|
||||
AvatarCache::AvatarCache(const std::filesystem::path &user_data_directory)
|
||||
: directory_(user_data_directory / "avatars")
|
||||
{
|
||||
std::filesystem::create_directories(directory_);
|
||||
}
|
||||
|
||||
AvatarCache::~AvatarCache() {
|
||||
AvatarCache::~AvatarCache()
|
||||
{
|
||||
shutdown();
|
||||
}
|
||||
|
||||
std::string AvatarCache::hashEmail(const std::string& email) const {
|
||||
std::string AvatarCache::hashEmail(const std::string &email) const
|
||||
{
|
||||
std::string normalized = email;
|
||||
normalized.erase(normalized.begin(), std::find_if(normalized.begin(), normalized.end(),
|
||||
[](unsigned char value) { return !std::isspace(value); }));
|
||||
[](unsigned char value)
|
||||
{ return !std::isspace(value); }));
|
||||
normalized.erase(std::find_if(normalized.rbegin(), normalized.rend(),
|
||||
[](unsigned char value) { return !std::isspace(value); }).base(), normalized.end());
|
||||
[](unsigned char value)
|
||||
{ return !std::isspace(value); })
|
||||
.base(),
|
||||
normalized.end());
|
||||
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
|
||||
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
|
||||
[](unsigned char value)
|
||||
{ return static_cast<char>(std::tolower(value)); });
|
||||
|
||||
#ifdef _WIN32
|
||||
BCRYPT_ALG_HANDLE algorithm = nullptr;
|
||||
@@ -43,99 +51,121 @@ std::string AvatarCache::hashEmail(const std::string& email) const {
|
||||
DWORD written = 0;
|
||||
if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_MD5_ALGORITHM, nullptr, 0) < 0 ||
|
||||
BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast<PUCHAR>(&object_size),
|
||||
sizeof(object_size), &written, 0) < 0) {
|
||||
if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0);
|
||||
sizeof(object_size), &written, 0) < 0)
|
||||
{
|
||||
if (algorithm)
|
||||
BCryptCloseAlgorithmProvider(algorithm, 0);
|
||||
return {};
|
||||
}
|
||||
std::vector<unsigned char> object(object_size);
|
||||
if (BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) < 0 ||
|
||||
BCryptHashData(hash, reinterpret_cast<PUCHAR>(normalized.data()),
|
||||
static_cast<ULONG>(normalized.size()), 0) < 0 ||
|
||||
BCryptFinishHash(hash, digest.data(), static_cast<ULONG>(digest.size()), 0) < 0) {
|
||||
if (hash) BCryptDestroyHash(hash);
|
||||
static_cast<ULONG>(normalized.size()), 0) < 0 ||
|
||||
BCryptFinishHash(hash, digest.data(), static_cast<ULONG>(digest.size()), 0) < 0)
|
||||
{
|
||||
if (hash)
|
||||
BCryptDestroyHash(hash);
|
||||
BCryptCloseAlgorithmProvider(algorithm, 0);
|
||||
return {};
|
||||
}
|
||||
BCryptDestroyHash(hash);
|
||||
BCryptCloseAlgorithmProvider(algorithm, 0);
|
||||
std::ostringstream output;
|
||||
for (unsigned char value : digest) output << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(value);
|
||||
for (unsigned char value : digest)
|
||||
output << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(value);
|
||||
return output.str();
|
||||
#else
|
||||
return std::to_string(std::hash<std::string>{}(normalized));
|
||||
#endif
|
||||
}
|
||||
|
||||
unsigned int AvatarCache::textureFor(const std::string& email) {
|
||||
if (email.empty()) return 0;
|
||||
unsigned int AvatarCache::textureFor(const std::string &email)
|
||||
{
|
||||
if (email.empty())
|
||||
return 0;
|
||||
const std::string hash = hashEmail(email);
|
||||
if (hash.empty()) return 0;
|
||||
Entry& entry = entries_[hash];
|
||||
if (entry.texture) return entry.texture;
|
||||
if (entry.requested) return 0;
|
||||
if (hash.empty())
|
||||
return 0;
|
||||
Entry &entry = entries_[hash];
|
||||
if (entry.texture)
|
||||
return entry.texture;
|
||||
if (entry.requested)
|
||||
return 0;
|
||||
|
||||
entry.requested = true;
|
||||
entry.file = directory_ / (hash + ".img");
|
||||
if (std::filesystem::exists(entry.file)) {
|
||||
if (std::filesystem::exists(entry.file))
|
||||
{
|
||||
entry.texture = loadTexture(entry.file);
|
||||
return entry.texture;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
const std::filesystem::path file = entry.file;
|
||||
const std::wstring url = L"https://www.gravatar.com/avatar/" +
|
||||
std::wstring(hash.begin(), hash.end()) + L"?s=64&d=identicon&r=g";
|
||||
entry.download = std::async(std::launch::async, [file, url] {
|
||||
return SUCCEEDED(URLDownloadToFileW(nullptr, url.c_str(), file.wstring().c_str(), 0, nullptr));
|
||||
});
|
||||
std::wstring(hash.begin(), hash.end()) + L"?s=64&d=identicon&r=g";
|
||||
entry.download = std::async(std::launch::async, [file, url]
|
||||
{ return SUCCEEDED(URLDownloadToFileW(nullptr, url.c_str(), file.wstring().c_str(), 0, nullptr)); });
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
void AvatarCache::update() {
|
||||
for (auto& [hash, entry] : entries_) {
|
||||
void AvatarCache::update()
|
||||
{
|
||||
for (auto &[hash, entry] : entries_)
|
||||
{
|
||||
(void)hash;
|
||||
if (entry.texture || !entry.download.valid()) continue;
|
||||
if (entry.download.wait_for(std::chrono::seconds(0)) != std::future_status::ready) continue;
|
||||
if (entry.download.get()) entry.texture = loadTexture(entry.file);
|
||||
if (entry.texture || !entry.download.valid())
|
||||
continue;
|
||||
if (entry.download.wait_for(std::chrono::seconds(0)) != std::future_status::ready)
|
||||
continue;
|
||||
if (entry.download.get())
|
||||
entry.texture = loadTexture(entry.file);
|
||||
}
|
||||
}
|
||||
|
||||
unsigned int AvatarCache::loadTexture(const std::filesystem::path& file) const {
|
||||
unsigned int AvatarCache::loadTexture(const std::filesystem::path &file) const
|
||||
{
|
||||
#ifdef _WIN32
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
IWICImagingFactory* factory = nullptr;
|
||||
IWICBitmapDecoder* decoder = nullptr;
|
||||
IWICBitmapFrameDecode* frame = nullptr;
|
||||
IWICFormatConverter* converter = nullptr;
|
||||
IWICImagingFactory *factory = nullptr;
|
||||
IWICBitmapDecoder *decoder = nullptr;
|
||||
IWICBitmapFrameDecode *frame = nullptr;
|
||||
IWICFormatConverter *converter = nullptr;
|
||||
UINT width = 0;
|
||||
UINT height = 0;
|
||||
std::vector<unsigned char> pixels;
|
||||
unsigned int texture = 0;
|
||||
if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(&factory))) &&
|
||||
IID_PPV_ARGS(&factory))) &&
|
||||
SUCCEEDED(factory->CreateDecoderFromFilename(file.wstring().c_str(), nullptr, GENERIC_READ,
|
||||
WICDecodeMetadataCacheOnLoad, &decoder)) &&
|
||||
WICDecodeMetadataCacheOnLoad, &decoder)) &&
|
||||
SUCCEEDED(decoder->GetFrame(0, &frame)) &&
|
||||
SUCCEEDED(factory->CreateFormatConverter(&converter)) &&
|
||||
SUCCEEDED(converter->Initialize(frame, GUID_WICPixelFormat32bppRGBA,
|
||||
WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) &&
|
||||
SUCCEEDED(converter->GetSize(&width, &height))) {
|
||||
WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) &&
|
||||
SUCCEEDED(converter->GetSize(&width, &height)))
|
||||
{
|
||||
pixels.resize(static_cast<size_t>(width) * height * 4);
|
||||
if (SUCCEEDED(converter->CopyPixels(nullptr, width * 4,
|
||||
static_cast<UINT>(pixels.size()), pixels.data()))) {
|
||||
static_cast<UINT>(pixels.size()), pixels.data())))
|
||||
{
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, static_cast<int>(width), static_cast<int>(height),
|
||||
0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
|
||||
0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
}
|
||||
if (converter) converter->Release();
|
||||
if (frame) frame->Release();
|
||||
if (decoder) decoder->Release();
|
||||
if (factory) factory->Release();
|
||||
if (converter)
|
||||
converter->Release();
|
||||
if (frame)
|
||||
frame->Release();
|
||||
if (decoder)
|
||||
decoder->Release();
|
||||
if (factory)
|
||||
factory->Release();
|
||||
CoUninitialize();
|
||||
return texture;
|
||||
#else
|
||||
@@ -144,11 +174,15 @@ unsigned int AvatarCache::loadTexture(const std::filesystem::path& file) const {
|
||||
#endif
|
||||
}
|
||||
|
||||
void AvatarCache::shutdown() {
|
||||
for (auto& [hash, entry] : entries_) {
|
||||
void AvatarCache::shutdown()
|
||||
{
|
||||
for (auto &[hash, entry] : entries_)
|
||||
{
|
||||
(void)hash;
|
||||
if (entry.download.valid()) entry.download.wait();
|
||||
if (entry.texture) glDeleteTextures(1, &entry.texture);
|
||||
if (entry.download.valid())
|
||||
entry.download.wait();
|
||||
if (entry.texture)
|
||||
glDeleteTextures(1, &entry.texture);
|
||||
entry.texture = 0;
|
||||
}
|
||||
entries_.clear();
|
||||
|
||||
@@ -5,28 +5,30 @@
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
class AvatarCache {
|
||||
class AvatarCache
|
||||
{
|
||||
public:
|
||||
explicit AvatarCache(const std::filesystem::path& user_data_directory);
|
||||
explicit AvatarCache(const std::filesystem::path &user_data_directory);
|
||||
~AvatarCache();
|
||||
|
||||
AvatarCache(const AvatarCache&) = delete;
|
||||
AvatarCache& operator=(const AvatarCache&) = delete;
|
||||
AvatarCache(const AvatarCache &) = delete;
|
||||
AvatarCache &operator=(const AvatarCache &) = delete;
|
||||
|
||||
unsigned int textureFor(const std::string& email);
|
||||
unsigned int textureFor(const std::string &email);
|
||||
void update();
|
||||
void shutdown();
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
struct Entry
|
||||
{
|
||||
unsigned int texture = 0;
|
||||
bool requested = false;
|
||||
std::filesystem::path file;
|
||||
std::future<bool> download;
|
||||
};
|
||||
|
||||
std::string hashEmail(const std::string& email) const;
|
||||
unsigned int loadTexture(const std::filesystem::path& file) const;
|
||||
std::string hashEmail(const std::string &email) const;
|
||||
unsigned int loadTexture(const std::filesystem::path &file) const;
|
||||
std::filesystem::path directory_;
|
||||
std::map<std::string, Entry> entries_;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,57 +4,66 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class GitManager {
|
||||
class GitManager
|
||||
{
|
||||
public:
|
||||
GitManager();
|
||||
~GitManager();
|
||||
|
||||
bool openRepository(RepositoryView& repository, const std::string& path, std::string& error);
|
||||
bool initializeRepository(RepositoryView& repository, const std::string& path, 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 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 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 captureGit(RepositoryView& repository, const std::vector<std::string>& arguments,
|
||||
std::string& output, std::string& error);
|
||||
bool applyPatch(RepositoryView& repository, const std::string& patch, bool cached,
|
||||
bool reverse, std::string& error);
|
||||
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 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 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 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);
|
||||
bool applyPatch(RepositoryView &repository, const std::string &patch, bool cached,
|
||||
bool reverse, std::string &error);
|
||||
|
||||
private:
|
||||
static std::string lastError(const char* fallback);
|
||||
static std::string lastError(const char *fallback);
|
||||
static std::string formatTime(git_time_t value, int offset_minutes);
|
||||
void readBranches(RepositoryView& repository, git_branch_t type, std::vector<std::string>& output);
|
||||
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);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -1,72 +1,111 @@
|
||||
#include "user_data.h"
|
||||
|
||||
extern "C" {
|
||||
#include <izo/Paths.hpp>
|
||||
|
||||
extern "C"
|
||||
{
|
||||
#include <ikv.h>
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
std::filesystem::path roaming_directory() {
|
||||
#ifdef _WIN32
|
||||
if (const char* appdata = std::getenv("APPDATA")) return appdata;
|
||||
#endif
|
||||
if (const char* home = std::getenv("HOME")) return std::filesystem::path(home) / ".config";
|
||||
return std::filesystem::temp_directory_path();
|
||||
namespace
|
||||
{
|
||||
std::filesystem::path roaming_directory()
|
||||
{
|
||||
if (const auto config = izo::GetKnownPath(izo::KnownPath::Config); !config.empty())
|
||||
return config;
|
||||
if (const auto temporary = izo::GetKnownPath(izo::KnownPath::Temporary); !temporary.empty())
|
||||
return temporary;
|
||||
return std::filesystem::temp_directory_path();
|
||||
}
|
||||
|
||||
const ikv_node_t *object_value(const ikv_node_t *object, const char *key, ikv_type_t type)
|
||||
{
|
||||
const ikv_node_t *value = object ? ikv_object_get(object, key) : nullptr;
|
||||
return value && ikv_node_type(value) == type ? value : nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const ikv_node_t* object_value(const ikv_node_t* object, const char* key, ikv_type_t type) {
|
||||
const ikv_node_t* value = object ? ikv_object_get(object, key) : nullptr;
|
||||
return value && ikv_node_type(value) == type ? value : nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
UserData::UserData() {
|
||||
UserData::UserData()
|
||||
{
|
||||
directory_ = roaming_directory() / "Identity" / "Gitree";
|
||||
std::filesystem::create_directories(directory_);
|
||||
imgui_ini_path_ = (directory_ / "imgui.ini").string();
|
||||
load();
|
||||
}
|
||||
|
||||
UserData::~UserData() {
|
||||
UserData::~UserData()
|
||||
{
|
||||
save();
|
||||
}
|
||||
|
||||
void UserData::load() {
|
||||
const std::filesystem::path data_path = directory_ / "user_data.ikv";
|
||||
if (ikv_node_t* root = ikv_parse_file(data_path.string().c_str())) {
|
||||
if (const ikv_node_t* settings = object_value(root, "settings", IKV_OBJECT)) {
|
||||
if (const ikv_node_t* value = object_value(settings, "sidebar_width", IKV_FLOAT))
|
||||
std::filesystem::path UserData::dataPath() const
|
||||
{
|
||||
return directory_ / "user_data.ikv2b";
|
||||
}
|
||||
|
||||
void UserData::removeLegacyFiles() const
|
||||
{
|
||||
std::error_code error;
|
||||
for (const char *name : {"imgui.ini", "settings.ini", "history.txt", "session.txt", "user_data.ikv"})
|
||||
std::filesystem::remove(directory_ / name, error);
|
||||
}
|
||||
|
||||
void UserData::load()
|
||||
{
|
||||
const std::filesystem::path binary_path = dataPath();
|
||||
if (ikv_node_t *root = ikvb_parse_file(binary_path.string().c_str()))
|
||||
{
|
||||
if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT))
|
||||
{
|
||||
if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT))
|
||||
sidebar_width_ = static_cast<float>(ikv_as_float(value));
|
||||
if (const ikv_node_t* value = object_value(settings, "details_width", IKV_FLOAT))
|
||||
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, "pull_mode", IKV_INT))
|
||||
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* heights = object_value(settings, "sidebar_sections", IKV_ARRAY)) {
|
||||
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
|
||||
zoom_percent_ = static_cast<int>(ikv_as_int(value));
|
||||
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
|
||||
{
|
||||
const uint32_t count = std::min<uint32_t>(
|
||||
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
|
||||
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* history = object_value(root, "recently_closed", IKV_ARRAY)) {
|
||||
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 12);
|
||||
for (uint32_t index = 0; index < count; ++index) {
|
||||
const char* path = ikv_as_string(ikv_array_get(history, index));
|
||||
if (path && *path) recently_closed_.emplace_back(path);
|
||||
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
|
||||
{
|
||||
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 100);
|
||||
for (uint32_t index = 0; index < count; ++index)
|
||||
{
|
||||
const char *path = ikv_as_string(ikv_array_get(history, index));
|
||||
if (path && *path)
|
||||
recently_closed_.emplace_back(path);
|
||||
}
|
||||
}
|
||||
if (const ikv_node_t* session = object_value(root, "session", IKV_OBJECT)) {
|
||||
if (const ikv_node_t* active = object_value(session, "active_tab", IKV_INT))
|
||||
if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY))
|
||||
{
|
||||
const uint32_t count = std::min<uint32_t>(ikv_array_size(recent), 100);
|
||||
for (uint32_t index = 0; index < count; ++index)
|
||||
{
|
||||
const char *path = ikv_as_string(ikv_array_get(recent, index));
|
||||
if (path && *path)
|
||||
recent_repositories_.emplace_back(path);
|
||||
}
|
||||
}
|
||||
if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT))
|
||||
{
|
||||
if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT))
|
||||
active_repository_ = static_cast<size_t>(std::max<int64_t>(0, ikv_as_int(active)));
|
||||
if (const ikv_node_t* tabs = object_value(session, "tabs", IKV_ARRAY)) {
|
||||
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index) {
|
||||
const char* path = ikv_as_string(ikv_array_get(tabs, index));
|
||||
if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY))
|
||||
{
|
||||
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index)
|
||||
{
|
||||
const char *path = ikv_as_string(ikv_array_get(tabs, index));
|
||||
open_repositories_.emplace_back(path ? path : "");
|
||||
}
|
||||
}
|
||||
@@ -75,81 +114,212 @@ void UserData::load() {
|
||||
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
|
||||
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
|
||||
pull_mode_ = std::clamp(pull_mode_, 0, 3);
|
||||
for (float& height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f);
|
||||
if (open_repositories_.empty()) active_repository_ = 0;
|
||||
else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
|
||||
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
|
||||
for (float &height : sidebar_section_heights_)
|
||||
height = std::clamp(height, 42.0f, 500.0f);
|
||||
if (open_repositories_.empty())
|
||||
active_repository_ = 0;
|
||||
else
|
||||
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const std::filesystem::path text_data_path = directory_ / "user_data.ikv";
|
||||
if (ikv_node_t *root = ikv_parse_file(text_data_path.string().c_str()))
|
||||
{
|
||||
if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT))
|
||||
{
|
||||
if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT))
|
||||
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, "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))
|
||||
zoom_percent_ = static_cast<int>(ikv_as_int(value));
|
||||
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
|
||||
{
|
||||
const uint32_t count = std::min<uint32_t>(
|
||||
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
|
||||
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 *history = object_value(root, "recently_closed", IKV_ARRAY))
|
||||
{
|
||||
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 100);
|
||||
for (uint32_t index = 0; index < count; ++index)
|
||||
{
|
||||
const char *path = ikv_as_string(ikv_array_get(history, index));
|
||||
if (path && *path)
|
||||
recently_closed_.emplace_back(path);
|
||||
}
|
||||
}
|
||||
if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY))
|
||||
{
|
||||
const uint32_t count = std::min<uint32_t>(ikv_array_size(recent), 100);
|
||||
for (uint32_t index = 0; index < count; ++index)
|
||||
{
|
||||
const char *path = ikv_as_string(ikv_array_get(recent, index));
|
||||
if (path && *path)
|
||||
recent_repositories_.emplace_back(path);
|
||||
}
|
||||
}
|
||||
if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT))
|
||||
{
|
||||
if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT))
|
||||
active_repository_ = static_cast<size_t>(std::max<int64_t>(0, ikv_as_int(active)));
|
||||
if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY))
|
||||
{
|
||||
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index)
|
||||
{
|
||||
const char *path = ikv_as_string(ikv_array_get(tabs, index));
|
||||
open_repositories_.emplace_back(path ? path : "");
|
||||
}
|
||||
}
|
||||
}
|
||||
ikv_free(root);
|
||||
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
|
||||
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
|
||||
pull_mode_ = std::clamp(pull_mode_, 0, 3);
|
||||
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
|
||||
for (float &height : sidebar_section_heights_)
|
||||
height = std::clamp(height, 42.0f, 500.0f);
|
||||
if (open_repositories_.empty())
|
||||
active_repository_ = 0;
|
||||
else
|
||||
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
|
||||
save();
|
||||
return;
|
||||
}
|
||||
|
||||
// Import the original files once when upgrading an existing installation.
|
||||
std::ifstream settings(directory_ / "settings.ini");
|
||||
std::string key;
|
||||
while (settings >> key) {
|
||||
if (key == "sidebar_width") settings >> sidebar_width_;
|
||||
else if (key == "details_width") settings >> details_width_;
|
||||
else if (key == "pull_mode") settings >> pull_mode_;
|
||||
else if (key.rfind("sidebar_section_", 0) == 0) {
|
||||
while (settings >> key)
|
||||
{
|
||||
if (key == "sidebar_width")
|
||||
settings >> sidebar_width_;
|
||||
else if (key == "details_width")
|
||||
settings >> details_width_;
|
||||
else if (key == "pull_mode")
|
||||
settings >> pull_mode_;
|
||||
else if (key == "zoom_percent")
|
||||
settings >> zoom_percent_;
|
||||
else if (key.rfind("sidebar_section_", 0) == 0)
|
||||
{
|
||||
const size_t index = static_cast<size_t>(std::stoul(key.substr(16)));
|
||||
float height = 0.0f;
|
||||
settings >> height;
|
||||
if (index < sidebar_section_heights_.size()) sidebar_section_heights_[index] = height;
|
||||
if (index < sidebar_section_heights_.size())
|
||||
sidebar_section_heights_[index] = height;
|
||||
}
|
||||
}
|
||||
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
|
||||
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
|
||||
pull_mode_ = std::clamp(pull_mode_, 0, 3);
|
||||
for (float& height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f);
|
||||
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
|
||||
for (float &height : sidebar_section_heights_)
|
||||
height = std::clamp(height, 42.0f, 500.0f);
|
||||
|
||||
std::ifstream history(directory_ / "history.txt");
|
||||
std::string path;
|
||||
while (history >> std::quoted(path)) {
|
||||
if (!path.empty()) recently_closed_.push_back(path);
|
||||
if (recently_closed_.size() == 12) break;
|
||||
while (history >> std::quoted(path))
|
||||
{
|
||||
if (!path.empty())
|
||||
recently_closed_.push_back(path);
|
||||
if (recently_closed_.size() == 100)
|
||||
break;
|
||||
}
|
||||
recent_repositories_ = recently_closed_;
|
||||
|
||||
std::ifstream session(directory_ / "session.txt");
|
||||
session >> active_repository_;
|
||||
while (session >> std::quoted(path)) open_repositories_.push_back(path);
|
||||
if (open_repositories_.empty()) active_repository_ = 0;
|
||||
else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
|
||||
while (session >> std::quoted(path))
|
||||
open_repositories_.push_back(path);
|
||||
if (open_repositories_.empty())
|
||||
active_repository_ = 0;
|
||||
else
|
||||
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
|
||||
save();
|
||||
}
|
||||
|
||||
void UserData::addRecentlyClosed(const std::string& path) {
|
||||
if (path.empty()) return;
|
||||
void UserData::addRecentRepository(const std::string &path)
|
||||
{
|
||||
if (path.empty())
|
||||
return;
|
||||
std::erase(recent_repositories_, path);
|
||||
recent_repositories_.insert(recent_repositories_.begin(), path);
|
||||
if (recent_repositories_.size() > 100)
|
||||
recent_repositories_.resize(100);
|
||||
save();
|
||||
}
|
||||
|
||||
void UserData::addRecentlyClosed(const std::string &path)
|
||||
{
|
||||
if (path.empty())
|
||||
return;
|
||||
std::erase(recent_repositories_, path);
|
||||
recent_repositories_.insert(recent_repositories_.begin(), path);
|
||||
if (recent_repositories_.size() > 100)
|
||||
recent_repositories_.resize(100);
|
||||
std::erase(recently_closed_, path);
|
||||
recently_closed_.insert(recently_closed_.begin(), path);
|
||||
if (recently_closed_.size() > 12) recently_closed_.resize(12);
|
||||
if (recently_closed_.size() > 100)
|
||||
recently_closed_.resize(100);
|
||||
save();
|
||||
}
|
||||
|
||||
void UserData::setRepositorySession(std::vector<std::string> paths, size_t active_repository) {
|
||||
std::string UserData::takeRecentlyClosed()
|
||||
{
|
||||
if (recently_closed_.empty())
|
||||
return {};
|
||||
std::string path = std::move(recently_closed_.front());
|
||||
recently_closed_.erase(recently_closed_.begin());
|
||||
save();
|
||||
return path;
|
||||
}
|
||||
|
||||
void UserData::setRepositorySession(std::vector<std::string> paths, size_t active_repository)
|
||||
{
|
||||
open_repositories_ = std::move(paths);
|
||||
active_repository_ = open_repositories_.empty()
|
||||
? 0
|
||||
: std::min(active_repository, open_repositories_.size() - 1);
|
||||
? 0
|
||||
: std::min(active_repository, open_repositories_.size() - 1);
|
||||
save();
|
||||
}
|
||||
|
||||
void UserData::save() const {
|
||||
void UserData::save() const
|
||||
{
|
||||
std::filesystem::create_directories(directory_);
|
||||
ikv_node_t* root = ikv_create_object("gitree");
|
||||
if (!root) return;
|
||||
ikv_node_t *root = ikv_create_object("gitree");
|
||||
if (!root)
|
||||
return;
|
||||
|
||||
ikv_node_t* settings = ikv_object_add_object(root, "settings");
|
||||
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_int(settings, "pull_mode", pull_mode_);
|
||||
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_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* history = ikv_object_add_array(root, "recently_closed", IKV_STRING);
|
||||
for (const auto& path : recently_closed_) ikv_array_add_string(history, path.c_str());
|
||||
ikv_node_t *history = ikv_object_add_array(root, "recently_closed", IKV_STRING);
|
||||
for (const auto &path : recently_closed_)
|
||||
ikv_array_add_string(history, path.c_str());
|
||||
|
||||
ikv_node_t* session = ikv_object_add_object(root, "session");
|
||||
ikv_node_t *recent = ikv_object_add_array(root, "recent_repositories", IKV_STRING);
|
||||
for (const auto &path : recent_repositories_)
|
||||
ikv_array_add_string(recent, path.c_str());
|
||||
|
||||
ikv_node_t *session = ikv_object_add_object(root, "session");
|
||||
ikv_object_set_int(session, "active_tab", static_cast<int64_t>(active_repository_));
|
||||
ikv_node_t* tabs = ikv_object_add_array(session, "tabs", IKV_STRING);
|
||||
for (const auto& path : open_repositories_) ikv_array_add_string(tabs, path.c_str());
|
||||
ikv_node_t *tabs = ikv_object_add_array(session, "tabs", IKV_STRING);
|
||||
for (const auto &path : open_repositories_)
|
||||
ikv_array_add_string(tabs, path.c_str());
|
||||
|
||||
ikv_write_file((directory_ / "user_data.ikv").string().c_str(), root);
|
||||
ikvb_write_file_version(dataPath().string().c_str(), root, IKV_VERSION_2);
|
||||
ikv_free(root);
|
||||
removeLegacyFiles();
|
||||
}
|
||||
|
||||
@@ -5,34 +5,49 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class UserData {
|
||||
class UserData
|
||||
{
|
||||
public:
|
||||
UserData();
|
||||
~UserData();
|
||||
|
||||
[[nodiscard]] const std::filesystem::path& directory() const { return directory_; }
|
||||
[[nodiscard]] const std::string& imguiIniPath() const { return imgui_ini_path_; }
|
||||
[[nodiscard]] const std::vector<std::string>& recentlyClosed() const { return recently_closed_; }
|
||||
[[nodiscard]] const std::vector<std::string>& openRepositories() const { return open_repositories_; }
|
||||
[[nodiscard]] const std::filesystem::path &directory() const { return directory_; }
|
||||
[[nodiscard]] const std::vector<std::string> &recentRepositories() const { return recent_repositories_; }
|
||||
[[nodiscard]] const std::vector<std::string> &recentlyClosed() const { return recently_closed_; }
|
||||
[[nodiscard]] const std::vector<std::string> &openRepositories() const { return open_repositories_; }
|
||||
[[nodiscard]] size_t activeRepository() const { return active_repository_; }
|
||||
[[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]] int pullMode() const { return pull_mode_; }
|
||||
[[nodiscard]] int zoomPercent() const { return zoom_percent_; }
|
||||
|
||||
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 setPullMode(int mode) { pull_mode_ = mode; save(); }
|
||||
void addRecentlyClosed(const std::string& path);
|
||||
void setPullMode(int mode)
|
||||
{
|
||||
pull_mode_ = mode;
|
||||
save();
|
||||
}
|
||||
void setZoomPercent(int percent)
|
||||
{
|
||||
zoom_percent_ = percent;
|
||||
save();
|
||||
}
|
||||
void addRecentRepository(const std::string &path);
|
||||
void addRecentlyClosed(const std::string &path);
|
||||
std::string takeRecentlyClosed();
|
||||
void setRepositorySession(std::vector<std::string> paths, size_t active_repository);
|
||||
void save() const;
|
||||
|
||||
private:
|
||||
void load();
|
||||
[[nodiscard]] std::filesystem::path dataPath() const;
|
||||
void removeLegacyFiles() const;
|
||||
|
||||
std::filesystem::path directory_;
|
||||
std::string imgui_ini_path_;
|
||||
std::vector<std::string> recent_repositories_;
|
||||
std::vector<std::string> recently_closed_;
|
||||
std::vector<std::string> open_repositories_;
|
||||
size_t active_repository_ = 0;
|
||||
@@ -40,4 +55,5 @@ private:
|
||||
float details_width_ = 368.0f;
|
||||
std::array<float, 4> sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f};
|
||||
int pull_mode_ = 1;
|
||||
int zoom_percent_ = 100;
|
||||
};
|
||||
|
||||
@@ -2,36 +2,155 @@
|
||||
|
||||
#include <GLFW/glfw3.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
#define GLFW_EXPOSE_NATIVE_WIN32
|
||||
#include <GLFW/glfw3native.h>
|
||||
#include <dwmapi.h>
|
||||
#include <windows.h>
|
||||
#include <wincodec.h>
|
||||
#endif
|
||||
|
||||
WindowManager::~WindowManager() {
|
||||
#ifdef _WIN32
|
||||
namespace
|
||||
{
|
||||
HICON loadPngIcon(const std::filesystem::path &path, UINT size, bool rounded)
|
||||
{
|
||||
const HRESULT com_result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
const bool uninitialize_com = SUCCEEDED(com_result);
|
||||
|
||||
IWICImagingFactory *factory = nullptr;
|
||||
IWICBitmapDecoder *decoder = nullptr;
|
||||
IWICBitmapFrameDecode *frame = nullptr;
|
||||
IWICBitmapScaler *scaler = nullptr;
|
||||
IWICFormatConverter *converter = nullptr;
|
||||
HICON icon = nullptr;
|
||||
|
||||
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(&factory))) ||
|
||||
FAILED(factory->CreateDecoderFromFilename(path.c_str(), nullptr, GENERIC_READ,
|
||||
WICDecodeMetadataCacheOnLoad, &decoder)) ||
|
||||
FAILED(decoder->GetFrame(0, &frame)) ||
|
||||
FAILED(factory->CreateBitmapScaler(&scaler)) ||
|
||||
FAILED(scaler->Initialize(frame, size, size, WICBitmapInterpolationModeFant)) ||
|
||||
FAILED(factory->CreateFormatConverter(&converter)) ||
|
||||
FAILED(converter->Initialize(scaler, GUID_WICPixelFormat32bppBGRA,
|
||||
WICBitmapDitherTypeNone, nullptr, 0.0,
|
||||
WICBitmapPaletteTypeCustom)))
|
||||
{
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
{
|
||||
std::vector<BYTE> pixels(static_cast<size_t>(size) * size * 4);
|
||||
if (FAILED(converter->CopyPixels(nullptr, size * 4,
|
||||
static_cast<UINT>(pixels.size()), pixels.data())))
|
||||
{
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (rounded)
|
||||
{
|
||||
const float radius = static_cast<float>(size) * 0.22f;
|
||||
const float maximum = static_cast<float>(size) - radius - 0.5f;
|
||||
const float minimum = radius - 0.5f;
|
||||
for (UINT y = 0; y < size; ++y)
|
||||
{
|
||||
for (UINT x = 0; x < size; ++x)
|
||||
{
|
||||
const float nearest_x = std::clamp(static_cast<float>(x), minimum, maximum);
|
||||
const float nearest_y = std::clamp(static_cast<float>(y), minimum, maximum);
|
||||
const float dx = static_cast<float>(x) - nearest_x;
|
||||
const float dy = static_cast<float>(y) - nearest_y;
|
||||
const float coverage = std::clamp(radius + 0.5f - std::sqrt(dx * dx + dy * dy),
|
||||
0.0f, 1.0f);
|
||||
pixels[(static_cast<size_t>(y) * size + x) * 4 + 3] =
|
||||
static_cast<BYTE>(coverage * 255.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BITMAPV5HEADER header{};
|
||||
header.bV5Size = sizeof(header);
|
||||
header.bV5Width = static_cast<LONG>(size);
|
||||
header.bV5Height = -static_cast<LONG>(size);
|
||||
header.bV5Planes = 1;
|
||||
header.bV5BitCount = 32;
|
||||
header.bV5Compression = BI_BITFIELDS;
|
||||
header.bV5RedMask = 0x00ff0000;
|
||||
header.bV5GreenMask = 0x0000ff00;
|
||||
header.bV5BlueMask = 0x000000ff;
|
||||
header.bV5AlphaMask = 0xff000000;
|
||||
|
||||
void *bitmap_bits = nullptr;
|
||||
const HDC screen = GetDC(nullptr);
|
||||
const HBITMAP color = CreateDIBSection(screen, reinterpret_cast<BITMAPINFO *>(&header),
|
||||
DIB_RGB_COLORS, &bitmap_bits, nullptr, 0);
|
||||
ReleaseDC(nullptr, screen);
|
||||
const HBITMAP mask = CreateBitmap(size, size, 1, 1, nullptr);
|
||||
if (color && mask && bitmap_bits)
|
||||
{
|
||||
std::memcpy(bitmap_bits, pixels.data(), pixels.size());
|
||||
ICONINFO info{};
|
||||
info.fIcon = TRUE;
|
||||
info.hbmColor = color;
|
||||
info.hbmMask = mask;
|
||||
icon = CreateIconIndirect(&info);
|
||||
}
|
||||
if (color)
|
||||
DeleteObject(color);
|
||||
if (mask)
|
||||
DeleteObject(mask);
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (converter)
|
||||
converter->Release();
|
||||
if (scaler)
|
||||
scaler->Release();
|
||||
if (frame)
|
||||
frame->Release();
|
||||
if (decoder)
|
||||
decoder->Release();
|
||||
if (factory)
|
||||
factory->Release();
|
||||
if (uninitialize_com)
|
||||
CoUninitialize();
|
||||
return icon;
|
||||
}
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
WindowManager::~WindowManager()
|
||||
{
|
||||
destroy();
|
||||
}
|
||||
|
||||
bool WindowManager::create(const char* title, int width, int height) {
|
||||
bool WindowManager::create(const char *title, int width, int height)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
using SetDpiAwarenessContext = BOOL(WINAPI*)(HANDLE);
|
||||
using SetDpiAwarenessContext = BOOL(WINAPI *)(HANDLE);
|
||||
const HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
const FARPROC dpi_address = GetProcAddress(user32, "SetProcessDpiAwarenessContext");
|
||||
SetDpiAwarenessContext set_dpi_awareness = nullptr;
|
||||
static_assert(sizeof(set_dpi_awareness) == sizeof(dpi_address));
|
||||
std::memcpy(&set_dpi_awareness, &dpi_address, sizeof(set_dpi_awareness));
|
||||
if (set_dpi_awareness) set_dpi_awareness(reinterpret_cast<HANDLE>(-4)); // Per-monitor v2
|
||||
if (set_dpi_awareness)
|
||||
set_dpi_awareness(reinterpret_cast<HANDLE>(-4)); // Per-monitor v2
|
||||
#endif
|
||||
if (!glfwInit()) return false;
|
||||
if (!glfwInit())
|
||||
return false;
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
glfwWindowHint(GLFW_SCALE_TO_MONITOR, GLFW_TRUE);
|
||||
window_ = glfwCreateWindow(width, height, title, nullptr, nullptr);
|
||||
if (!window_) {
|
||||
if (!window_)
|
||||
{
|
||||
glfwTerminate();
|
||||
return false;
|
||||
}
|
||||
@@ -45,13 +164,17 @@ bool WindowManager::create(const char* title, int width, int height) {
|
||||
glfwGetWindowContentScale(window_, &x_scale, &y_scale);
|
||||
dpi_scale_ = std::max(x_scale, y_scale);
|
||||
applyNativeCaption();
|
||||
applyNativeIcons();
|
||||
return true;
|
||||
}
|
||||
|
||||
void WindowManager::destroy() {
|
||||
if (!window_) return;
|
||||
void WindowManager::destroy()
|
||||
{
|
||||
if (!window_)
|
||||
return;
|
||||
glfwDestroyWindow(window_);
|
||||
window_ = nullptr;
|
||||
destroyNativeIcons();
|
||||
glfwTerminate();
|
||||
}
|
||||
|
||||
@@ -60,39 +183,126 @@ void WindowManager::swapBuffers() { glfwSwapBuffers(window_); }
|
||||
void WindowManager::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); }
|
||||
bool WindowManager::shouldClose() const { return !window_ || glfwWindowShouldClose(window_); }
|
||||
|
||||
bool WindowManager::consumeDpiChange() {
|
||||
bool WindowManager::consumeDpiChange()
|
||||
{
|
||||
const bool changed = dpi_changed_;
|
||||
dpi_changed_ = false;
|
||||
return changed;
|
||||
}
|
||||
|
||||
void WindowManager::contentScaleCallback(GLFWwindow* window, float x_scale, float y_scale) {
|
||||
auto* manager = static_cast<WindowManager*>(glfwGetWindowUserPointer(window));
|
||||
if (manager) manager->updateDpi(std::max(x_scale, y_scale));
|
||||
void WindowManager::contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale)
|
||||
{
|
||||
auto *manager = static_cast<WindowManager *>(glfwGetWindowUserPointer(window));
|
||||
if (manager)
|
||||
manager->updateDpi(std::max(x_scale, y_scale));
|
||||
}
|
||||
|
||||
void WindowManager::updateDpi(float scale) {
|
||||
void WindowManager::updateDpi(float scale)
|
||||
{
|
||||
scale = std::clamp(scale, 1.0f, 4.0f);
|
||||
if (scale == dpi_scale_) return;
|
||||
if (scale == dpi_scale_)
|
||||
return;
|
||||
dpi_scale_ = scale;
|
||||
dpi_changed_ = true;
|
||||
applyNativeCaption();
|
||||
}
|
||||
|
||||
void WindowManager::applyNativeCaption() const {
|
||||
void WindowManager::applyNativeCaption() const
|
||||
{
|
||||
#ifdef _WIN32
|
||||
if (!window_)
|
||||
return;
|
||||
|
||||
const HWND hwnd = glfwGetWin32Window(window_);
|
||||
if (!hwnd)
|
||||
return;
|
||||
|
||||
const BOOL dark = TRUE;
|
||||
DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark)); // DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
const DWORD square_corners = 1; // DWMWCP_DONOTROUND
|
||||
DwmSetWindowAttribute(hwnd, 33, &square_corners, sizeof(square_corners));
|
||||
|
||||
// DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark));
|
||||
|
||||
DWORD corner_pref = 0;
|
||||
|
||||
switch (corner_mode_)
|
||||
{
|
||||
case WindowCornerMode::Default:
|
||||
corner_pref = 0; // DWMWCP_DEFAULT
|
||||
break;
|
||||
|
||||
case WindowCornerMode::DoNotRound:
|
||||
corner_pref = 1; // DWMWCP_DONOTROUND
|
||||
break;
|
||||
|
||||
case WindowCornerMode::Round:
|
||||
corner_pref = 2; // DWMWCP_ROUND
|
||||
break;
|
||||
|
||||
case WindowCornerMode::RoundSmall:
|
||||
corner_pref = 3; // DWMWCP_ROUNDSMALL
|
||||
break;
|
||||
}
|
||||
|
||||
// DWMWA_WINDOW_CORNER_PREFERENCE
|
||||
DwmSetWindowAttribute(hwnd, 33, &corner_pref, sizeof(corner_pref));
|
||||
|
||||
// Windows 11 caption customization. Older versions safely ignore these.
|
||||
const COLORREF caption = RGB(32, 32, 32);
|
||||
const COLORREF caption = static_cast<COLORREF>(caption_color_);
|
||||
const COLORREF border = RGB(51, 55, 63);
|
||||
const COLORREF text = RGB(199, 203, 209);
|
||||
DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption)); // DWMWA_CAPTION_COLOR
|
||||
DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border)); // DWMWA_BORDER_COLOR
|
||||
DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text)); // DWMWA_TEXT_COLOR
|
||||
|
||||
// DWMWA_BORDER_COLOR
|
||||
DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border));
|
||||
|
||||
// DWMWA_CAPTION_COLOR
|
||||
DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption));
|
||||
|
||||
// DWMWA_TEXT_COLOR
|
||||
DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text));
|
||||
#endif
|
||||
}
|
||||
|
||||
void WindowManager::applyNativeIcons()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
destroyNativeIcons();
|
||||
const HWND hwnd = glfwGetWin32Window(window_);
|
||||
const auto asset_dir = std::filesystem::path(GITREE_IMAGE_ASSET_DIR);
|
||||
window_icon_ = loadPngIcon(asset_dir / L"gitree_logo.png", 32, true);
|
||||
taskbar_icon_ = loadPngIcon(asset_dir / L"gitree_logo.png", 64, true);
|
||||
|
||||
if (window_icon_)
|
||||
{
|
||||
SendMessageW(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(window_icon_));
|
||||
SendMessageW(hwnd, WM_SETICON, ICON_SMALL2, reinterpret_cast<LPARAM>(window_icon_));
|
||||
}
|
||||
if (taskbar_icon_)
|
||||
{
|
||||
SendMessageW(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(taskbar_icon_));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void WindowManager::destroyNativeIcons()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
if (window_icon_)
|
||||
DestroyIcon(static_cast<HICON>(window_icon_));
|
||||
if (taskbar_icon_)
|
||||
DestroyIcon(static_cast<HICON>(taskbar_icon_));
|
||||
#endif
|
||||
window_icon_ = nullptr;
|
||||
taskbar_icon_ = nullptr;
|
||||
}
|
||||
|
||||
void WindowManager::setCornerMode(WindowCornerMode mode)
|
||||
{
|
||||
corner_mode_ = mode;
|
||||
applyNativeCaption();
|
||||
}
|
||||
|
||||
void WindowManager::setCaptionColor(std::uint32_t color)
|
||||
{
|
||||
caption_color_ = color;
|
||||
applyNativeCaption();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct GLFWwindow;
|
||||
|
||||
class WindowManager {
|
||||
class WindowManager
|
||||
{
|
||||
public:
|
||||
enum class WindowCornerMode
|
||||
{
|
||||
Default,
|
||||
DoNotRound,
|
||||
Round,
|
||||
RoundSmall
|
||||
};
|
||||
|
||||
public:
|
||||
WindowManager() = default;
|
||||
~WindowManager();
|
||||
|
||||
WindowManager(const WindowManager&) = delete;
|
||||
WindowManager& operator=(const WindowManager&) = delete;
|
||||
WindowManager(const WindowManager &) = delete;
|
||||
WindowManager &operator=(const WindowManager &) = delete;
|
||||
|
||||
bool create(const char* title, int width, int height);
|
||||
bool create(const char *title, int width, int height);
|
||||
void destroy();
|
||||
void pollEvents();
|
||||
void swapBuffers();
|
||||
void requestClose();
|
||||
|
||||
[[nodiscard]] bool shouldClose() const;
|
||||
[[nodiscard]] GLFWwindow* nativeWindow() const { return window_; }
|
||||
[[nodiscard]] GLFWwindow *nativeWindow() const { return window_; }
|
||||
[[nodiscard]] float dpiScale() const { return dpi_scale_; }
|
||||
|
||||
bool consumeDpiChange();
|
||||
|
||||
void setCornerMode(WindowCornerMode mode);
|
||||
[[nodiscard]] WindowCornerMode cornerMode() const { return corner_mode_; }
|
||||
|
||||
void setCaptionColor(std::uint32_t color);
|
||||
[[nodiscard]] std::uint32_t captionColor() const { return caption_color_; }
|
||||
|
||||
private:
|
||||
static void contentScaleCallback(GLFWwindow* window, float x_scale, float y_scale);
|
||||
static void contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale);
|
||||
|
||||
void updateDpi(float scale);
|
||||
void applyNativeCaption() const;
|
||||
void applyNativeIcons();
|
||||
void destroyNativeIcons();
|
||||
|
||||
GLFWwindow *window_ = nullptr;
|
||||
void *window_icon_ = nullptr;
|
||||
void *taskbar_icon_ = nullptr;
|
||||
|
||||
GLFWwindow* window_ = nullptr;
|
||||
float dpi_scale_ = 1.0f;
|
||||
bool dpi_changed_ = false;
|
||||
|
||||
WindowCornerMode corner_mode_ = WindowCornerMode::Round;
|
||||
|
||||
// 0x00BBGGRR, same layout Windows COLORREF uses.
|
||||
std::uint32_t caption_color_ = 0x00201B19;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <git2.h>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -28,19 +29,39 @@ struct WorkingFile {
|
||||
bool staged = false;
|
||||
};
|
||||
|
||||
struct BranchDivergence {
|
||||
size_t ahead = 0;
|
||||
size_t behind = 0;
|
||||
};
|
||||
|
||||
enum class ToolbarHistoryActionKind { none, checkout, commit };
|
||||
|
||||
struct ToolbarHistoryAction {
|
||||
ToolbarHistoryActionKind kind = ToolbarHistoryActionKind::none;
|
||||
bool available = false;
|
||||
std::string tooltip;
|
||||
std::string source;
|
||||
std::string target;
|
||||
std::string summary;
|
||||
};
|
||||
|
||||
struct CommitInfo {
|
||||
git_oid oid{};
|
||||
std::string short_id;
|
||||
std::string summary;
|
||||
std::string description;
|
||||
std::string author;
|
||||
std::string email;
|
||||
std::string date;
|
||||
int parents = 0;
|
||||
int lane = 0;
|
||||
int graph_color = 0;
|
||||
std::vector<git_oid> parent_ids;
|
||||
std::vector<RefBadge> refs;
|
||||
std::vector<ChangedFile> changed_files;
|
||||
std::vector<ChangedFile> all_files;
|
||||
bool changes_loaded = false;
|
||||
bool all_files_loaded = false;
|
||||
};
|
||||
|
||||
struct RepositoryView {
|
||||
@@ -53,13 +74,19 @@ struct RepositoryView {
|
||||
std::string branch = "detached";
|
||||
std::vector<std::string> local_branches;
|
||||
std::vector<std::string> remote_branches;
|
||||
std::map<std::string, BranchDivergence> local_branch_divergence;
|
||||
std::map<std::string, BranchDivergence> remote_branch_divergence;
|
||||
std::vector<std::string> remotes;
|
||||
std::vector<std::string> worktrees;
|
||||
std::set<std::string> worktree_branches;
|
||||
std::vector<std::string> submodules;
|
||||
std::map<std::string, unsigned int> submodule_statuses;
|
||||
std::vector<CommitInfo> commits;
|
||||
std::vector<WorkingFile> working_files;
|
||||
int selected_commit = 0;
|
||||
int scroll_to_commit = -1;
|
||||
ToolbarHistoryAction undo_action;
|
||||
ToolbarHistoryAction redo_action;
|
||||
|
||||
RepositoryView() = default;
|
||||
~RepositoryView() { close(); }
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
#include "ui/diff_viewer.h"
|
||||
|
||||
#include "managers/avatar_cache.h"
|
||||
#include "managers/git_manager.h"
|
||||
#include "models/repository.h"
|
||||
|
||||
#include <IconsFontAwesome6.h>
|
||||
#include <IconsTabler.h>
|
||||
#include <imgui.h>
|
||||
#include <izo/Dialogs.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <initializer_list>
|
||||
#include <sstream>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
float scaled(float value, float scale) { return value * scale; }
|
||||
@@ -38,41 +43,450 @@ void parseRange(const std::string& header, char marker, int& line) {
|
||||
}
|
||||
}
|
||||
|
||||
ImU32 syntaxColor(const std::string& text) {
|
||||
const size_t first = text.find_first_not_of(" \t");
|
||||
if (first == std::string::npos) return IM_COL32(210, 214, 220, 255);
|
||||
const std::string_view value(text.c_str() + first, text.size() - first);
|
||||
if (value.starts_with("//") || value.starts_with("# ")) return IM_COL32(129, 184, 125, 255);
|
||||
if (value.starts_with('#')) return IM_COL32(205, 157, 222, 255);
|
||||
static constexpr const char* keywords[] = {
|
||||
"class ", "struct ", "enum ", "if ", "else", "for ", "while ", "return ",
|
||||
"const ", "static ", "void ", "bool ", "int ", "float ", "auto ", "namespace "
|
||||
enum class SyntaxLanguage { plain, c, cpp, csharp, lua, python, javascript };
|
||||
enum class SyntaxContinuation { none, block_comment, lua_comment, python_single, python_double, template_string };
|
||||
|
||||
struct SyntaxState {
|
||||
SyntaxContinuation continuation = SyntaxContinuation::none;
|
||||
};
|
||||
|
||||
[[maybe_unused]] ImU32 blameColor(std::string_view hash, int alpha = 255) {
|
||||
static constexpr ImU32 colors[] = {
|
||||
IM_COL32(24, 181, 204, 255), IM_COL32(73, 123, 235, 255), IM_COL32(200, 64, 200, 255),
|
||||
IM_COL32(239, 79, 89, 255), IM_COL32(255, 122, 41, 255), IM_COL32(240, 186, 46, 255),
|
||||
IM_COL32(64, 186, 128, 255),
|
||||
};
|
||||
for (const char* keyword : keywords)
|
||||
if (value.starts_with(keyword)) return IM_COL32(124, 177, 228, 255);
|
||||
if (value.find('"') != std::string_view::npos || value.find('\'') != std::string_view::npos)
|
||||
return IM_COL32(226, 166, 140, 255);
|
||||
return IM_COL32(218, 221, 226, 255);
|
||||
uint32_t value = 2166136261u;
|
||||
for (const unsigned char character : hash) value = (value ^ character) * 16777619u;
|
||||
return (colors[value % std::size(colors)] & ~IM_COL32_A_MASK) | (static_cast<ImU32>(alpha) << IM_COL32_A_SHIFT);
|
||||
}
|
||||
|
||||
[[maybe_unused]] SyntaxLanguage languageForPath(const std::string& path) {
|
||||
std::string extension = std::filesystem::path(path).extension().string();
|
||||
std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char value) {
|
||||
return static_cast<char>(std::tolower(value));
|
||||
});
|
||||
if (extension == ".c") return SyntaxLanguage::c;
|
||||
if (extension == ".h" || extension == ".hh" || extension == ".hpp" || extension == ".hxx" ||
|
||||
extension == ".cc" || extension == ".cpp" || extension == ".cxx") return SyntaxLanguage::cpp;
|
||||
if (extension == ".cs") return SyntaxLanguage::csharp;
|
||||
if (extension == ".lua") return SyntaxLanguage::lua;
|
||||
if (extension == ".py" || extension == ".pyw") return SyntaxLanguage::python;
|
||||
if (extension == ".js" || extension == ".jsx" || extension == ".mjs" || extension == ".cjs")
|
||||
return SyntaxLanguage::javascript;
|
||||
return SyntaxLanguage::plain;
|
||||
}
|
||||
|
||||
bool wordIs(std::string_view word, std::initializer_list<std::string_view> values) {
|
||||
return std::find(values.begin(), values.end(), word) != values.end();
|
||||
}
|
||||
|
||||
bool isKeyword(SyntaxLanguage language, std::string_view word) {
|
||||
static constexpr std::string_view common[] = {
|
||||
"break", "case", "continue", "default", "do", "else", "for", "if", "return", "switch", "while"
|
||||
};
|
||||
if (std::find(std::begin(common), std::end(common), word) != std::end(common)) return true;
|
||||
switch (language) {
|
||||
case SyntaxLanguage::c:
|
||||
return wordIs(word, {"const", "enum", "extern", "goto", "register", "sizeof", "static", "struct", "typedef", "union", "volatile"});
|
||||
case SyntaxLanguage::cpp:
|
||||
return wordIs(word, {"alignas", "auto", "class", "concept", "const", "constexpr", "consteval", "constinit", "co_await", "co_return", "co_yield", "decltype", "delete", "enum", "explicit", "export", "extern", "friend", "inline", "mutable", "namespace", "new", "noexcept", "operator", "override", "private", "protected", "public", "requires", "sizeof", "static", "struct", "template", "this", "thread_local", "throw", "try", "typedef", "typename", "union", "using", "virtual", "volatile"});
|
||||
case SyntaxLanguage::csharp:
|
||||
return wordIs(word, {"abstract", "as", "async", "await", "base", "class", "const", "delegate", "enum", "event", "explicit", "extern", "finally", "fixed", "foreach", "implicit", "in", "interface", "internal", "is", "lock", "namespace", "new", "operator", "out", "override", "params", "private", "protected", "public", "readonly", "record", "ref", "sealed", "sizeof", "stackalloc", "static", "struct", "this", "throw", "try", "typeof", "unchecked", "unsafe", "using", "virtual", "volatile", "where", "yield"});
|
||||
case SyntaxLanguage::lua:
|
||||
return wordIs(word, {"and", "elseif", "end", "false", "function", "goto", "in", "local", "nil", "not", "or", "repeat", "then", "true", "until"});
|
||||
case SyntaxLanguage::python:
|
||||
return wordIs(word, {"and", "as", "assert", "async", "await", "class", "def", "del", "elif", "except", "False", "finally", "from", "global", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "True", "try", "with", "yield"});
|
||||
case SyntaxLanguage::javascript:
|
||||
return wordIs(word, {"async", "await", "catch", "class", "const", "debugger", "delete", "export", "extends", "false", "finally", "from", "function", "get", "import", "in", "instanceof", "let", "new", "null", "of", "set", "static", "super", "this", "throw", "true", "try", "typeof", "undefined", "var", "void", "with", "yield"});
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool isTypeWord(SyntaxLanguage language, std::string_view word) {
|
||||
if (language == SyntaxLanguage::python || language == SyntaxLanguage::lua || language == SyntaxLanguage::plain)
|
||||
return false;
|
||||
return wordIs(word, {"bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "int8_t", "int16_t", "int32_t", "int64_t", "long", "nint", "nuint", "object", "sbyte", "short", "signed", "size_t", "string", "uint", "uint8_t", "uint16_t", "uint32_t", "uint64_t", "ulong", "unsigned", "ushort", "var", "void", "wchar_t"});
|
||||
}
|
||||
|
||||
bool isBuiltin(SyntaxLanguage language, std::string_view word) {
|
||||
switch (language) {
|
||||
case SyntaxLanguage::cpp:
|
||||
return wordIs(word, {"std", "move", "forward", "make_shared", "make_unique", "optional", "string_view", "vector"});
|
||||
case SyntaxLanguage::csharp:
|
||||
return wordIs(word, {"Console", "DateTime", "Dictionary", "Enumerable", "List", "Task", "ValueTask"});
|
||||
case SyntaxLanguage::lua:
|
||||
return wordIs(word, {"assert", "error", "ipairs", "io", "math", "os", "pairs", "pcall", "print", "require", "string", "table", "tonumber", "tostring", "type"});
|
||||
case SyntaxLanguage::python:
|
||||
return wordIs(word, {"abs", "all", "any", "dict", "enumerate", "filter", "float", "int", "len", "list", "map", "max", "min", "object", "open", "print", "range", "reversed", "set", "str", "sum", "super", "tuple", "zip"});
|
||||
case SyntaxLanguage::javascript:
|
||||
return wordIs(word, {"Array", "Boolean", "console", "Date", "Error", "JSON", "Map", "Math", "Number", "Object", "process", "Promise", "RegExp", "require", "Set", "String", "Symbol"});
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool isLiteral(std::string_view word) {
|
||||
return wordIs(word, {"false", "False", "nil", "null", "nullptr", "None", "true", "True", "undefined"});
|
||||
}
|
||||
|
||||
bool isDeclarationKeyword(std::string_view word) {
|
||||
return wordIs(word, {"class", "concept", "delegate", "enum", "interface", "namespace", "record", "struct", "type"});
|
||||
}
|
||||
|
||||
bool isFunctionDeclarationKeyword(std::string_view word) {
|
||||
return word == "def" || word == "function";
|
||||
}
|
||||
|
||||
bool isMacroName(std::string_view word) {
|
||||
bool has_letter = false;
|
||||
for (const unsigned char character : word) {
|
||||
if (std::isalpha(character)) {
|
||||
has_letter = true;
|
||||
if (std::islower(character)) return false;
|
||||
} else if (!std::isdigit(character) && character != '_') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return has_letter && word.size() > 1;
|
||||
}
|
||||
|
||||
[[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;
|
||||
const char* last = text.data() + end;
|
||||
draw->AddText(position, color, first, last);
|
||||
position.x += ImGui::CalcTextSize(first, last, false).x;
|
||||
};
|
||||
if (language == SyntaxLanguage::plain) {
|
||||
drawSpan(0, text.size(), 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);
|
||||
return;
|
||||
}
|
||||
|
||||
size_t cursor = 0;
|
||||
std::string_view previous_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);
|
||||
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);
|
||||
cursor = end + 2;
|
||||
state.continuation = SyntaxContinuation::none;
|
||||
continue;
|
||||
}
|
||||
if (state.continuation == SyntaxContinuation::python_single ||
|
||||
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);
|
||||
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);
|
||||
cursor = end + 1;
|
||||
state.continuation = SyntaxContinuation::none;
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
state.continuation = SyntaxContinuation::block_comment;
|
||||
return;
|
||||
}
|
||||
drawSpan(cursor, end + 2, comment);
|
||||
cursor = end + 2;
|
||||
continue;
|
||||
}
|
||||
if (language == SyntaxLanguage::python && text[cursor] == '#') {
|
||||
drawSpan(cursor, text.size(), 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);
|
||||
state.continuation = SyntaxContinuation::lua_comment;
|
||||
return;
|
||||
}
|
||||
drawSpan(cursor, end + 2, comment);
|
||||
cursor = end + 2;
|
||||
continue;
|
||||
}
|
||||
if (language == SyntaxLanguage::lua && text.compare(cursor, 2, "--") == 0) {
|
||||
drawSpan(cursor, text.size(), comment);
|
||||
return;
|
||||
}
|
||||
if (language == SyntaxLanguage::python &&
|
||||
(text.compare(cursor, 3, "'''") == 0 || text.compare(cursor, 3, "\"\"\"") == 0)) {
|
||||
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);
|
||||
state.continuation = delimiter[0] == '\'' ? SyntaxContinuation::python_single : SyntaxContinuation::python_double;
|
||||
return;
|
||||
}
|
||||
drawSpan(cursor, end + 3, string);
|
||||
cursor = end + 3;
|
||||
continue;
|
||||
}
|
||||
if (text[cursor] == '\'' || text[cursor] == '"' ||
|
||||
(language == SyntaxLanguage::javascript && text[cursor] == '`')) {
|
||||
const char quote = text[cursor];
|
||||
size_t end = cursor + 1;
|
||||
bool escaped = false;
|
||||
for (; end < text.size(); ++end) {
|
||||
if (!escaped && text[end] == quote) { ++end; break; }
|
||||
escaped = !escaped && text[end] == '\\';
|
||||
if (text[end] != '\\') escaped = false;
|
||||
}
|
||||
const bool closed = end <= text.size() && end > cursor + 1 && text[end - 1] == quote;
|
||||
drawSpan(cursor, end, string);
|
||||
if (quote == '`' && !closed) state.continuation = SyntaxContinuation::template_string;
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if (std::isdigit(static_cast<unsigned char>(text[cursor]))) {
|
||||
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);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if (text[cursor] == '@' && cursor + 1 < text.size() &&
|
||||
(std::isalpha(static_cast<unsigned char>(text[cursor + 1])) || text[cursor + 1] == '_')) {
|
||||
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);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if (std::isalpha(static_cast<unsigned char>(text[cursor])) || 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);
|
||||
size_t next = end;
|
||||
while (next < text.size() && std::isspace(static_cast<unsigned char>(text[next]))) ++next;
|
||||
size_t previous = cursor;
|
||||
while (previous > 0 && std::isspace(static_cast<unsigned char>(text[previous - 1]))) --previous;
|
||||
const bool member_access = previous > 0 && (text[previous - 1] == '.' ||
|
||||
(text[previous - 1] == '>' && previous > 1 && text[previous - 2] == '-') ||
|
||||
(text[previous - 1] == ':' && previous > 1 && text[previous - 2] == ':'));
|
||||
const bool declared_name = isDeclarationKeyword(previous_word);
|
||||
const bool declared_function = isFunctionDeclarationKeyword(previous_word);
|
||||
const bool capitalized_type =
|
||||
(language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) &&
|
||||
std::isupper(static_cast<unsigned char>(word.front())) && !member_access &&
|
||||
(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;
|
||||
drawSpan(cursor, end, color);
|
||||
previous_word = word;
|
||||
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;
|
||||
drawSpan(cursor, end, operator_color);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
size_t end = cursor + 1;
|
||||
while (end < text.size()) {
|
||||
const unsigned char value = static_cast<unsigned char>(text[end]);
|
||||
const bool token_start = std::isalnum(value) || text[end] == '_' || text[end] == '\'' ||
|
||||
text[end] == '"' || (language == SyntaxLanguage::javascript && text[end] == '`') ||
|
||||
(slash_comments && text[end] == '/') || (language == SyntaxLanguage::python && text[end] == '#') ||
|
||||
(language == SyntaxLanguage::lua && text[end] == '-') || text[end] == '@' ||
|
||||
std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos;
|
||||
if (token_start) break;
|
||||
++end;
|
||||
}
|
||||
drawSpan(cursor, end, normal);
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
|
||||
bool compactButton(const char* label, bool active = false) {
|
||||
if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.25f, 0.43f, 1.0f));
|
||||
const bool clicked = ImGui::SmallButton(label);
|
||||
const bool icon_only = label && std::strlen(label) <= 4;
|
||||
const bool clicked = icon_only
|
||||
? ImGui::Button(label, {ImGui::GetFrameHeight(), ImGui::GetFrameHeight()})
|
||||
: ImGui::SmallButton(label);
|
||||
if (active) ImGui::PopStyleColor();
|
||||
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];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
struct MinimapEntry {
|
||||
size_t length = 0;
|
||||
ImU32 color = IM_COL32(120, 126, 136, 255);
|
||||
};
|
||||
|
||||
void drawMinimap(const std::vector<MinimapEntry>& entries, float scale,
|
||||
float rendered_content_height, float visible_start, float visible_height) {
|
||||
const ImVec2 minimum = ImGui::GetCursorScreenPos();
|
||||
const ImVec2 size = ImGui::GetContentRegionAvail();
|
||||
ImGui::InvisibleButton("##code_minimap", size);
|
||||
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));
|
||||
|
||||
if (entries.empty() || size.y <= 2.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_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 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);
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
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) {
|
||||
const float row_height = scaled(21.0f, scale);
|
||||
const ImVec2 start = ImGui::GetCursorScreenPos();
|
||||
const float 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);
|
||||
}
|
||||
|
||||
void drawCodeLineNumber(int value, float x, float y, ImU32 color) {
|
||||
if (value <= 0) return;
|
||||
const std::string text = std::to_string(value);
|
||||
ImGui::GetWindowDrawList()->AddText({x, y}, color, text.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std::string& path,
|
||||
bool staged, std::string& notice) {
|
||||
path_ = path;
|
||||
commit_id_.clear();
|
||||
staged_ = staged;
|
||||
mode_ = Mode::diff;
|
||||
reload(repository, manager, notice);
|
||||
}
|
||||
|
||||
void DiffViewer::openCommit(RepositoryView& repository, GitManager& manager,
|
||||
const std::string& path, const std::string& commit_id, std::string& notice) {
|
||||
path_ = path;
|
||||
commit_id_ = commit_id;
|
||||
staged_ = false;
|
||||
mode_ = Mode::diff;
|
||||
reload(repository, manager, notice);
|
||||
if (hunks_.empty()) {
|
||||
mode_ = Mode::file;
|
||||
loadSupplement(repository, manager, mode_, notice);
|
||||
}
|
||||
}
|
||||
|
||||
void DiffViewer::close() {
|
||||
path_.clear();
|
||||
commit_id_.clear();
|
||||
file_header_.clear();
|
||||
hunks_.clear();
|
||||
file_lines_.clear();
|
||||
@@ -118,16 +532,73 @@ void DiffViewer::parseDiff(const std::string& text) {
|
||||
}
|
||||
}
|
||||
|
||||
void DiffViewer::parseBlame(const std::string& text) {
|
||||
blame_lines_.clear();
|
||||
const std::vector<std::string> lines = splitLines(text);
|
||||
std::string previous_hash;
|
||||
for (size_t index = 0; index < lines.size();) {
|
||||
std::istringstream header(lines[index++]);
|
||||
BlameLine line;
|
||||
int original_line = 0;
|
||||
if (!(header >> line.hash >> original_line >> line.line_number)) continue;
|
||||
if (line.hash.size() < 8 || !std::all_of(line.hash.begin(), line.hash.end(), [](unsigned char value) {
|
||||
return std::isxdigit(value) != 0;
|
||||
})) continue;
|
||||
|
||||
std::time_t author_time = 0;
|
||||
while (index < lines.size()) {
|
||||
const std::string& field = lines[index++];
|
||||
if (!field.empty() && field.front() == '\t') {
|
||||
line.text = field.substr(1);
|
||||
break;
|
||||
}
|
||||
const size_t separator = field.find(' ');
|
||||
const std::string_view key(field.data(), separator == std::string::npos ? field.size() : separator);
|
||||
const std::string value = separator == std::string::npos ? std::string{} : field.substr(separator + 1);
|
||||
if (key == "author") line.author = value;
|
||||
else if (key == "author-mail") {
|
||||
line.email = value;
|
||||
if (line.email.size() >= 2 && line.email.front() == '<' && line.email.back() == '>')
|
||||
line.email = line.email.substr(1, line.email.size() - 2);
|
||||
}
|
||||
else if (key == "author-time") {
|
||||
try { author_time = static_cast<std::time_t>(std::stoll(value)); }
|
||||
catch (...) { author_time = 0; }
|
||||
} else if (key == "summary") line.summary = value;
|
||||
}
|
||||
if (author_time != 0) {
|
||||
std::tm date{};
|
||||
#ifdef _WIN32
|
||||
localtime_s(&date, &author_time);
|
||||
#else
|
||||
localtime_r(&author_time, &date);
|
||||
#endif
|
||||
std::ostringstream formatted;
|
||||
formatted << std::put_time(&date, "%Y-%m-%d");
|
||||
line.date = formatted.str();
|
||||
}
|
||||
line.show_attribution = line.hash != previous_hash;
|
||||
previous_hash = line.hash;
|
||||
blame_lines_.push_back(std::move(line));
|
||||
}
|
||||
}
|
||||
|
||||
void DiffViewer::reload(RepositoryView& repository, GitManager& manager, std::string& notice) {
|
||||
std::vector<std::string> arguments{"diff", "--no-ext-diff", "--no-color", "--unified=3"};
|
||||
if (staged_) arguments.push_back("--cached");
|
||||
std::vector<std::string> arguments;
|
||||
if (commit_id_.empty()) {
|
||||
arguments = {"diff", "--no-ext-diff", "--no-color", "--unified=3"};
|
||||
if (staged_) arguments.push_back("--cached");
|
||||
} else {
|
||||
arguments = {"show", "--first-parent", "--format=", "--no-ext-diff", "--no-color",
|
||||
"--unified=3", commit_id_};
|
||||
}
|
||||
arguments.insert(arguments.end(), {"--", path_});
|
||||
std::string output;
|
||||
std::string error;
|
||||
if (!manager.captureGit(repository, arguments, output, error)) notice = error;
|
||||
parseDiff(output);
|
||||
|
||||
if (hunks_.empty()) {
|
||||
if (hunks_.empty() && commit_id_.empty()) {
|
||||
const auto file = std::find_if(repository.working_files.begin(), repository.working_files.end(),
|
||||
[this](const WorkingFile& item) { return item.path == path_; });
|
||||
if (file != repository.working_files.end() && file->kind == FileChangeKind::added && !staged_) {
|
||||
@@ -158,7 +629,10 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager,
|
||||
std::string output;
|
||||
std::string error;
|
||||
if (mode == Mode::file) {
|
||||
if (staged_) {
|
||||
if (!commit_id_.empty()) {
|
||||
if (!manager.captureGit(repository, {"show", commit_id_ + ":" + path_}, output, error))
|
||||
notice = error;
|
||||
} else if (staged_) {
|
||||
if (!manager.captureGit(repository, {"show", ":" + path_}, output, error)) notice = error;
|
||||
} else {
|
||||
std::ifstream stream(std::filesystem::path(repository.path) / path_, std::ios::binary);
|
||||
@@ -168,26 +642,36 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager,
|
||||
}
|
||||
file_lines_ = splitLines(output);
|
||||
} else if (mode == Mode::blame) {
|
||||
if (!manager.captureGit(repository, {"blame", "--date=short", "--", path_}, output, error)) notice = error;
|
||||
blame_lines_ = splitLines(output);
|
||||
std::vector<std::string> arguments{"blame", "--line-porcelain"};
|
||||
if (!commit_id_.empty()) arguments.push_back(commit_id_);
|
||||
arguments.insert(arguments.end(), {"--", path_});
|
||||
if (!manager.captureGit(repository, arguments, output, error)) notice = error;
|
||||
parseBlame(output);
|
||||
} else if (mode == Mode::history) {
|
||||
if (!manager.captureGit(repository,
|
||||
{"log", "--follow", "--date=short", "--pretty=format:%h %ad %an %s", "--", path_},
|
||||
output, error)) notice = error;
|
||||
std::vector<std::string> arguments{
|
||||
"log", "--follow", "--date=short", "--pretty=format:%h %ad %an %s"};
|
||||
if (!commit_id_.empty()) arguments.push_back(commit_id_);
|
||||
arguments.insert(arguments.end(), {"--", path_});
|
||||
if (!manager.captureGit(repository, arguments, output, error)) notice = error;
|
||||
history_lines_ = splitLines(output);
|
||||
}
|
||||
}
|
||||
|
||||
void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice) {
|
||||
void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars,
|
||||
float scale, ImFont* code_font, std::string& notice) {
|
||||
(void)avatars;
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {scaled(8, scale), scaled(5, scale)});
|
||||
ImGui::BeginChild("diff_viewer", {-1, -1}, ImGuiChildFlags_None,
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN);
|
||||
ImGui::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)));
|
||||
if (compactButton(staged_ ? "Staged" : "Unstaged", true)) {}
|
||||
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);
|
||||
@@ -202,63 +686,74 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
|
||||
if (compactButton("History", mode_ == Mode::history)) {
|
||||
mode_ = Mode::history; loadSupplement(repository, manager, mode_, notice);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.43f, 0.90f, 0.51f, 1));
|
||||
if (compactButton(staged_ ? "Unstage File" : "Stage File")) {
|
||||
const bool changed = staged_ ? manager.unstageFile(repository, path_, notice)
|
||||
: manager.stageFile(repository, path_, notice);
|
||||
if (changed) { staged_ = !staged_; reload(repository, manager, notice); }
|
||||
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::PopStyleColor();
|
||||
ImGui::SameLine();
|
||||
if (compactButton(ICON_FA_XMARK)) close();
|
||||
if (compactButton(ICON_TB_XMARK)) close();
|
||||
ImGui::Separator();
|
||||
if (!path_.empty()) {
|
||||
if (compactButton(ICON_FA_PEN " Edit This File")) {
|
||||
std::string error;
|
||||
if (!izo::OpenPath(std::filesystem::path(repository.path) / path_, &error)) notice = error;
|
||||
}
|
||||
ImGui::SameLine(ImGui::GetWindowWidth() - scaled(116, scale));
|
||||
ImGui::SetCursorPosX(ImGui::GetWindowWidth() - scaled(116, scale));
|
||||
ImGui::TextDisabled("UTF-8");
|
||||
if (!staged_) {
|
||||
if (!historical && !staged_) {
|
||||
ImGui::SameLine();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1));
|
||||
if (compactButton(ICON_FA_TRASH_CAN)) {
|
||||
if (compactButton(ICON_TB_TRASH_CAN)) {
|
||||
if (manager.discardFile(repository, path_, notice)) close();
|
||||
}
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
ImGui::Separator();
|
||||
ImGui::Separator();
|
||||
}
|
||||
|
||||
ImGui::BeginChild("diff_content", {-1, -1}, ImGuiChildFlags_None,
|
||||
ImGuiWindowFlags_HorizontalScrollbar);
|
||||
const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file;
|
||||
const float minimap_width = scaled(56.0f, scale);
|
||||
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)});
|
||||
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({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({0, IM_COL32(0, 0, 0, 0)});
|
||||
}
|
||||
} else {
|
||||
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)});
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::BeginChild("diff_content_main",
|
||||
show_minimap ? ImVec2{-minimap_width - scaled(6.0f, scale), -1} : ImVec2{-1, -1},
|
||||
ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar);
|
||||
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 number_width = scaled(48, scale);
|
||||
auto draw_line = [&](const std::string& text, int old_number, int new_number, LineKind kind) {
|
||||
ImGui::InvisibleButton("##line", {std::max(ImGui::GetContentRegionAvail().x, scaled(900, scale)), row_height});
|
||||
const ImVec2 minimum = ImGui::GetItemRectMin();
|
||||
const ImVec2 maximum = ImGui::GetItemRectMax();
|
||||
ImDrawList* draw = ImGui::GetWindowDrawList();
|
||||
if (kind == LineKind::added) draw->AddRectFilled(minimum, maximum, IM_COL32(31, 65, 43, 225));
|
||||
else if (kind == LineKind::removed) draw->AddRectFilled(minimum, maximum, IM_COL32(70, 38, 40, 225));
|
||||
draw->AddRectFilled(minimum, {minimum.x + number_width * 2, maximum.y}, IM_COL32(31, 34, 40, 255));
|
||||
char old_buffer[16]{};
|
||||
char new_buffer[16]{};
|
||||
if (old_number) std::snprintf(old_buffer, sizeof(old_buffer), "%d", old_number);
|
||||
if (new_number) std::snprintf(new_buffer, sizeof(new_buffer), "%d", new_number);
|
||||
draw->AddText({minimum.x + scaled(5, scale), minimum.y + scaled(2, scale)},
|
||||
IM_COL32(158, 164, 174, 255), old_buffer);
|
||||
draw->AddText({minimum.x + number_width + scaled(5, scale), minimum.y + scaled(2, scale)},
|
||||
IM_COL32(158, 164, 174, 255), new_buffer);
|
||||
const char marker = kind == LineKind::added ? '+' : kind == LineKind::removed ? '-' : ' ';
|
||||
char marker_text[2]{marker, 0};
|
||||
draw->AddText({minimum.x + number_width * 2 + scaled(5, scale), minimum.y + scaled(2, scale)},
|
||||
kind == LineKind::added ? IM_COL32(87, 190, 112, 255) :
|
||||
kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) : IM_COL32(148, 154, 164, 255), marker_text);
|
||||
draw->AddText({minimum.x + number_width * 2 + scaled(22, scale), minimum.y + scaled(2, scale)},
|
||||
syntaxColor(text), text.c_str());
|
||||
};
|
||||
const float content_width = std::max(ImGui::GetContentRegionAvail().x, scaled(200.0f, scale));
|
||||
const SyntaxLanguage language = languageForPath(path_);
|
||||
|
||||
// Keep the first source row clear of the toolbar and aligned with a normal row inset.
|
||||
ImGui::Dummy({0.0f, row_height});
|
||||
|
||||
if (mode_ == Mode::diff) {
|
||||
if (hunks_.empty()) ImGui::TextDisabled("No textual diff is available for this file.");
|
||||
@@ -267,12 +762,19 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
|
||||
enum class HunkAction { none, stage, discard, unstage };
|
||||
HunkAction pending_action = HunkAction::none;
|
||||
for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) {
|
||||
SyntaxState old_syntax;
|
||||
SyntaxState new_syntax;
|
||||
ImGui::PushID(static_cast<int>(hunk_index));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.56f, 0.70f, 0.90f, 1));
|
||||
if (hunk_index > 0) {
|
||||
ImGui::Dummy({0.0f, scaled(11.0f, scale)});
|
||||
ImGui::Separator();
|
||||
ImGui::Dummy({0.0f, scaled(8.0f, scale)});
|
||||
}
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.62f, 0.64f, 0.68f, 1.0f));
|
||||
ImGui::TextUnformatted(hunks_[hunk_index].header.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::SameLine(std::max(scaled(220, scale), ImGui::GetWindowWidth() - scaled(205, scale)));
|
||||
if (!staged_) {
|
||||
if (!historical && !staged_) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1));
|
||||
if (ImGui::SmallButton("Discard Hunk")) {
|
||||
pending_hunk = static_cast<int>(hunk_index);
|
||||
@@ -286,13 +788,27 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
|
||||
pending_action = HunkAction::stage;
|
||||
}
|
||||
ImGui::PopStyleColor();
|
||||
} else if (ImGui::SmallButton("Unstage Hunk")) {
|
||||
} else if (!historical && ImGui::SmallButton("Unstage Hunk")) {
|
||||
pending_hunk = static_cast<int>(hunk_index);
|
||||
pending_action = HunkAction::unstage;
|
||||
}
|
||||
ImGui::Separator();
|
||||
ImGui::Dummy({0.0f, scaled(4.0f, scale)});
|
||||
const float number_column = scaled(44.0f, scale);
|
||||
const float code_gutter = number_column * 2.0f + scaled(14.0f, scale);
|
||||
for (const Line& line : hunks_[hunk_index].lines) {
|
||||
ImGui::PushID(line_id++);
|
||||
draw_line(line.text, line.old_number, line.new_number, line.kind);
|
||||
const ImVec2 line_minimum = ImGui::GetCursorScreenPos();
|
||||
const ImU32 background = line.kind == LineKind::added ? IM_COL32(30, 68, 46, 170) :
|
||||
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);
|
||||
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));
|
||||
drawCodeLineNumber(line.new_number, line_minimum.x + number_column + scaled(6.0f, scale), text_y,
|
||||
IM_COL32(126, 132, 142, 255));
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::PopID();
|
||||
@@ -303,18 +819,57 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
|
||||
if (manager.applyPatch(repository, hunks_[pending_hunk].patch, cached, reverse, notice))
|
||||
reload(repository, manager, notice);
|
||||
}
|
||||
} else if (mode_ == Mode::blame) {
|
||||
std::ostringstream blame_text;
|
||||
for (size_t index = 0; index < blame_lines_.size(); ++index) {
|
||||
const BlameLine& line = blame_lines_[index];
|
||||
if (index) blame_text << '\n';
|
||||
blame_text << line.line_number << " ";
|
||||
if (line.show_attribution) {
|
||||
blame_text << line.summary;
|
||||
if (!line.date.empty()) blame_text << " " << line.date;
|
||||
blame_text << " ";
|
||||
} else {
|
||||
blame_text << "| ";
|
||||
}
|
||||
blame_text << line.text;
|
||||
}
|
||||
drawSelectableTextBlock("##blame_text", blame_text.str(), {-1, -1});
|
||||
if (blame_lines_.empty()) ImGui::TextDisabled("No blame data is available for this file.");
|
||||
} else {
|
||||
const std::vector<std::string>* lines = mode_ == Mode::file ? &file_lines_ :
|
||||
mode_ == Mode::blame ? &blame_lines_ : &history_lines_;
|
||||
for (size_t index = 0; index < lines->size(); ++index) {
|
||||
ImGui::PushID(static_cast<int>(index));
|
||||
draw_line((*lines)[index], mode_ == Mode::file ? static_cast<int>(index + 1) : 0,
|
||||
mode_ == Mode::file ? static_cast<int>(index + 1) : 0, LineKind::context);
|
||||
ImGui::PopID();
|
||||
const std::vector<std::string>* lines = mode_ == Mode::file ? &file_lines_ : &history_lines_;
|
||||
if (mode_ == Mode::file) {
|
||||
SyntaxState syntax;
|
||||
const float code_gutter = scaled(52.0f, scale);
|
||||
for (size_t index = 0; index < lines->size(); ++index) {
|
||||
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);
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
drawSelectableTextBlock("##history_text", joinLines(*lines), {-1, -1});
|
||||
}
|
||||
if (lines->empty()) ImGui::TextDisabled("No data is available for this view.");
|
||||
}
|
||||
main_scroll_y = ImGui::GetScrollY();
|
||||
main_window_height = ImGui::GetWindowHeight();
|
||||
rendered_content_height = ImGui::GetCursorPosY();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
#include <vector>
|
||||
|
||||
class GitManager;
|
||||
class AvatarCache;
|
||||
struct RepositoryView;
|
||||
struct ImFont;
|
||||
|
||||
class DiffViewer {
|
||||
public:
|
||||
void open(RepositoryView& repository, GitManager& manager, const std::string& path,
|
||||
bool staged, std::string& notice);
|
||||
void openCommit(RepositoryView& repository, GitManager& manager, const std::string& path,
|
||||
const std::string& commit_id, std::string& notice);
|
||||
void close();
|
||||
bool isOpen() const { return !path_.empty(); }
|
||||
void draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice);
|
||||
void draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars,
|
||||
float scale, ImFont* code_font, std::string& notice);
|
||||
|
||||
private:
|
||||
enum class Mode { diff, file, blame, history };
|
||||
@@ -29,17 +34,29 @@ private:
|
||||
std::vector<Line> lines;
|
||||
std::string patch;
|
||||
};
|
||||
struct BlameLine {
|
||||
std::string hash;
|
||||
std::string author;
|
||||
std::string email;
|
||||
std::string date;
|
||||
std::string summary;
|
||||
std::string text;
|
||||
int line_number = 0;
|
||||
bool show_attribution = false;
|
||||
};
|
||||
|
||||
std::string path_;
|
||||
std::string commit_id_;
|
||||
bool staged_ = false;
|
||||
Mode mode_ = Mode::diff;
|
||||
std::string file_header_;
|
||||
std::vector<Hunk> hunks_;
|
||||
std::vector<std::string> file_lines_;
|
||||
std::vector<std::string> blame_lines_;
|
||||
std::vector<BlameLine> blame_lines_;
|
||||
std::vector<std::string> history_lines_;
|
||||
|
||||
void reload(RepositoryView& repository, GitManager& manager, std::string& notice);
|
||||
void loadSupplement(RepositoryView& repository, GitManager& manager, Mode mode, std::string& notice);
|
||||
void parseDiff(const std::string& text);
|
||||
void parseBlame(const std::string& text);
|
||||
};
|
||||
|
||||
2424
src/ui/gitree_ui.cpp
2424
src/ui/gitree_ui.cpp
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,98 @@
|
||||
|
||||
#include "managers/avatar_cache.h"
|
||||
#include "models/repository.h"
|
||||
#include <IconsFontAwesome6.h>
|
||||
#include <IconsTabler.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
|
||||
template <size_t Count>
|
||||
void drawRoundedPolyline(ImDrawList* draw, const std::array<ImVec2, Count>& points,
|
||||
float radius, ImU32 color, float thickness) {
|
||||
static_assert(Count >= 2);
|
||||
draw->PathClear();
|
||||
draw->PathLineTo(points.front());
|
||||
for (size_t index = 1; index + 1 < Count; ++index) {
|
||||
const ImVec2 previous = points[index - 1];
|
||||
const ImVec2 corner = points[index];
|
||||
const ImVec2 next = points[index + 1];
|
||||
const float incoming_length = std::hypot(corner.x - previous.x, corner.y - previous.y);
|
||||
const float outgoing_length = std::hypot(next.x - corner.x, next.y - corner.y);
|
||||
if (incoming_length < 0.01f || outgoing_length < 0.01f) continue;
|
||||
const float turn_radius = std::min({radius, incoming_length * 0.5f, outgoing_length * 0.5f});
|
||||
const ImVec2 incoming{
|
||||
(corner.x - previous.x) / incoming_length,
|
||||
(corner.y - previous.y) / incoming_length,
|
||||
};
|
||||
const ImVec2 outgoing{
|
||||
(next.x - corner.x) / outgoing_length,
|
||||
(next.y - corner.y) / outgoing_length,
|
||||
};
|
||||
const ImVec2 before{
|
||||
corner.x - incoming.x * turn_radius,
|
||||
corner.y - incoming.y * turn_radius,
|
||||
};
|
||||
const ImVec2 after{
|
||||
corner.x + outgoing.x * turn_radius,
|
||||
corner.y + outgoing.y * turn_radius,
|
||||
};
|
||||
constexpr float control = 0.55228475f;
|
||||
draw->PathLineTo(before);
|
||||
draw->PathBezierCubicCurveTo(
|
||||
{before.x + incoming.x * turn_radius * control,
|
||||
before.y + incoming.y * turn_radius * control},
|
||||
{after.x - outgoing.x * turn_radius * control,
|
||||
after.y - outgoing.y * turn_radius * control},
|
||||
after);
|
||||
}
|
||||
draw->PathLineTo(points.back());
|
||||
draw->PathStroke(color, ImDrawFlags_None, thickness);
|
||||
}
|
||||
|
||||
void drawOrthogonalEdge(ImDrawList* draw, const ImVec2& child, const ImVec2& parent,
|
||||
ImU32 color, float scale, int route_slot, float detour_x) {
|
||||
const float thickness = 2.0f * scale;
|
||||
const float horizontal = parent.x - child.x;
|
||||
const float vertical = parent.y - child.y;
|
||||
if (vertical <= 0.0f) {
|
||||
draw->AddLine(child, parent, color, thickness);
|
||||
return;
|
||||
}
|
||||
|
||||
if (std::isfinite(detour_x)) {
|
||||
if (std::abs(detour_x - child.x) < 0.5f * scale) {
|
||||
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, parent.y}, parent},
|
||||
7.0f * scale, color, thickness);
|
||||
} else if (std::abs(detour_x - parent.x) < 0.5f * scale) {
|
||||
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, child.y}, parent},
|
||||
7.0f * scale, color, thickness);
|
||||
} else {
|
||||
drawRoundedPolyline(draw,
|
||||
std::array{child, ImVec2{detour_x, child.y}, ImVec2{detour_x, parent.y}, parent},
|
||||
7.0f * scale, color, thickness);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (std::abs(horizontal) < 0.5f * scale) {
|
||||
draw->AddLine(child, parent, color, thickness);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bends remain anchored to commit rows, but use a compact quarter-round turn.
|
||||
if (route_slot == 0)
|
||||
drawRoundedPolyline(draw, std::array{child, ImVec2{child.x, parent.y}, parent},
|
||||
7.0f * scale, color, thickness);
|
||||
else
|
||||
drawRoundedPolyline(draw, std::array{child, ImVec2{parent.x, child.y}, parent},
|
||||
7.0f * scale, color, thickness);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ImU32 GraphRenderer::laneColor(int lane, int alpha) {
|
||||
static constexpr ImVec4 colors[] = {
|
||||
@@ -25,35 +113,47 @@ ImU32 GraphRenderer::laneColor(int lane, int alpha) {
|
||||
float GraphRenderer::requiredWidth(const std::vector<CommitInfo>& commits, float scale) {
|
||||
int maximum_lane = 0;
|
||||
for (const auto& commit : commits) maximum_lane = std::max(maximum_lane, commit.lane);
|
||||
return std::clamp((42.0f + maximum_lane * 18.0f) * scale, 56.0f * scale, 220.0f * scale);
|
||||
// Keep one spare lane available for edges that must route around occupied commit lanes.
|
||||
return std::max((68.0f + maximum_lane * 22.0f) * scale, 82.0f * scale);
|
||||
}
|
||||
|
||||
void GraphRenderer::drawRow(int row, const CommitInfo& commit,
|
||||
const std::vector<CommitInfo>& commits, const std::vector<float>& row_heights,
|
||||
const std::vector<std::vector<int>>& parent_rows, AvatarCache* avatars) const {
|
||||
const std::vector<std::vector<int>>& parent_rows, AvatarCache* avatars,
|
||||
const ImVec2* ref_connector_start) const {
|
||||
ImDrawList* draw = ImGui::GetWindowDrawList();
|
||||
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
const float row_height = row_heights[static_cast<size_t>(row)];
|
||||
const float content_height = std::max(px(1.0f), row_height - ImGui::GetStyle().CellPadding.y * 2.0f);
|
||||
const float lane_spacing = px(18.0f);
|
||||
const float x = origin.x + px(15.0f) + lane_spacing * commit.lane;
|
||||
const float lane_spacing = px(22.0f);
|
||||
const float x = origin.x + px(17.0f) + lane_spacing * commit.lane;
|
||||
const float y = origin.y + content_height * 0.5f;
|
||||
const float cell_right = origin.x + ImGui::GetContentRegionAvail().x;
|
||||
const float row_clip_padding = ImGui::GetStyle().CellPadding.y + px(1.0f);
|
||||
|
||||
// GitKraken-style lane ribbon: a quiet tint carries the branch color through
|
||||
// the rest of the graph column while the far edge remains crisply identifiable.
|
||||
// Start the lane ribbon at the profile circle's centerline. The opaque node backing
|
||||
// masks its left edge, while the vertical inset keeps neighboring row tints separate.
|
||||
const float ribbon_left = std::min(x, cell_right);
|
||||
const float ribbon_top = origin.y + px(1.0f);
|
||||
const float ribbon_bottom = origin.y + content_height - px(1.0f);
|
||||
draw->AddRectFilled(
|
||||
{x, origin.y - row_clip_padding},
|
||||
{cell_right, origin.y + content_height + row_clip_padding},
|
||||
laneColor(commit.lane, 38));
|
||||
{ribbon_left, ribbon_top},
|
||||
{cell_right, ribbon_bottom},
|
||||
laneColor(commit.graph_color, 38));
|
||||
draw->AddLine(
|
||||
{cell_right - px(1.0f), origin.y - row_clip_padding},
|
||||
{cell_right - px(1.0f), origin.y + content_height + row_clip_padding},
|
||||
laneColor(commit.lane, 220), px(2.0f));
|
||||
if (!commit.refs.empty())
|
||||
draw->AddLine({origin.x - ImGui::GetStyle().CellPadding.x, y}, {x, y},
|
||||
laneColor(commit.lane, 150), px(1.0f));
|
||||
{cell_right - px(1.0f), ribbon_top},
|
||||
{cell_right - px(1.0f), ribbon_bottom},
|
||||
laneColor(commit.graph_color, 215), px(2.0f));
|
||||
if (ref_connector_start) {
|
||||
const ImVec2 end{x, y};
|
||||
const float turn_x = end.x - px(8.0f);
|
||||
draw->PushClipRectFullScreen();
|
||||
drawRoundedPolyline(draw,
|
||||
std::array{*ref_connector_start, ImVec2{turn_x, ref_connector_start->y},
|
||||
ImVec2{turn_x, end.y}, end},
|
||||
px(5.0f), laneColor(commit.graph_color, 190), px(1.8f));
|
||||
draw->PopClipRect();
|
||||
}
|
||||
|
||||
std::vector<float> row_offsets(row_heights.size() + 1, 0.0f);
|
||||
for (size_t index = 0; index < row_heights.size(); ++index)
|
||||
@@ -72,49 +172,81 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
|
||||
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)];
|
||||
for (const int parent_row : parent_rows[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)];
|
||||
|
||||
const float child_x = origin.x + px(15.0f) + lane_spacing * child.lane;
|
||||
const float parent_x = origin.x + px(15.0f) + lane_spacing * parent.lane;
|
||||
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);
|
||||
// An edge belongs to the branch it leaves, including the curved transition
|
||||
// into a parent lane, so its color stays consistent with the child node/ref chip.
|
||||
const ImU32 color = laneColor(child.lane, 225);
|
||||
if (child.lane == parent.lane) {
|
||||
draw->AddLine({child_x, child_y}, {parent_x, parent_y}, color, px(1.8f));
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
|
||||
const float bend_height = std::min(parent_y - child_y, px(24.0f));
|
||||
const float curve_end_y = child_y + bend_height;
|
||||
draw->AddBezierCubic(
|
||||
{child_x, child_y},
|
||||
{child_x, child_y + bend_height * 0.45f},
|
||||
{parent_x, child_y + bend_height * 0.55f},
|
||||
{parent_x, curve_end_y},
|
||||
color, px(1.8f));
|
||||
if (curve_end_y < parent_y)
|
||||
draw->AddLine({parent_x, curve_end_y}, {parent_x, parent_y}, color, px(1.8f));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
draw->PopClipRect();
|
||||
|
||||
const ImU32 lane_color = laneColor(commit.lane);
|
||||
const ImU32 lane_color = laneColor(commit.graph_color);
|
||||
const auto show_identity_tooltip = [&](float hit_radius) {
|
||||
if (!ImGui::IsWindowHovered()) return;
|
||||
const ImVec2 mouse = ImGui::GetIO().MousePos;
|
||||
const float delta_x = mouse.x - x;
|
||||
const float delta_y = mouse.y - y;
|
||||
if (delta_x * delta_x + delta_y * delta_y > hit_radius * hit_radius) return;
|
||||
if (commit.email.empty()) ImGui::SetTooltip("%s", commit.author.c_str());
|
||||
else ImGui::SetTooltip("%s <%s>", commit.author.c_str(), commit.email.c_str());
|
||||
};
|
||||
if (commit.parents > 1) {
|
||||
draw->AddCircleFilled({x, y}, px(4.5f), lane_color);
|
||||
draw->AddCircle({x, y}, px(6.0f), laneColor(commit.lane, 110), 0, px(1.0f));
|
||||
draw->AddCircleFilled({x, y}, px(6.0f), lane_color);
|
||||
draw->AddCircle({x, y}, px(7.5f), laneColor(commit.graph_color, 130), 0, px(1.2f));
|
||||
show_identity_tooltip(px(9.0f));
|
||||
ImGui::Dummy({0.0f, content_height});
|
||||
return;
|
||||
}
|
||||
|
||||
const float radius = px(8.4f);
|
||||
const float radius = px(9.6f);
|
||||
// The opaque backing masks every lane segment before the avatar is painted.
|
||||
draw->AddCircleFilled({x, y}, radius + px(2.2f), IM_COL32(19, 24, 31, 255));
|
||||
draw->AddCircle({x, y}, radius + px(1.0f), lane_color, 0, px(2.0f));
|
||||
draw->AddCircleFilled({x, y}, radius + px(1.8f), IM_COL32(19, 24, 31, 255));
|
||||
draw->AddCircle({x, y}, radius + px(0.8f), lane_color, 0, px(2.0f));
|
||||
draw->AddCircleFilled({x, y}, radius - px(1.0f), IM_COL32(232, 238, 242, 255));
|
||||
|
||||
const unsigned int texture = avatars ? avatars->textureFor(commit.email) : 0;
|
||||
@@ -124,8 +256,9 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
|
||||
{x + radius - px(1.5f), y + radius - px(1.5f)},
|
||||
{0, 0}, {1, 1}, IM_COL32_WHITE, radius);
|
||||
} else {
|
||||
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_FA_USER);
|
||||
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_FA_USER);
|
||||
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_TB_USER);
|
||||
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_TB_USER);
|
||||
}
|
||||
show_identity_tooltip(radius + px(2.0f));
|
||||
ImGui::Dummy({0.0f, content_height});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public:
|
||||
|
||||
void drawRow(int row, const CommitInfo& commit, const std::vector<CommitInfo>& commits,
|
||||
const std::vector<float>& row_heights, const std::vector<std::vector<int>>& parent_rows,
|
||||
AvatarCache* avatars) const;
|
||||
AvatarCache* avatars, const ImVec2* ref_connector_start = nullptr) const;
|
||||
|
||||
private:
|
||||
float px(float value) const { return value * scale_; }
|
||||
|
||||
BIN
vendor/fonts/Inter-Regular.ttf
vendored
Normal file
BIN
vendor/fonts/Inter-Regular.ttf
vendored
Normal file
Binary file not shown.
BIN
vendor/fonts/Inter-SemiBold.ttf
vendored
Normal file
BIN
vendor/fonts/Inter-SemiBold.ttf
vendored
Normal file
Binary file not shown.
BIN
vendor/fonts/tabler-icons-outline.ttf
vendored
BIN
vendor/fonts/tabler-icons-outline.ttf
vendored
Binary file not shown.
2
vendor/glfw
vendored
2
vendor/glfw
vendored
Submodule vendor/glfw updated: 7b6aead9fb...567b1ec244
2
vendor/iZo
vendored
2
vendor/iZo
vendored
Submodule vendor/iZo updated: 80c6bfce90...355a757a17
59
vendor/icons/IconsTabler.h
vendored
Normal file
59
vendor/icons/IconsTabler.h
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
// Tabler Icons 3.44.0 outline webfont glyphs used by Gitree.
|
||||
#define ICON_MIN_TB 0xEA06
|
||||
#define ICON_MAX_TB 0xFAF7
|
||||
|
||||
#define ICON_TB_ANGLE_RIGHT "\xee\xa9\xa1"
|
||||
#define ICON_TB_ARROW_DOWN "\xee\xa8\x96"
|
||||
#define ICON_TB_ARROW_DOWN_A_Z "\xee\xbc\x98"
|
||||
#define ICON_TB_ARROW_DOWN_Z_A "\xee\xbc\x9a"
|
||||
#define ICON_TB_ARROW_RIGHT_ARROW_LEFT "\xef\x87\xb4"
|
||||
#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_BOX_ARCHIVE "\xee\xa8\x8b"
|
||||
#define ICON_TB_BOX_OPEN "\xef\x81\xba"
|
||||
#define ICON_TB_CARET_DOWN "\xee\xad\x9d"
|
||||
#define ICON_TB_CHECK "\xee\xa9\x9e"
|
||||
#define ICON_TB_CHEVRON_DOWN "\xee\xa9\x9f"
|
||||
#define ICON_TB_CHEVRON_RIGHT "\xee\xa9\xa1"
|
||||
#define ICON_TB_CIRCLE_CHEVRON_LEFT "\xef\x98\xa3"
|
||||
#define ICON_TB_CIRCLE_CHEVRON_RIGHT "\xef\x98\xa4"
|
||||
#define ICON_TB_CIRCLE_DOT "\xee\xbe\xb1"
|
||||
#define ICON_TB_CIRCLE_NODES "\xee\xaa\xb5"
|
||||
#define ICON_TB_CLOCK_ROTATE_LEFT "\xee\xaf\xaa"
|
||||
#define ICON_TB_CLOUD "\xee\xa9\xb6"
|
||||
#define ICON_TB_CODE "\xee\xa9\xb7"
|
||||
#define ICON_TB_CODE_BRANCH "\xee\xaa\xb2"
|
||||
#define ICON_TB_COMPUTER "\xee\xaa\x89"
|
||||
#define ICON_TB_COPY "\xee\xa9\xba"
|
||||
#define ICON_TB_CUBES "\xee\xb8\x97"
|
||||
#define ICON_TB_DESKTOP "\xee\xaa\x89"
|
||||
#define ICON_TB_DEVICE_LAPTOP "\xee\xad\xa4"
|
||||
#define ICON_TB_DOWNLOAD "\xee\xaa\x96"
|
||||
#define ICON_TB_EYE "\xee\xaa\x9a"
|
||||
#define ICON_TB_FILE "\xee\xaa\xa4"
|
||||
#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_LAYERS_LINKED "\xee\xba\xa1"
|
||||
#define ICON_TB_MAGNIFYING_GLASS "\xee\xac\x9c"
|
||||
#define ICON_TB_MINUS "\xee\xab\xb2"
|
||||
#define ICON_TB_PEN "\xee\xac\x84"
|
||||
#define ICON_TB_PLUS "\xee\xac\x8b"
|
||||
#define ICON_TB_ROBOT "\xef\x80\x8b"
|
||||
#define ICON_TB_ROTATE_LEFT "\xee\xac\x96"
|
||||
#define ICON_TB_ROTATE_RIGHT "\xee\xac\x95"
|
||||
#define ICON_TB_SERVER "\xee\xac\x9f"
|
||||
#define ICON_TB_TAG "\xee\xbe\x86"
|
||||
#define ICON_TB_TERMINAL "\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"
|
||||
#define ICON_TB_UPLOAD "\xee\xad\x87"
|
||||
#define ICON_TB_USER "\xee\xad\x8d"
|
||||
#define ICON_TB_WINDOW_MAXIMIZE "\xee\xab\xaa"
|
||||
#define ICON_TB_XMARK "\xee\xad\x95"
|
||||
2
vendor/imgui
vendored
2
vendor/imgui
vendored
Submodule vendor/imgui updated: b61e56346a...d15966ff6c
2
vendor/libgit2
vendored
2
vendor/libgit2
vendored
Submodule vendor/libgit2 updated: f7164261c9...44c05e5d12
Reference in New Issue
Block a user