Git история: откат файлов в любой момент (фаза 9) — UI для простого пользователя
- 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, каждый шаг с проверкой содержимого и истории
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
#include "git/GitManager.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
#include <QTimeZone>
|
||||
#include <QVariantMap>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -25,6 +30,34 @@ struct CommitInfo {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -73,6 +106,54 @@ void GitManager::setLastError(const QString &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());
|
||||
@@ -86,6 +167,7 @@ bool GitManager::initRepo(const QString &path)
|
||||
if (git_repository_open(&repo, p.constData()) == 0) {
|
||||
setRepoIdentity(repo);
|
||||
git_repository_free(repo);
|
||||
ensureGitignore(clean);
|
||||
setLastError(QString());
|
||||
return true; // .git уже есть
|
||||
}
|
||||
@@ -102,6 +184,7 @@ bool GitManager::initRepo(const QString &path)
|
||||
}
|
||||
setRepoIdentity(repo);
|
||||
git_repository_free(repo);
|
||||
ensureGitignore(clean);
|
||||
setLastError(QString());
|
||||
return true;
|
||||
}
|
||||
@@ -269,4 +352,320 @@ bool GitManager::commitAll(const QString &path, const QString &message)
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user