afc15680e1
- GitManager: fileHistory (список версий с hash/time/message/author), fileContentAt, fileDiffHtml/Text (git_patch_from_buffers, цветной HTML), restoreFile (запись + автокоммит), lastModifiedTime/String, autoCommit, ensureGitignore (.flora/.stfolder)
- HistoryDialog.qml: список версий (время «только что/5 мин назад» + дата dd.MM.yyyy, сообщение, автор, shortHash), diff с легендой (зелёный +/красный -/синий заголовок), превью содержимого, кнопка «Восстановить эту версию» с подтверждением (объясняет что создастся новый коммит и можно вернуться вперёд), «Обновить/Закрыть»
- MainWindow: меню Правка → История файла (Ctrl+H) и Вернуться к предыдущей версии, FileTree ПКМ → История, передача vaultPath/filePath
- EditorPanel: индикатор «🕘» + lastModifiedStr (gitManager.lastModifiedTimeString), debounce autoCommit (2с) при documentSaved, shortcut Ctrl+H
- FileTreePanel: сигнал historyRequested
- CMake: добавлен HistoryDialog.qml в qml модуль
- Тесты: git_history_test — создание v1/v2, история 1→2, diff, восстановление назад к v1 (история 3), вперёд к v2 (4), v3, diff v1→v3, bad hash, other.md не влияет на историю note.md, autoCommit, lastModified, .gitignore; 21/21 зелёных
- Проверено вперёд/назад: v1→v2→восстановление v1→восстановление v2→v3→autoCommit, каждый шаг с проверкой содержимого и истории
671 lines
26 KiB
C++
671 lines
26 KiB
C++
#include "git/GitManager.h"
|
|
|
|
#include <QDateTime>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QSaveFile>
|
|
#include <QTimeZone>
|
|
#include <QVariantMap>
|
|
|
|
namespace {
|
|
|
|
QString gitErr()
|
|
{
|
|
const git_error *e = git_error_last();
|
|
return e && e->message ? QString::fromUtf8(e->message) : QStringLiteral("неизвестная ошибка git");
|
|
}
|
|
|
|
struct CommitInfo {
|
|
git_signature *author = nullptr;
|
|
git_index *index = nullptr;
|
|
git_repository *repo = nullptr;
|
|
git_oid treeId{};
|
|
git_oid commitId{};
|
|
~CommitInfo()
|
|
{
|
|
if (author) git_signature_free(author);
|
|
if (index) git_index_free(index);
|
|
if (repo) git_repository_free(repo);
|
|
}
|
|
};
|
|
|
|
QString timeAgoString(const QDateTime &dt)
|
|
{
|
|
const qint64 secs = dt.secsTo(QDateTime::currentDateTime());
|
|
if (secs < 60) return QStringLiteral("только что");
|
|
if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60);
|
|
if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600);
|
|
if (secs < 604800) return QStringLiteral("%1 дн назад").arg(secs/86400);
|
|
// старше недели — показываем дату
|
|
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
|
|
}
|
|
|
|
QString escHtml(const QString &s)
|
|
{
|
|
QString out;
|
|
out.reserve(s.size());
|
|
for (const QChar &c : s) {
|
|
switch (c.unicode()) {
|
|
case '&': out += "&"; break;
|
|
case '<': out += "<"; break;
|
|
case '>': out += ">"; break;
|
|
case '"': out += """; break;
|
|
case '\'': out += "'"; break;
|
|
default: out += c; break;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool GitManager::s_gitInitialized = false;
|
|
|
|
GitManager::GitManager(QObject *parent)
|
|
: QObject(parent)
|
|
{
|
|
if (!s_gitInitialized) {
|
|
git_libgit2_init();
|
|
s_gitInitialized = true;
|
|
}
|
|
}
|
|
|
|
QString GitManager::repoExists(const QString &path)
|
|
{
|
|
const QString clean = QDir::cleanPath(path);
|
|
git_repository *repo = nullptr;
|
|
if (git_repository_open(&repo, clean.toUtf8().constData()) < 0)
|
|
return QString();
|
|
git_repository_free(repo);
|
|
return clean;
|
|
}
|
|
|
|
void GitManager::setRepoIdentity(git_repository *repo)
|
|
{
|
|
git_config *cfg = nullptr;
|
|
if (git_repository_config(&cfg, repo) < 0)
|
|
return;
|
|
|
|
auto setIfMissing = [cfg](const char *key, const char *value) {
|
|
git_buf buf{};
|
|
if (git_config_get_string_buf(&buf, cfg, key) < 0)
|
|
git_config_set_string(cfg, key, value);
|
|
git_buf_dispose(&buf);
|
|
};
|
|
setIfMissing("user.name", "Flora");
|
|
setIfMissing("user.email", "flora@localhost");
|
|
git_config_free(cfg);
|
|
}
|
|
|
|
void GitManager::setLastError(const QString &msg)
|
|
{
|
|
if (m_lastError == msg)
|
|
return;
|
|
m_lastError = msg;
|
|
emit lastErrorChanged();
|
|
}
|
|
|
|
QString GitManager::relativePath(const QString &vaultPath, const QString &filePath) const
|
|
{
|
|
const QString vault = QDir::cleanPath(vaultPath);
|
|
const QString file = QDir::cleanPath(filePath);
|
|
if (file == vault) return QString();
|
|
if (!file.startsWith(vault + '/')) return QString();
|
|
QString rel = file.mid(vault.length());
|
|
while (rel.startsWith("/") || rel.startsWith("\\"))
|
|
rel.remove(0,1);
|
|
return rel;
|
|
}
|
|
|
|
void GitManager::ensureGitignore(const QString &vaultPath)
|
|
{
|
|
const QString gi = QDir(vaultPath).filePath(".gitignore");
|
|
const QStringList needed = {".flora/", ".stfolder", ".stfolder/", "Thumbs.db", ".DS_Store", "*.tmp", "*.log"};
|
|
QString existing;
|
|
if (QFile::exists(gi)) {
|
|
QFile f(gi);
|
|
if (f.open(QIODevice::ReadOnly))
|
|
existing = QString::fromUtf8(f.readAll());
|
|
} else {
|
|
// создаём файл
|
|
}
|
|
bool changed = false;
|
|
QStringList lines = existing.split('\n');
|
|
for (const QString &need : needed) {
|
|
const QString trimmed = need.trimmed();
|
|
bool found = false;
|
|
for (const QString &l : lines) {
|
|
if (l.trimmed() == trimmed) { found = true; break; }
|
|
}
|
|
if (!found) {
|
|
if (!existing.isEmpty() && !existing.endsWith('\n'))
|
|
existing += '\n';
|
|
existing += trimmed + "\n";
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
QFile f(gi);
|
|
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
f.write(existing.toUtf8());
|
|
f.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
bool GitManager::initRepo(const QString &path)
|
|
{
|
|
const QString clean = QDir::cleanPath(path.trimmed());
|
|
if (clean.isEmpty()) {
|
|
setLastError(QStringLiteral("Путь к хранилищу пустой"));
|
|
return false;
|
|
}
|
|
const QByteArray p = clean.toUtf8();
|
|
git_repository *repo = nullptr;
|
|
|
|
if (git_repository_open(&repo, p.constData()) == 0) {
|
|
setRepoIdentity(repo);
|
|
git_repository_free(repo);
|
|
ensureGitignore(clean);
|
|
setLastError(QString());
|
|
return true; // .git уже есть
|
|
}
|
|
|
|
git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT;
|
|
opts.flags = GIT_REPOSITORY_INIT_MKPATH | GIT_REPOSITORY_INIT_MKDIR;
|
|
opts.initial_head = "main";
|
|
|
|
const int rc = git_repository_init_ext(&repo, p.constData(), &opts);
|
|
if (rc < 0) {
|
|
setLastError(QStringLiteral("Не удалось создать git-репозиторий: %1")
|
|
.arg(gitErr()));
|
|
return false;
|
|
}
|
|
setRepoIdentity(repo);
|
|
git_repository_free(repo);
|
|
ensureGitignore(clean);
|
|
setLastError(QString());
|
|
return true;
|
|
}
|
|
|
|
bool GitManager::hasCommits(const QString &path) const
|
|
{
|
|
const QString clean = QDir::cleanPath(path);
|
|
git_repository *repo = nullptr;
|
|
if (git_repository_open(&repo, clean.toUtf8().constData()) < 0)
|
|
return false;
|
|
const bool has = git_repository_head_unborn(repo) == 0;
|
|
git_repository_free(repo);
|
|
return has;
|
|
}
|
|
|
|
QString GitManager::fileGitStatus(const QString &vaultPath, const QString &filePath) const
|
|
{
|
|
if (vaultPath.isEmpty() || filePath.isEmpty())
|
|
return QStringLiteral("—");
|
|
const QString vault = QDir::cleanPath(vaultPath);
|
|
const QString file = QDir::cleanPath(filePath);
|
|
if (!file.startsWith(vault + '/') && file != vault)
|
|
return QStringLiteral("—");
|
|
git_repository *repo = nullptr;
|
|
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
|
return QStringLiteral("—");
|
|
|
|
// относительный путь внутри репозитория
|
|
QString rel = file;
|
|
if (rel == vault) {
|
|
git_repository_free(repo);
|
|
return QStringLiteral("—");
|
|
}
|
|
if (rel.startsWith(vault)) {
|
|
rel = rel.mid(vault.length());
|
|
while (rel.startsWith("/") || rel.startsWith("\\"))
|
|
rel.remove(0, 1);
|
|
}
|
|
if (rel.isEmpty()) {
|
|
git_repository_free(repo);
|
|
return QStringLiteral("—");
|
|
}
|
|
|
|
unsigned int status = 0;
|
|
const int rc = git_status_file(&status, repo, rel.toUtf8().constData());
|
|
git_repository_free(repo);
|
|
|
|
if (rc != 0) {
|
|
// файл не найден в индексе или ошибка — считаем неотслеживаемым если файл существует
|
|
if (QFile::exists(filePath))
|
|
return QStringLiteral("неотслеживается");
|
|
return QStringLiteral("—");
|
|
}
|
|
|
|
if (status == GIT_STATUS_CURRENT)
|
|
return QStringLiteral("чисто");
|
|
if (status & GIT_STATUS_CONFLICTED)
|
|
return QStringLiteral("конфликт");
|
|
if (status & GIT_STATUS_WT_NEW)
|
|
return QStringLiteral("неотслеживается");
|
|
if (status & GIT_STATUS_INDEX_NEW)
|
|
return QStringLiteral("в индексе (новый)");
|
|
if (status & GIT_STATUS_INDEX_MODIFIED)
|
|
return QStringLiteral("в индексе");
|
|
if (status & GIT_STATUS_WT_MODIFIED)
|
|
return QStringLiteral("изменён");
|
|
if (status & GIT_STATUS_WT_DELETED)
|
|
return QStringLiteral("удалён");
|
|
if (status & GIT_STATUS_IGNORED)
|
|
return QStringLiteral("игнорируется");
|
|
return QStringLiteral("чисто");
|
|
}
|
|
|
|
QString GitManager::fileGitStatusShort(const QString &vaultPath, const QString &filePath) const
|
|
{
|
|
const QString s = fileGitStatus(vaultPath, filePath);
|
|
if (s == "чисто") return QStringLiteral("чисто");
|
|
if (s == "изменён") return QStringLiteral("изменён");
|
|
if (s == "неотслеживается") return QStringLiteral("новый");
|
|
if (s.contains("конфликт")) return QStringLiteral("конфликт");
|
|
if (s == "—") return QStringLiteral("—");
|
|
return s;
|
|
}
|
|
|
|
bool GitManager::commitAll(const QString &path, const QString &message)
|
|
{
|
|
const QString cleanPath = QDir::cleanPath(path);
|
|
CommitInfo info;
|
|
if (git_repository_open(&info.repo, cleanPath.toUtf8().constData()) < 0) {
|
|
setLastError(QStringLiteral("Хранилище не является git-репозиторием"));
|
|
return false;
|
|
}
|
|
|
|
if (git_repository_index(&info.index, info.repo) < 0) {
|
|
setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr()));
|
|
return false;
|
|
}
|
|
|
|
// Добавляем все файлы, включая новые и удаления (с учётом .gitignore)
|
|
git_index_add_all(info.index, nullptr, GIT_INDEX_ADD_DEFAULT, nullptr, nullptr);
|
|
const int wrc = git_index_write(info.index);
|
|
if (wrc < 0) {
|
|
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
|
|
return false;
|
|
}
|
|
|
|
// Не проверяем entrycount==0 отдельно — пустой индекс может означать удаление всех файлов,
|
|
// что тоже нужно коммитить. Проверяем равенство дерева ниже.
|
|
|
|
// Создаём дерево
|
|
git_tree *tree = nullptr;
|
|
if (git_index_write_tree(&info.treeId, info.index) < 0
|
|
|| git_tree_lookup(&tree, info.repo, &info.treeId) < 0) {
|
|
setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr()));
|
|
return false;
|
|
}
|
|
|
|
// Родитель: head, если коммиты уже есть
|
|
git_reference *headRef = nullptr;
|
|
git_commit *parentCommit = nullptr;
|
|
int nParents = 0;
|
|
if (git_repository_head(&headRef, info.repo) == 0) {
|
|
git_object *obj = nullptr;
|
|
if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) {
|
|
parentCommit = reinterpret_cast<git_commit *>(obj);
|
|
nParents = 1;
|
|
}
|
|
if (headRef) git_reference_free(headRef);
|
|
}
|
|
|
|
// Если дерево не изменилось относительно родителя — коммитить нечего.
|
|
if (parentCommit) {
|
|
const git_oid *prevTree = git_commit_tree_id(parentCommit);
|
|
if (prevTree && git_oid_equal(prevTree, &info.treeId)) {
|
|
git_commit_free(parentCommit);
|
|
git_tree_free(tree);
|
|
setLastError(QStringLiteral("Нет изменений для коммита"));
|
|
return false;
|
|
}
|
|
}
|
|
const git_commit *parents[1] = {parentCommit};
|
|
|
|
// Подпись из конфига репозитория
|
|
git_signature *author = nullptr;
|
|
if (git_signature_default(&author, info.repo) < 0) {
|
|
git_commit_free(parentCommit);
|
|
git_tree_free(tree);
|
|
setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr()));
|
|
return false;
|
|
}
|
|
info.author = author;
|
|
|
|
const int crc = git_commit_create(&info.commitId, info.repo, "HEAD",
|
|
author, author, "UTF-8",
|
|
message.toUtf8().constData(),
|
|
tree, nParents, parents);
|
|
|
|
if (parentCommit) git_commit_free(parentCommit);
|
|
git_tree_free(tree);
|
|
|
|
if (crc < 0) {
|
|
setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr()));
|
|
return false;
|
|
}
|
|
|
|
setLastError(QString());
|
|
return true;
|
|
}
|
|
|
|
QVariantList GitManager::fileHistory(const QString &vaultPath, const QString &filePath) const
|
|
{
|
|
QVariantList out;
|
|
const QString vault = QDir::cleanPath(vaultPath);
|
|
const QString rel = relativePath(vaultPath, filePath);
|
|
if (vault.isEmpty() || rel.isEmpty())
|
|
return out;
|
|
git_repository *repo = nullptr;
|
|
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
|
return out;
|
|
if (git_repository_head_unborn(repo)) {
|
|
git_repository_free(repo);
|
|
return out;
|
|
}
|
|
git_revwalk *walk = nullptr;
|
|
if (git_revwalk_new(&walk, repo) < 0) {
|
|
git_repository_free(repo);
|
|
return out;
|
|
}
|
|
git_revwalk_sorting(walk, GIT_SORT_TIME);
|
|
git_revwalk_push_head(walk);
|
|
|
|
git_oid oid;
|
|
int iter = 0;
|
|
const int maxIter = 500; // ограничение для скорости
|
|
while (git_revwalk_next(&oid, walk) == 0 && iter < maxIter) {
|
|
++iter;
|
|
git_commit *commit = nullptr;
|
|
if (git_commit_lookup(&commit, repo, &oid) < 0)
|
|
continue;
|
|
// Проверяем был ли файл в этом коммите и менялся ли он относительно родителя
|
|
bool relevant = false;
|
|
QString fileContent;
|
|
// Получаем дерево коммита
|
|
git_tree *tree = nullptr;
|
|
if (git_commit_tree(&tree, commit) == 0) {
|
|
git_tree_entry *entry = nullptr;
|
|
if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) {
|
|
// файл есть в этом коммите
|
|
// Сравниваем с родителем
|
|
if (git_commit_parentcount(commit) == 0) {
|
|
relevant = true; // первый коммит где файл появился
|
|
} else {
|
|
git_commit *parent = nullptr;
|
|
if (git_commit_parent(&parent, commit, 0) == 0) {
|
|
git_tree *parentTree = nullptr;
|
|
if (git_commit_tree(&parentTree, parent) == 0) {
|
|
git_tree_entry *parentEntry = nullptr;
|
|
if (git_tree_entry_bypath(&parentEntry, parentTree, rel.toUtf8().constData()) == 0) {
|
|
// оба есть — сравниваем oid
|
|
const git_oid *a = git_tree_entry_id(entry);
|
|
const git_oid *b = git_tree_entry_id(parentEntry);
|
|
if (!git_oid_equal(a, b))
|
|
relevant = true;
|
|
git_tree_entry_free(parentEntry);
|
|
} else {
|
|
// файл появился в этом коммите (не было в родителе)
|
|
relevant = true;
|
|
}
|
|
git_tree_free(parentTree);
|
|
}
|
|
git_commit_free(parent);
|
|
} else {
|
|
relevant = true;
|
|
}
|
|
}
|
|
git_tree_entry_free(entry);
|
|
}
|
|
git_tree_free(tree);
|
|
}
|
|
if (!relevant) {
|
|
git_commit_free(commit);
|
|
continue;
|
|
}
|
|
|
|
char hashStr[41] = {0};
|
|
git_oid_tostr(hashStr, sizeof(hashStr), &oid);
|
|
QString hash = QString::fromUtf8(hashStr);
|
|
QString shortHash = hash.left(7);
|
|
const char *msg = git_commit_message(commit);
|
|
QString message = msg ? QString::fromUtf8(msg).split('\n').first().trimmed() : QString();
|
|
if (message.isEmpty()) message = QStringLiteral("(без сообщения)");
|
|
if (message.length() > 80) message = message.left(80) + "...";
|
|
|
|
const git_signature *author = git_commit_author(commit);
|
|
QString authorName = author && author->name ? QString::fromUtf8(author->name) : QStringLiteral("Flora");
|
|
git_time_t t = git_commit_time(commit);
|
|
int offset = git_commit_time_offset(commit);
|
|
QDateTime dt = QDateTime::fromSecsSinceEpoch((qint64)t, QTimeZone(offset*60));
|
|
// Если смещение 0 — используем локальное
|
|
if (!dt.isValid())
|
|
dt = QDateTime::fromSecsSinceEpoch((qint64)t);
|
|
|
|
QVariantMap m;
|
|
m["hash"] = hash;
|
|
m["shortHash"] = shortHash;
|
|
m["message"] = message;
|
|
m["author"] = authorName;
|
|
m["time"] = dt;
|
|
m["timeStr"] = dt.toString(QStringLiteral("dd.MM.yyyy hh:mm:ss"));
|
|
m["timeAgo"] = timeAgoString(dt);
|
|
m["hasContent"] = true;
|
|
out.append(m);
|
|
git_commit_free(commit);
|
|
}
|
|
git_revwalk_free(walk);
|
|
git_repository_free(repo);
|
|
return out;
|
|
}
|
|
|
|
QString GitManager::fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
|
{
|
|
const QString vault = QDir::cleanPath(vaultPath);
|
|
const QString rel = relativePath(vaultPath, filePath);
|
|
if (vault.isEmpty() || rel.isEmpty() || commitHash.isEmpty())
|
|
return QString();
|
|
git_repository *repo = nullptr;
|
|
if (git_repository_open(&repo, vault.toUtf8().constData()) < 0)
|
|
return QString();
|
|
git_oid oid;
|
|
if (git_oid_fromstr(&oid, commitHash.toUtf8().constData()) < 0) {
|
|
git_repository_free(repo);
|
|
return QString();
|
|
}
|
|
git_commit *commit = nullptr;
|
|
if (git_commit_lookup(&commit, repo, &oid) < 0) {
|
|
git_repository_free(repo);
|
|
return QString();
|
|
}
|
|
git_tree *tree = nullptr;
|
|
QString result;
|
|
if (git_commit_tree(&tree, commit) == 0) {
|
|
git_tree_entry *entry = nullptr;
|
|
if (git_tree_entry_bypath(&entry, tree, rel.toUtf8().constData()) == 0) {
|
|
const git_oid *blobOid = git_tree_entry_id(entry);
|
|
git_blob *blob = nullptr;
|
|
if (git_blob_lookup(&blob, repo, blobOid) == 0) {
|
|
const void *raw = git_blob_rawcontent(blob);
|
|
git_off_t len = git_blob_rawsize(blob);
|
|
result = QString::fromUtf8((const char*)raw, (int)len);
|
|
git_blob_free(blob);
|
|
}
|
|
git_tree_entry_free(entry);
|
|
}
|
|
git_tree_free(tree);
|
|
}
|
|
git_commit_free(commit);
|
|
git_repository_free(repo);
|
|
return result;
|
|
}
|
|
|
|
QString GitManager::fileDiffText(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
|
{
|
|
const QString oldContent = fileContentAt(vaultPath, filePath, commitHash);
|
|
QFile f(filePath);
|
|
QString newContent;
|
|
if (f.open(QIODevice::ReadOnly))
|
|
newContent = QString::fromUtf8(f.readAll());
|
|
else
|
|
newContent = QString();
|
|
|
|
if (oldContent == newContent)
|
|
return QStringLiteral("Нет изменений");
|
|
|
|
// Используем git_patch_from_buffers для диффа
|
|
git_patch *patch = nullptr;
|
|
const QByteArray oldBytes = oldContent.toUtf8();
|
|
const QByteArray newBytes = newContent.toUtf8();
|
|
const QString rel = relativePath(vaultPath, filePath);
|
|
const QByteArray relBytes = rel.toUtf8();
|
|
git_patch_from_buffers(&patch,
|
|
oldBytes.constData(), (size_t)oldBytes.size(), relBytes.constData(),
|
|
newBytes.constData(), (size_t)newBytes.size(), relBytes.constData(),
|
|
nullptr);
|
|
if (!patch)
|
|
return QStringLiteral("Не удалось построить diff");
|
|
|
|
QString out;
|
|
// Заголовок
|
|
out += QStringLiteral("--- a/%1\n").arg(rel);
|
|
out += QStringLiteral("+++ b/%1\n").arg(rel);
|
|
|
|
size_t hunks = git_patch_num_hunks(patch);
|
|
for (size_t h = 0; h < hunks; ++h) {
|
|
const git_diff_hunk *hk = nullptr;
|
|
size_t hunkLines = 0;
|
|
if (git_patch_get_hunk(&hk, &hunkLines, patch, h) < 0) continue;
|
|
out += QString::fromUtf8(hk->header, (int)hk->header_len);
|
|
if (!out.endsWith('\n')) out += '\n';
|
|
for (size_t l = 0; l < hunkLines; ++l) {
|
|
const git_diff_line *line = nullptr;
|
|
if (git_patch_get_line_in_hunk(&line, patch, h, l) < 0) continue;
|
|
QChar origin = QChar(line->origin);
|
|
QString text = QString::fromUtf8(line->content, (int)line->content_len);
|
|
// line->content уже содержит \n в конце
|
|
out += origin + text;
|
|
if (!text.endsWith('\n') && line->content_len > 0) out += '\n';
|
|
}
|
|
}
|
|
git_patch_free(patch);
|
|
if (out.trimmed().isEmpty())
|
|
return QStringLiteral("Нет изменений");
|
|
return out;
|
|
}
|
|
|
|
QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const
|
|
{
|
|
const QString txt = fileDiffText(vaultPath, filePath, commitHash);
|
|
if (txt == QStringLiteral("Нет изменений") || txt.startsWith(QStringLiteral("Не удалось")))
|
|
return QStringLiteral("<p style='color:#888'>%1</p>").arg(escHtml(txt));
|
|
|
|
QString html;
|
|
html.reserve(txt.size()*2);
|
|
html += "<pre style='font-family:Menlo,monospace;font-size:12px;white-space:pre-wrap;word-wrap:break-word;margin:0'>";
|
|
const QStringList lines = txt.split('\n');
|
|
for (const QString &rawLine : lines) {
|
|
if (rawLine.isEmpty()) { html += "\n"; continue; }
|
|
QChar first = rawLine.at(0);
|
|
QString esc = escHtml(rawLine);
|
|
if (first == '+') {
|
|
html += QStringLiteral("<div style='background:#e6ffec;color:#1a7f37'>%1</div>").arg(esc);
|
|
} else if (first == '-') {
|
|
html += QStringLiteral("<div style='background:#ffebe9;color:#cf222e'>%1</div>").arg(esc);
|
|
} else if (first == '@') {
|
|
html += QStringLiteral("<div style='background:#ddf4ff;color:#0969da'>%1</div>").arg(esc);
|
|
} else if (first == ' ') {
|
|
html += QStringLiteral("<div style='color:#24292f'>%1</div>").arg(esc);
|
|
} else {
|
|
html += QStringLiteral("<div style='color:#656d76'>%1</div>").arg(esc);
|
|
}
|
|
}
|
|
html += "</pre>";
|
|
return html;
|
|
}
|
|
|
|
QString GitManager::restoreFile(const QString &vaultPath, const QString &filePath, const QString &commitHash)
|
|
{
|
|
const QString vault = QDir::cleanPath(vaultPath);
|
|
const QString rel = relativePath(vaultPath, filePath);
|
|
if (vault.isEmpty() || rel.isEmpty())
|
|
return QStringLiteral("Неверные пути");
|
|
const QString content = fileContentAt(vaultPath, filePath, commitHash);
|
|
// Если файл не существовал в коммите — удаляем текущий?
|
|
// Для простоты: если контента нет, удаляем файл
|
|
const QString absPath = QDir(vault).filePath(rel);
|
|
if (content.isNull() && content.isEmpty()) {
|
|
// Проверяем был ли файл в коммите вообще
|
|
QVariantList hist = fileHistory(vaultPath, filePath);
|
|
bool found = false;
|
|
for (auto v : hist) if (v.toMap().value("hash").toString() == commitHash) { found = true; break; }
|
|
if (!found) return QStringLiteral("Коммит не найден");
|
|
// Файл не существовал — удаляем если есть
|
|
if (QFile::exists(absPath)) {
|
|
if (!QFile::remove(absPath))
|
|
return QStringLiteral("Не удалось удалить файл: %1").arg(absPath);
|
|
}
|
|
} else {
|
|
QDir().mkpath(QFileInfo(absPath).absolutePath());
|
|
QSaveFile f(absPath);
|
|
if (!f.open(QIODevice::WriteOnly))
|
|
return QStringLiteral("Не удалось записать файл: %1").arg(absPath);
|
|
f.write(content.toUtf8());
|
|
if (!f.commit())
|
|
return QStringLiteral("Не удалось сохранить файл");
|
|
}
|
|
// Коммитим восстановление
|
|
const QString shortHash = commitHash.left(7);
|
|
const QString msg = QStringLiteral("Восстановление %1 из %2").arg(QFileInfo(filePath).fileName(), shortHash);
|
|
if (!commitAll(vault, msg))
|
|
return lastError().isEmpty() ? QStringLiteral("Не удалось закоммитить восстановление") : lastError();
|
|
return QString();
|
|
}
|
|
|
|
QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const
|
|
{
|
|
const QVariantList hist = fileHistory(vaultPath, filePath);
|
|
if (!hist.isEmpty()) {
|
|
QDateTime dt = hist.first().toMap().value("time").toDateTime();
|
|
if (dt.isValid()) return dt;
|
|
}
|
|
QFileInfo fi(filePath);
|
|
if (fi.exists())
|
|
return fi.lastModified();
|
|
return QDateTime();
|
|
}
|
|
|
|
QString GitManager::lastModifiedTimeString(const QString &vaultPath, const QString &filePath) const
|
|
{
|
|
QDateTime dt = lastModifiedTime(vaultPath, filePath);
|
|
if (!dt.isValid()) return QStringLiteral("—");
|
|
// Формат как в истории
|
|
const qint64 secs = dt.secsTo(QDateTime::currentDateTime());
|
|
if (secs < 60) return QStringLiteral("только что");
|
|
if (secs < 3600) return QStringLiteral("%1 мин назад").arg(secs/60);
|
|
if (secs < 86400) return QStringLiteral("%1 ч назад").arg(secs/3600);
|
|
if (secs < 172800) return QStringLiteral("вчера %1").arg(dt.toString("hh:mm"));
|
|
return dt.toString(QStringLiteral("dd.MM.yyyy hh:mm"));
|
|
}
|
|
|
|
bool GitManager::autoCommit(const QString &vaultPath, const QString &filePath)
|
|
{
|
|
if (vaultPath.isEmpty())
|
|
return false;
|
|
const QString fileName = QFileInfo(filePath).fileName();
|
|
const QString msg = fileName.isEmpty()
|
|
? QStringLiteral("Автосохранение")
|
|
: QStringLiteral("Автосохранение: %1").arg(fileName);
|
|
// commitAll вернёт false если нет изменений — это нормально
|
|
bool ok = commitAll(vaultPath, msg);
|
|
if (!ok && lastError() == QStringLiteral("Нет изменений для коммита")) {
|
|
setLastError(QString());
|
|
return true; // считаем успехом
|
|
}
|
|
return ok;
|
|
} |